feat: 新增动态表单模块并同步字典与菜单改动
- 表单: 新增定义、数据、快照三表与接口 - 字典: 字典项改为逻辑删除,同字典下值唯一 - 菜单: 移除 alwaysShow,下拉改 types,新增按钮生成与父级校验 - AI: 新增对话服务与提示词 - sql: 同步表结构与菜单权限数据 - 工作流: 新增流程模块与 BPMN 流程
This commit is contained in:
@@ -13,7 +13,7 @@ public interface RedisConstants {
|
||||
*/
|
||||
interface RateLimit {
|
||||
/** 接口级限流 Key(示例:rate_limit:api:{user}:{uri}) */
|
||||
String API = "rate_limit:{}:{}:{}";
|
||||
String API = "rate_limit:api:{}:{}";
|
||||
|
||||
/** IP 全局限流 Key(示例:rate_limit:ip:192.168.1.100) */
|
||||
String IP = "rate_limit:ip:{}";
|
||||
|
||||
@@ -10,6 +10,7 @@ import com.youlai.boot.common.result.Result;
|
||||
import com.youlai.boot.common.exception.BusinessException;
|
||||
import com.youlai.boot.common.model.Option;
|
||||
import com.youlai.boot.framework.web.util.IPUtils;
|
||||
import com.youlai.boot.module.form.model.form.FormAiGenerateForm;
|
||||
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.FormDataPageQuery;
|
||||
@@ -240,4 +241,14 @@ public class FormController {
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
//---------------------------------------------------
|
||||
// AI 辅助接口(未开启 AI 时调用返回业务异常)
|
||||
//---------------------------------------------------
|
||||
|
||||
@Operation(summary = "AI 生成表单规则")
|
||||
@PostMapping("/ai-generate")
|
||||
public Result<String> generateRule(@RequestBody @Valid FormAiGenerateForm form) {
|
||||
return Result.success(formDefinitionService.aiGenerateRule(form.getDescription()));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.youlai.boot.module.form.model.form;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* AI 生成表单入参
|
||||
*
|
||||
* @author Ray.Hao
|
||||
* @since 0.1.0
|
||||
*/
|
||||
@Schema(description = "AI 生成表单入参")
|
||||
@Data
|
||||
public class FormAiGenerateForm {
|
||||
|
||||
@Schema(description = "表单需求描述", example = "请假申请:姓名、请假类型、起止日期、请假事由(必填)、附件")
|
||||
@NotBlank(message = "需求描述不能为空")
|
||||
private String description;
|
||||
}
|
||||
@@ -137,4 +137,12 @@ public interface FormDefinitionService {
|
||||
*/
|
||||
List<Option<String>> listWorkflowFormOptions();
|
||||
|
||||
/**
|
||||
* 用 AI 把需求描述转成 form-create 规则,产出经结构校验后才返回
|
||||
*
|
||||
* @param description 需求描述
|
||||
* @return 通过结构校验的 form-create rule JSON 字符串
|
||||
*/
|
||||
String aiGenerateRule(String description);
|
||||
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ 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 cn.hutool.json.JSONUtil;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
@@ -28,6 +29,10 @@ 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.module.form.util.FormRuleValidator;
|
||||
import com.youlai.boot.module.workflow.service.WorkflowModelService;
|
||||
import com.youlai.boot.support.ai.AiChatService;
|
||||
import com.youlai.boot.support.ai.PromptLoader;
|
||||
import com.youlai.boot.system.enums.MenuTypeEnum;
|
||||
import com.youlai.boot.system.model.entity.Menu;
|
||||
import com.youlai.boot.system.model.entity.RoleMenu;
|
||||
@@ -35,6 +40,7 @@ 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.beans.factory.ObjectProvider;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -42,6 +48,7 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Collectors;
|
||||
@@ -56,12 +63,18 @@ import java.util.stream.Collectors;
|
||||
@RequiredArgsConstructor
|
||||
public class FormDefinitionServiceImpl extends ServiceImpl<FormDefinitionMapper, FormDefinition> implements FormDefinitionService {
|
||||
|
||||
/** 系统提示词名称,对应 resources/prompts 下的相对路径 */
|
||||
private static final String SYSTEM_PROMPT_PATH = "form/system.md";
|
||||
|
||||
private final FormConverter formConverter;
|
||||
private final RedisTemplate<String, Object> redisTemplate;
|
||||
private final MenuService menuService;
|
||||
private final RoleMenuService roleMenuService;
|
||||
private final FormSnapshotMapper formSnapshotMapper;
|
||||
private final FormDataMapper formDataMapper;
|
||||
private final ObjectProvider<WorkflowModelService> workflowModelServiceProvider;
|
||||
private final AiChatService aiChatService;
|
||||
private final PromptLoader promptLoader;
|
||||
|
||||
/** 默认挂载目录名称:发布向导未显式选目录时自动使用/创建 */
|
||||
private static final String DEFAULT_CATALOG_NAME = "表单中心";
|
||||
@@ -181,7 +194,7 @@ public class FormDefinitionServiceImpl extends ServiceImpl<FormDefinitionMapper,
|
||||
/**
|
||||
* 批量删除表单定义
|
||||
*
|
||||
* <p>级联清理关联数据,避免孤儿残留:生成的访问菜单(含其角色授权)、
|
||||
* <p>被流程引用的表单禁止删除;其余级联清理关联数据,避免孤儿残留:生成的访问菜单(含其角色授权)、
|
||||
* 提交数据、版本快照一并删除</p>
|
||||
*
|
||||
* @param ids 表单ID列表
|
||||
@@ -190,6 +203,7 @@ public class FormDefinitionServiceImpl extends ServiceImpl<FormDefinitionMapper,
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void removeFormDefinition(List<Long> ids) {
|
||||
List<FormDefinition> forms = this.listByIds(ids);
|
||||
this.checkProcessReferences(forms);
|
||||
this.removeByIds(ids);
|
||||
for (FormDefinition form : forms) {
|
||||
// 级联删除已生成的访问菜单(deleteMenu 内部同步清理其角色授权并刷新权限缓存)
|
||||
@@ -208,6 +222,31 @@ public class FormDefinitionServiceImpl extends ServiceImpl<FormDefinitionMapper,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验表单是否被流程引用
|
||||
*
|
||||
* <p>被引用的表单删除后,流程发起与审批时无表单可渲染,故在删除前整体拦截</p>
|
||||
*
|
||||
* @param forms 待删除表单
|
||||
*/
|
||||
private void checkProcessReferences(List<FormDefinition> forms) {
|
||||
// 工作流未启用时无流程服务,跳过校验
|
||||
WorkflowModelService workflowModelService = workflowModelServiceProvider.getIfAvailable();
|
||||
if (workflowModelService == null || forms.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Map<String, Set<String>> references = workflowModelService.getFormReferences(
|
||||
forms.stream().map(FormDefinition::getFormKey).collect(Collectors.toSet()));
|
||||
List<String> blocked = forms.stream()
|
||||
.filter(form -> references.containsKey(form.getFormKey()))
|
||||
.map(form -> StrUtil.format("表单【{}】被流程【{}】引用", form.getFormName(),
|
||||
String.join("、", references.get(form.getFormKey()))))
|
||||
.toList();
|
||||
if (!blocked.isEmpty()) {
|
||||
throw new BusinessException(String.join(";", blocked) + ",请先解除流程绑定后删除");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布表单
|
||||
*
|
||||
@@ -452,8 +491,6 @@ public class FormDefinitionServiceImpl extends ServiceImpl<FormDefinitionMapper,
|
||||
menuFormData.setType(MenuTypeEnum.CATALOG.getValue());
|
||||
menuFormData.setRoutePath(underAdminCatalog ? "center" : "/form-center");
|
||||
menuFormData.setComponent("Layout");
|
||||
// alwaysShow=1:角色仅有单个表单入口时父级目录不被侧边栏折叠吞掉
|
||||
menuFormData.setAlwaysShow(1);
|
||||
menuFormData.setVisible(1);
|
||||
menuFormData.setSort(5);
|
||||
// EP 图标(前端永久可渲染),避免空/失效图标名导致菜单显示异常
|
||||
@@ -563,4 +600,31 @@ public class FormDefinitionServiceImpl extends ServiceImpl<FormDefinitionMapper,
|
||||
.toList();
|
||||
}
|
||||
|
||||
//---------------------------------------------------
|
||||
// AI 辅助:未开启 AI 时调用返回业务异常
|
||||
//---------------------------------------------------
|
||||
|
||||
/**
|
||||
* 用 AI 把需求描述转成 form-create 规则
|
||||
*
|
||||
* @param description 需求描述
|
||||
* @return 通过结构校验的 form-create rule JSON 字符串
|
||||
*/
|
||||
@Override
|
||||
public String aiGenerateRule(String description) {
|
||||
List<Map<String, Object>> rules = aiChatService.chatJsonList(
|
||||
promptLoader.get(SYSTEM_PROMPT_PATH), buildAiUserPrompt(description));
|
||||
return FormRuleValidator.validateRule(JSONUtil.toJsonStr(rules));
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造用户提示词
|
||||
*
|
||||
* @param description 需求描述
|
||||
* @return 用户提示词
|
||||
*/
|
||||
private String buildAiUserPrompt(String description) {
|
||||
return "需求描述:\n" + description;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.youlai.boot.module.form.util;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.youlai.boot.common.exception.BusinessException;
|
||||
@@ -59,6 +60,90 @@ public final class FormRuleValidator {
|
||||
return filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验表单规则结构(AI 生成或外部导入的规则先过这一层)
|
||||
*
|
||||
* @param formJson 表单规则(form-create rule 数组 JSON 字符串)
|
||||
* @return 规则原文,便于链式使用
|
||||
*/
|
||||
public static String validateRule(String formJson) {
|
||||
JsonNode rules;
|
||||
try {
|
||||
rules = OBJECT_MAPPER.readTree(formJson);
|
||||
} catch (Exception e) {
|
||||
throw new BusinessException("表单规则解析失败");
|
||||
}
|
||||
if (!rules.isArray() || rules.isEmpty()) {
|
||||
throw new BusinessException("表单规则格式非法:应为非空数组");
|
||||
}
|
||||
checkNodes(rules);
|
||||
return formJson;
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归校验规则节点:每个节点必须有 type,非容器节点必须有 field
|
||||
*
|
||||
* @param nodes 规则节点数组
|
||||
*/
|
||||
private static void checkNodes(JsonNode nodes) {
|
||||
for (JsonNode node : nodes) {
|
||||
if (node == null || !node.isObject()) {
|
||||
throw new BusinessException("表单规则格式非法:节点必须为对象");
|
||||
}
|
||||
if (!node.path("type").isTextual()) {
|
||||
throw new BusinessException("表单规则格式非法:节点缺少 type");
|
||||
}
|
||||
JsonNode children = node.get("children");
|
||||
if (children != null && children.isArray() && !children.isEmpty()) {
|
||||
checkNodes(children);
|
||||
} else if (!node.path("field").isTextual()) {
|
||||
throw new BusinessException("表单规则格式非法:字段节点缺少 field");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析规则里 field -> title 的映射(摘要、导出等场景把字段名换成中文标题)
|
||||
*
|
||||
* @param formJson 表单规则
|
||||
* @return field -> title 映射,规则为空或解析失败时返回空 Map
|
||||
*/
|
||||
public static Map<String, String> resolveTitles(String formJson) {
|
||||
Map<String, String> titles = new LinkedHashMap<>();
|
||||
if (StrUtil.isBlank(formJson)) {
|
||||
return titles;
|
||||
}
|
||||
try {
|
||||
collectTitles(OBJECT_MAPPER.readTree(formJson), titles);
|
||||
} catch (Exception ignored) {
|
||||
return titles;
|
||||
}
|
||||
return titles;
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归收集 field 与 title
|
||||
*
|
||||
* @param nodes 规则节点数组
|
||||
* @param titles 映射结果(累积写入)
|
||||
*/
|
||||
private static void collectTitles(JsonNode nodes, Map<String, String> titles) {
|
||||
for (JsonNode node : nodes) {
|
||||
if (node == null || !node.isObject()) {
|
||||
continue;
|
||||
}
|
||||
JsonNode field = node.get("field");
|
||||
JsonNode title = node.get("title");
|
||||
if (field != null && field.isTextual() && title != null && title.isTextual()) {
|
||||
titles.putIfAbsent(field.asText(), title.asText());
|
||||
}
|
||||
JsonNode children = node.get("children");
|
||||
if (children != null && children.isArray()) {
|
||||
collectTitles(children, titles);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归遍历规则节点:布局容器(row/col)自身无 field,其 children 递归收集
|
||||
*
|
||||
|
||||
@@ -98,4 +98,14 @@ public class WorkflowTaskController {
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
// AI 辅助:未开启 AI 时调用返回业务异常
|
||||
|
||||
@Operation(summary = "AI 生成任务审批摘要")
|
||||
@PostMapping("/{taskId}/ai-summary")
|
||||
public Result<String> summarizeTask(
|
||||
@Parameter(description = "任务ID") @PathVariable String taskId
|
||||
) {
|
||||
return Result.success(workflowTaskService.aiSummarizeTask(taskId));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -7,10 +7,13 @@ import com.youlai.boot.module.workflow.model.query.WorkflowModelPageQuery;
|
||||
import com.youlai.boot.module.workflow.model.vo.ModelVO;
|
||||
import com.youlai.boot.module.workflow.model.vo.ModelXmlVO;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 流程模型业务接口(设计器侧:模型 CRUD、XML 保存、部署)
|
||||
* 流程模型业务接口(设计器侧:模型 CRUD、XML 保存、部署、表单引用查询)
|
||||
*
|
||||
* @author Ray.Hao
|
||||
* @since 4.9.0
|
||||
@@ -43,12 +46,20 @@ public interface WorkflowModelService {
|
||||
boolean updateModel(String modelId, ModelForm formData);
|
||||
|
||||
/**
|
||||
* 删除流程模型(未部署的草稿模型;已部署定义不受影响)
|
||||
* 删除流程模型(级联删除其全部发布版本;存在运行中实例时拦截)
|
||||
*
|
||||
* @param modelIds 模型ID集合
|
||||
*/
|
||||
void removeModels(List<String> modelIds);
|
||||
|
||||
/**
|
||||
* 查询引用指定表单的流程(已部署定义与模型草稿的表单绑定)
|
||||
*
|
||||
* @param formKeys 表单标识集合
|
||||
* @return 表单标识 → 引用它的流程名称集合(无引用返回空 Map)
|
||||
*/
|
||||
Map<String, Set<String>> getFormReferences(Collection<String> formKeys);
|
||||
|
||||
/**
|
||||
* 获取模型 BPMN XML(设计器回显)
|
||||
*
|
||||
|
||||
@@ -67,4 +67,12 @@ public interface WorkflowTaskService {
|
||||
*/
|
||||
void rejectTask(String taskId, RejectTaskForm formData);
|
||||
|
||||
/**
|
||||
* 用 AI 汇总表单数据与审批记录,生成审批摘要
|
||||
*
|
||||
* @param taskId 任务ID
|
||||
* @return 摘要文本
|
||||
*/
|
||||
String aiSummarizeTask(String taskId);
|
||||
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.youlai.boot.module.workflow.service.impl;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import cn.hutool.core.lang.Assert;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.json.JSONObject;
|
||||
@@ -18,18 +19,26 @@ import com.youlai.boot.system.model.entity.Role;
|
||||
import com.youlai.boot.system.service.RoleService;
|
||||
import com.youlai.boot.module.workflow.config.ConditionalOnWorkflowEnabled;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.flowable.engine.RepositoryService;
|
||||
import org.flowable.engine.RuntimeService;
|
||||
import org.flowable.engine.repository.Deployment;
|
||||
import org.flowable.engine.repository.Model;
|
||||
import org.flowable.engine.repository.ModelQuery;
|
||||
import org.flowable.engine.repository.ProcessDefinition;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Collection;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
@@ -43,9 +52,11 @@ import java.util.stream.Collectors;
|
||||
@ConditionalOnWorkflowEnabled
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class WorkflowModelServiceImpl implements WorkflowModelService {
|
||||
|
||||
private final RepositoryService repositoryService;
|
||||
private final RuntimeService runtimeService;
|
||||
private final BpmnModelHelper bpmnModelHelper;
|
||||
private final RoleService roleService;
|
||||
|
||||
@@ -193,22 +204,101 @@ public class WorkflowModelServiceImpl implements WorkflowModelService {
|
||||
* 删除流程模型
|
||||
*
|
||||
* <p>「流程」对用户是整体:删除设计草稿时级联删除其全部发布版本(含运行实例与历史),
|
||||
* 避免残留孤儿定义导致列表出现无模型可管理的流程</p>
|
||||
* 避免残留孤儿定义导致列表出现无模型可管理的流程;有在办实例的流程先拦截,防止在办任务被静默抹除</p>
|
||||
*
|
||||
* @param modelIds 模型ID集合
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void removeModels(List<String> modelIds) {
|
||||
modelIds.forEach(modelId -> {
|
||||
Model model = repositoryService.getModel(modelId);
|
||||
if (model == null) {
|
||||
return;
|
||||
// 先全量校验再删除:批量删除中途抛错时,前面已删的模型无法回退
|
||||
List<Model> models = modelIds.stream()
|
||||
.map(repositoryService::getModel)
|
||||
.filter(Objects::nonNull)
|
||||
.toList();
|
||||
models.forEach(this::checkNoRunningInstance);
|
||||
models.forEach(this::removeModelAndDeployments);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验流程无运行中实例
|
||||
*
|
||||
* <p>级联删除会连同在办任务一并抹除,审批人无从知晓,故有在办实例时禁止删除</p>
|
||||
*/
|
||||
private void checkNoRunningInstance(Model model) {
|
||||
long runningCount = runtimeService.createProcessInstanceQuery()
|
||||
.processDefinitionKey(model.getKey())
|
||||
.count();
|
||||
if (runningCount > 0) {
|
||||
throw new BusinessException("流程【" + model.getName() + "】存在 " + runningCount
|
||||
+ " 个运行中的实例,请先撤销或办结后再删除");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除模型的全部发布版本(级联流程定义、实例与历史)及设计草稿
|
||||
*/
|
||||
private void removeModelAndDeployments(Model model) {
|
||||
repositoryService.createDeploymentQuery()
|
||||
.processDefinitionKey(model.getKey()).list()
|
||||
.forEach(deployment -> repositoryService.deleteDeployment(deployment.getId(), true));
|
||||
repositoryService.deleteModel(model.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询引用指定表单的流程
|
||||
*
|
||||
* <p>引用写在 BPMN 的开始节点与用户任务节点(flowable:formKey);模型草稿虽未部署但已绑定表单,
|
||||
* 一并计入:表单被删除后,流程发起或审批时无表单可渲染</p>
|
||||
*
|
||||
* @param formKeys 表单标识集合
|
||||
* @return 表单标识 → 引用它的流程名称集合(无引用返回空 Map)
|
||||
*/
|
||||
@Override
|
||||
public Map<String, Set<String>> getFormReferences(Collection<String> formKeys) {
|
||||
if (CollUtil.isEmpty(formKeys)) {
|
||||
return Map.of();
|
||||
}
|
||||
Set<String> targets = new HashSet<>(formKeys);
|
||||
Map<String, Set<String>> references = new HashMap<>();
|
||||
|
||||
// 已部署定义:全部版本都算引用,旧版本的实例仍在流转
|
||||
for (ProcessDefinition definition : repositoryService.createProcessDefinitionQuery().list()) {
|
||||
Set<String> referenced;
|
||||
try {
|
||||
referenced = bpmnModelHelper.getFormKeys(definition.getId());
|
||||
} catch (Exception e) {
|
||||
log.warn("流程定义 [{}] 表单引用解析失败,跳过校验", definition.getKey());
|
||||
continue;
|
||||
}
|
||||
repositoryService.createDeploymentQuery()
|
||||
.processDefinitionKey(model.getKey()).list()
|
||||
.forEach(deployment -> repositoryService.deleteDeployment(deployment.getId(), true));
|
||||
repositoryService.deleteModel(modelId);
|
||||
});
|
||||
referenced.stream().filter(targets::contains)
|
||||
.forEach(formKey -> recordReference(references, formKey, definition.getName()));
|
||||
}
|
||||
|
||||
// 模型草稿:设计器里已绑定表单,删除后该模型发布即成无表单流程
|
||||
for (Model model : repositoryService.createModelQuery().list()) {
|
||||
byte[] source = repositoryService.getModelEditorSource(model.getId());
|
||||
if (source == null || source.length == 0) {
|
||||
continue;
|
||||
}
|
||||
Set<String> referenced;
|
||||
try {
|
||||
referenced = bpmnModelHelper.getFormKeysFromXml(new String(source, StandardCharsets.UTF_8));
|
||||
} catch (Exception e) {
|
||||
log.warn("流程模型 [{}] 表单引用解析失败,跳过校验", model.getKey());
|
||||
continue;
|
||||
}
|
||||
referenced.stream().filter(targets::contains)
|
||||
.forEach(formKey -> recordReference(references, formKey, model.getName()));
|
||||
}
|
||||
return references;
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录一条引用关系(同一表单被多个流程引用时累加,按流程名称排序保证提示稳定)
|
||||
*/
|
||||
private void recordReference(Map<String, Set<String>> references, String formKey, String processName) {
|
||||
references.computeIfAbsent(formKey, key -> new TreeSet<>()).add(processName);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
package com.youlai.boot.module.workflow.service.impl;
|
||||
|
||||
import cn.hutool.core.collection.CollectionUtil;
|
||||
import cn.hutool.core.lang.Assert;
|
||||
import cn.hutool.core.util.NumberUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.youlai.boot.common.exception.BusinessException;
|
||||
import com.youlai.boot.framework.security.util.SecurityUtils;
|
||||
import com.youlai.boot.module.form.model.vo.FormDataVO;
|
||||
import com.youlai.boot.module.form.service.FormDataService;
|
||||
import com.youlai.boot.module.form.util.FormRuleValidator;
|
||||
import com.youlai.boot.module.workflow.model.form.CompleteTaskForm;
|
||||
import com.youlai.boot.module.workflow.model.form.RejectTaskForm;
|
||||
import com.youlai.boot.module.workflow.model.query.WorkflowTaskPageQuery;
|
||||
import com.youlai.boot.module.workflow.model.vo.ApprovalHistoryVO;
|
||||
import com.youlai.boot.module.workflow.model.vo.DoneTaskVO;
|
||||
import com.youlai.boot.module.workflow.model.vo.RejectTargetVO;
|
||||
import com.youlai.boot.module.workflow.model.vo.TaskDetailVO;
|
||||
@@ -19,6 +25,8 @@ import com.youlai.boot.module.workflow.service.WorkflowTaskService;
|
||||
import com.youlai.boot.module.workflow.util.ApprovalHistoryHelper;
|
||||
import com.youlai.boot.module.workflow.util.BpmnModelHelper;
|
||||
import com.youlai.boot.module.workflow.config.ConditionalOnWorkflowEnabled;
|
||||
import com.youlai.boot.support.ai.AiChatService;
|
||||
import com.youlai.boot.support.ai.PromptLoader;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.flowable.common.engine.impl.identity.Authentication;
|
||||
@@ -37,6 +45,7 @@ import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -58,6 +67,11 @@ import java.util.stream.Collectors;
|
||||
@RequiredArgsConstructor
|
||||
public class WorkflowTaskServiceImpl implements WorkflowTaskService {
|
||||
|
||||
/** 系统提示词名称,对应 resources/prompts 下的相对路径 */
|
||||
private static final String SYSTEM_PROMPT_PATH = "workflow/summary.md";
|
||||
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
|
||||
private final TaskService taskService;
|
||||
private final RuntimeService runtimeService;
|
||||
private final HistoryService historyService;
|
||||
@@ -66,6 +80,8 @@ public class WorkflowTaskServiceImpl implements WorkflowTaskService {
|
||||
private final WorkflowDefinitionService workflowDefinitionService;
|
||||
private final BpmnModelHelper bpmnModelHelper;
|
||||
private final ApprovalHistoryHelper approvalHistoryHelper;
|
||||
private final AiChatService aiChatService;
|
||||
private final PromptLoader promptLoader;
|
||||
|
||||
/**
|
||||
* 待办任务分页列表
|
||||
@@ -379,4 +395,72 @@ public class WorkflowTaskServiceImpl implements WorkflowTaskService {
|
||||
.collect(Collectors.toMap(ProcessInstance::getId, instance -> instance));
|
||||
}
|
||||
|
||||
//---------------------------------------------------
|
||||
// AI 辅助:未开启 AI 时调用返回业务异常
|
||||
//---------------------------------------------------
|
||||
|
||||
/**
|
||||
* 用 AI 汇总表单数据与审批记录,生成审批摘要
|
||||
*
|
||||
* @param taskId 任务ID
|
||||
* @return 摘要文本
|
||||
*/
|
||||
@Override
|
||||
public String aiSummarizeTask(String taskId) {
|
||||
TaskDetailVO detail = getTaskDetail(taskId);
|
||||
return aiChatService.chat(promptLoader.get(SYSTEM_PROMPT_PATH), buildAiUserPrompt(detail));
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造用户提示词
|
||||
*
|
||||
* @param detail 任务详情
|
||||
* @return 用户提示词
|
||||
*/
|
||||
private String buildAiUserPrompt(TaskDetailVO detail) {
|
||||
StringBuilder text = new StringBuilder();
|
||||
text.append("流程名称:").append(StrUtil.blankToDefault(detail.getProcessName(), "未命名流程")).append("\n");
|
||||
text.append("当前节点:").append(StrUtil.blankToDefault(detail.getTaskName(), "未命名节点")).append("\n");
|
||||
text.append("表单数据:\n").append(buildAiFormDataText(detail));
|
||||
if (CollectionUtil.isNotEmpty(detail.getHistory())) {
|
||||
text.append("审批记录:\n");
|
||||
for (ApprovalHistoryVO history : detail.getHistory()) {
|
||||
text.append("- ")
|
||||
.append(StrUtil.blankToDefault(history.getActivityName(), "环节"))
|
||||
.append("(")
|
||||
.append(StrUtil.blankToDefault(history.getAssignee(), "待处理"))
|
||||
.append("):")
|
||||
.append(StrUtil.blankToDefault(history.getComment(), "无意见"))
|
||||
.append("\n");
|
||||
}
|
||||
}
|
||||
return text.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 把表单数据按 rule 的中文标题输出,便于模型理解字段含义
|
||||
*
|
||||
* @param detail 任务详情
|
||||
* @return 逐行「标题:值」文本
|
||||
*/
|
||||
private String buildAiFormDataText(TaskDetailVO detail) {
|
||||
Map<String, String> titles = FormRuleValidator.resolveTitles(detail.getFormJson());
|
||||
if (StrUtil.isBlank(detail.getDataJson())) {
|
||||
return "(无表单数据)\n";
|
||||
}
|
||||
StringBuilder text = new StringBuilder();
|
||||
try {
|
||||
JsonNode data = OBJECT_MAPPER.readTree(detail.getDataJson());
|
||||
Iterator<Map.Entry<String, JsonNode>> fields = data.fields();
|
||||
while (fields.hasNext()) {
|
||||
Map.Entry<String, JsonNode> entry = fields.next();
|
||||
String title = titles.getOrDefault(entry.getKey(), entry.getKey());
|
||||
text.append(title).append(":").append(entry.getValue().asText()).append("\n");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
throw new BusinessException("表单数据解析失败");
|
||||
}
|
||||
return text.toString();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import java.io.ByteArrayInputStream;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
@@ -98,6 +99,50 @@ public class BpmnModelHelper {
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取流程定义引用的全部表单标识(开始节点与用户任务节点,去重)
|
||||
*
|
||||
* @param processDefinitionId 流程定义ID
|
||||
* @return formKey 集合,未绑定返回空集合
|
||||
*/
|
||||
public Set<String> getFormKeys(String processDefinitionId) {
|
||||
Process process = getMainProcess(processDefinitionId);
|
||||
if (process == null) {
|
||||
return Set.of();
|
||||
}
|
||||
return extractFormKeys(process);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取 BPMN XML 引用的全部表单标识(开始节点与用户任务节点,去重)
|
||||
*
|
||||
* <p>供表单删除前校验使用:设计器草稿未部署,其引用不体现在流程定义里</p>
|
||||
*
|
||||
* @param bpmnXml BPMN 2.0 XML 字符串
|
||||
* @return formKey 集合,无引用返回空集合
|
||||
*/
|
||||
public Set<String> getFormKeysFromXml(String bpmnXml) {
|
||||
BpmnModel bpmnModel = new BpmnXMLConverter().convertToBpmnModel(
|
||||
() -> new ByteArrayInputStream(bpmnXml.getBytes(StandardCharsets.UTF_8)), false, true);
|
||||
Process process = bpmnModel.getMainProcess();
|
||||
if (process == null) {
|
||||
throw new BusinessException("流程文件校验失败,未找到主流程");
|
||||
}
|
||||
return extractFormKeys(process);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取主流程中开始节点与用户任务节点引用的表单标识
|
||||
*/
|
||||
private Set<String> extractFormKeys(Process process) {
|
||||
Set<String> formKeys = new HashSet<>();
|
||||
process.findFlowElementsOfType(StartEvent.class).stream()
|
||||
.map(StartEvent::getFormKey).filter(StrUtil::isNotBlank).forEach(formKeys::add);
|
||||
process.findFlowElementsOfType(UserTask.class).stream()
|
||||
.map(UserTask::getFormKey).filter(StrUtil::isNotBlank).forEach(formKeys::add);
|
||||
return formKeys;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取流程定义的 BPMN XML
|
||||
*
|
||||
|
||||
123
src/main/java/com/youlai/boot/support/ai/AiChatService.java
Normal file
123
src/main/java/com/youlai/boot/support/ai/AiChatService.java
Normal file
@@ -0,0 +1,123 @@
|
||||
package com.youlai.boot.support.ai;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.youlai.boot.common.exception.BusinessException;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.chat.client.ChatClient;
|
||||
import org.springframework.ai.chat.model.ChatModel;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.core.ParameterizedTypeReference;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* AI 对话服务
|
||||
*
|
||||
* <p>代码生成、动态表单、工作流的 AI 辅助功能统一走这里;对话客户端由 spring.ai 自动装配提供,
|
||||
* 未开启时调用返回业务异常,不影响应用启动</p>
|
||||
*
|
||||
* @author Ray.Hao
|
||||
* @since 0.1.0
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class AiChatService {
|
||||
|
||||
private final ObjectProvider<ChatModel> chatModelProvider;
|
||||
|
||||
private final ObjectProvider<ChatClient.Builder> chatClientBuilderProvider;
|
||||
|
||||
/**
|
||||
* 对话客户端,首次调用时构建
|
||||
*/
|
||||
private ChatClient chatClient;
|
||||
|
||||
/**
|
||||
* 发送提示词并返回模型输出
|
||||
*
|
||||
* @param systemPrompt 系统提示词(角色与约束)
|
||||
* @param userPrompt 用户提示词(输入数据)
|
||||
* @return 模型输出文本
|
||||
*/
|
||||
public String chat(String systemPrompt, String userPrompt) {
|
||||
return execute("chat", () -> {
|
||||
String content = client().prompt().system(systemPrompt).user(userPrompt).call().content();
|
||||
return StrUtil.blankToDefault(content, StrUtil.EMPTY).trim();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送提示词并映射为实体
|
||||
*
|
||||
* <p>实体结构不符时框架会携带校验错误自动重试,无需手工解析 JSON</p>
|
||||
*
|
||||
* @param systemPrompt 系统提示词(角色与约束)
|
||||
* @param userPrompt 用户提示词(输入数据)
|
||||
* @param type 目标实体类型
|
||||
* @return 映射后的实体
|
||||
*/
|
||||
public <T> T chatEntity(String systemPrompt, String userPrompt, Class<T> type) {
|
||||
return execute("chatEntity(" + type.getSimpleName() + ")", () -> client().prompt()
|
||||
.system(systemPrompt).user(userPrompt).call()
|
||||
.entity(type, spec -> spec.validateSchema()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送提示词并映射为 JSON 数组
|
||||
*
|
||||
* <p>元素结构开放(如 form-create rule)时使用,产出后仍需业务侧校验</p>
|
||||
*
|
||||
* @param systemPrompt 系统提示词(角色与约束)
|
||||
* @param userPrompt 用户提示词(输入数据)
|
||||
* @return JSON 数组
|
||||
*/
|
||||
public List<Map<String, Object>> chatJsonList(String systemPrompt, String userPrompt) {
|
||||
return execute("chatJsonList", () -> client().prompt()
|
||||
.system(systemPrompt).user(userPrompt).call()
|
||||
.entity(new ParameterizedTypeReference<List<Map<String, Object>>>() {
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取对话客户端,首次调用时构建
|
||||
*
|
||||
* @return 对话客户端
|
||||
*/
|
||||
private synchronized ChatClient client() {
|
||||
if (chatClient == null) {
|
||||
// 未装配 ChatModel 时 Builder 也建不出来,先判断模型是否存在,避免抛出框架的 BeanCreationException
|
||||
if (chatModelProvider.getIfAvailable() == null) {
|
||||
throw new BusinessException(
|
||||
"AI 功能未开启,请将 spring.ai.model.chat 置为 openai 并配置 spring.ai.openai.api-key");
|
||||
}
|
||||
log.info("AI 对话客户端初始化");
|
||||
chatClient = chatClientBuilderProvider.getObject().build();
|
||||
}
|
||||
return chatClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行模型调用并记录开始、耗时与异常
|
||||
*
|
||||
* @param operation 调用标识
|
||||
* @param call 模型调用
|
||||
* @return 调用结果
|
||||
*/
|
||||
private <T> T execute(String operation, Supplier<T> call) {
|
||||
log.info("AI 调用开始, {}", operation);
|
||||
long start = System.currentTimeMillis();
|
||||
try {
|
||||
T result = call.get();
|
||||
log.info("AI 调用完成, {} 耗时 {} ms", operation, System.currentTimeMillis() - start);
|
||||
return result;
|
||||
} catch (RuntimeException e) {
|
||||
log.error("AI 调用失败, {} 耗时 {} ms", operation, System.currentTimeMillis() - start, e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
126
src/main/java/com/youlai/boot/support/ai/PromptLoader.java
Normal file
126
src/main/java/com/youlai/boot/support/ai/PromptLoader.java
Normal file
@@ -0,0 +1,126 @@
|
||||
package com.youlai.boot.support.ai;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StreamUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 提示词加载器
|
||||
*
|
||||
* <p>提示词文本统一放在类路径的 prompts 目录下,一个文件对应一段系统提示词,
|
||||
* 应用启动时全部载入并缓存;文本中的 {{键}} 在渲染时替换为变量值</p>
|
||||
*
|
||||
* @author Ray.Hao
|
||||
* @since 0.1.0
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class PromptLoader {
|
||||
|
||||
/**
|
||||
* 提示词文件位置
|
||||
*/
|
||||
private static final String LOCATION_PATTERN = "classpath*:prompts/**/*.md";
|
||||
|
||||
/**
|
||||
* 提示词目录,用于从资源地址截取提示词名称
|
||||
*/
|
||||
private static final String DIRECTORY = "prompts/";
|
||||
|
||||
/**
|
||||
* 占位符左标记
|
||||
*/
|
||||
private static final String PLACEHOLDER_LEFT = "{{";
|
||||
|
||||
/**
|
||||
* 占位符右标记
|
||||
*/
|
||||
private static final String PLACEHOLDER_RIGHT = "}}";
|
||||
|
||||
/**
|
||||
* 提示词内容,键为 prompts 下的相对路径
|
||||
*/
|
||||
private final Map<String, String> prompts = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 载入全部提示词文件,读取失败则应用启动失败
|
||||
*/
|
||||
@PostConstruct
|
||||
public void load() {
|
||||
try {
|
||||
Resource[] resources = new PathMatchingResourcePatternResolver().getResources(LOCATION_PATTERN);
|
||||
for (Resource resource : resources) {
|
||||
try (InputStream inputStream = resource.getInputStream()) {
|
||||
String content = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8).trim();
|
||||
prompts.put(resolveName(resource), content);
|
||||
}
|
||||
}
|
||||
log.info("提示词载入完成, {} 个", prompts.size());
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("提示词载入失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取提示词原文
|
||||
*
|
||||
* @param name 提示词名称,prompts 下的相对路径,如 codegen/system.md
|
||||
* @return 提示词原文
|
||||
*/
|
||||
public String get(String name) {
|
||||
String prompt = prompts.get(name);
|
||||
if (prompt == null) {
|
||||
throw new IllegalStateException("提示词不存在:" + name);
|
||||
}
|
||||
return prompt;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取提示词并替换占位符,替换后仍残留 {{ 的视为占位符漏传,直接抛出异常
|
||||
*
|
||||
* @param name 提示词名称,prompts 下的相对路径
|
||||
* @param variables 变量值,键与文本中的 {{键}} 对应
|
||||
* @return 替换后的提示词
|
||||
*/
|
||||
public String render(String name, Map<String, String> variables) {
|
||||
String prompt = get(name);
|
||||
for (Map.Entry<String, String> variable : variables.entrySet()) {
|
||||
prompt = prompt.replace(PLACEHOLDER_LEFT + variable.getKey() + PLACEHOLDER_RIGHT,
|
||||
StrUtil.nullToEmpty(variable.getValue()));
|
||||
}
|
||||
int index = prompt.indexOf(PLACEHOLDER_LEFT);
|
||||
if (index >= 0) {
|
||||
int end = prompt.indexOf(PLACEHOLDER_RIGHT, index);
|
||||
String placeholder = end > index
|
||||
? prompt.substring(index, end + PLACEHOLDER_RIGHT.length())
|
||||
: prompt.substring(index);
|
||||
throw new IllegalStateException("提示词 " + name + " 存在未替换的占位符:" + placeholder);
|
||||
}
|
||||
return prompt;
|
||||
}
|
||||
|
||||
/**
|
||||
* 截取资源地址中 prompts 之后的路径作为提示词名称
|
||||
*
|
||||
* @param resource 提示词资源
|
||||
* @return 提示词名称
|
||||
*/
|
||||
private String resolveName(Resource resource) throws IOException {
|
||||
String url = resource.getURL().toString();
|
||||
int index = url.lastIndexOf(DIRECTORY);
|
||||
if (index < 0) {
|
||||
throw new IllegalStateException("提示词路径异常:" + url);
|
||||
}
|
||||
return url.substring(index + DIRECTORY.length());
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,28 @@
|
||||
package com.youlai.boot.system.controller;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.youlai.boot.common.annotation.Log;
|
||||
import com.youlai.boot.common.enums.ActionTypeEnum;
|
||||
import com.youlai.boot.common.enums.LogModuleEnum;
|
||||
import com.youlai.boot.common.model.Option;
|
||||
import com.youlai.boot.common.result.PageResult;
|
||||
import com.youlai.boot.common.result.Result;
|
||||
import com.youlai.boot.common.enums.ActionTypeEnum;
|
||||
import com.youlai.boot.common.enums.LogModuleEnum;
|
||||
import com.youlai.boot.system.model.form.DictItemForm;
|
||||
import com.youlai.boot.system.model.query.DictItemQuery;
|
||||
import com.youlai.boot.system.model.query.DictQuery;
|
||||
import com.youlai.boot.system.model.vo.DictItemOptionVO;
|
||||
import com.youlai.boot.system.model.vo.DictItemPageVO;
|
||||
import com.youlai.boot.system.model.vo.DictPageVO;
|
||||
import com.youlai.boot.framework.web.repeatsubmit.RepeatSubmit;
|
||||
import com.youlai.boot.system.model.form.DictForm;
|
||||
import com.youlai.boot.common.annotation.Log;
|
||||
import com.youlai.boot.system.service.DictItemService;
|
||||
import com.youlai.boot.system.service.DictService;
|
||||
import com.youlai.boot.support.sse.SseService;
|
||||
import com.youlai.boot.support.sse.constant.SseTopics;
|
||||
import com.youlai.boot.system.model.dto.DictChangeDTO;
|
||||
import com.youlai.boot.system.model.form.DictForm;
|
||||
import com.youlai.boot.system.model.form.DictItemForm;
|
||||
import com.youlai.boot.system.model.query.DictQuery;
|
||||
import com.youlai.boot.system.model.query.DictItemQuery;
|
||||
import com.youlai.boot.system.model.vo.DictItemOptionVO;
|
||||
import com.youlai.boot.system.model.vo.DictItemPageVO;
|
||||
import com.youlai.boot.system.model.vo.DictPageVO;
|
||||
import com.youlai.boot.system.service.DictItemService;
|
||||
import com.youlai.boot.system.service.DictService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
@@ -34,8 +34,8 @@ import java.util.List;
|
||||
/**
|
||||
* 字典控制层
|
||||
*
|
||||
* @author Ray.Hao
|
||||
* @since 2.9.0
|
||||
* @author Ray Hao
|
||||
* @since 2022/10/12
|
||||
*/
|
||||
@Tag(name = "06.字典接口")
|
||||
@RestController
|
||||
@@ -56,9 +56,6 @@ public class DictController {
|
||||
sseService.broadcast(SseTopics.DICT, new DictChangeDTO(dictCode));
|
||||
}
|
||||
|
||||
//---------------------------------------------------
|
||||
// 字典相关接口
|
||||
//---------------------------------------------------
|
||||
@Operation(summary = "字典分页列表")
|
||||
@GetMapping
|
||||
@Log(module = LogModuleEnum.DICT, value = ActionTypeEnum.LIST)
|
||||
@@ -106,12 +103,12 @@ public class DictController {
|
||||
@Log(module = LogModuleEnum.DICT, value = ActionTypeEnum.UPDATE)
|
||||
public Result<Void> updateDict(
|
||||
@PathVariable Long dictId,
|
||||
@RequestBody @Valid DictForm dictForm
|
||||
@RequestBody @Valid DictForm formData
|
||||
) {
|
||||
boolean status = dictService.updateDict(dictId, dictForm);
|
||||
boolean status = dictService.updateDict(dictId, formData);
|
||||
// 发送字典更新通知
|
||||
if (status && dictForm.getDictCode() != null) {
|
||||
sendDictChange(dictForm.getDictCode());
|
||||
if (status && formData.getDictCode() != null) {
|
||||
sendDictChange(formData.getDictCode());
|
||||
}
|
||||
return Result.judge(status);
|
||||
}
|
||||
@@ -130,20 +127,16 @@ public class DictController {
|
||||
|
||||
// 发送字典删除通知
|
||||
for (String dictCode : dictCodes) {
|
||||
sendDictChange(dictCode);
|
||||
sendDictChange(dictCode);
|
||||
}
|
||||
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
|
||||
//---------------------------------------------------
|
||||
// 字典项相关接口
|
||||
//---------------------------------------------------
|
||||
@Operation(summary = "字典项分页列表")
|
||||
@Operation(summary = "获取字典项分页列表")
|
||||
@GetMapping("/{dictCode}/items")
|
||||
public PageResult<DictItemPageVO> getDictItemPage(
|
||||
@PathVariable String dictCode,
|
||||
public PageResult<DictItemPageVO> getDictItemsPage(
|
||||
@Parameter(description = "字典编码") @PathVariable String dictCode,
|
||||
@Valid DictItemQuery queryParams
|
||||
) {
|
||||
queryParams.setDictCode(dictCode);
|
||||
@@ -151,7 +144,7 @@ public class DictController {
|
||||
return PageResult.success(result);
|
||||
}
|
||||
|
||||
@Operation(summary = "字典项列表")
|
||||
@Operation(summary = "获取字典项列表")
|
||||
@GetMapping("/{dictCode}/items/options")
|
||||
public Result<List<DictItemOptionVO>> getDictItems(
|
||||
@Parameter(description = "字典编码") @PathVariable String dictCode
|
||||
@@ -160,8 +153,18 @@ public class DictController {
|
||||
return Result.success(list);
|
||||
}
|
||||
|
||||
@Operation(summary = "获取字典项表单数据")
|
||||
@GetMapping("/{dictCode}/items/{itemId}/form")
|
||||
public Result<DictItemForm> getDictItemForm(
|
||||
@PathVariable String dictCode,
|
||||
@Parameter(description = "字典ID") @PathVariable Long itemId
|
||||
) {
|
||||
DictItemForm formData = dictItemService.getDictItemForm(itemId);
|
||||
return Result.success(formData);
|
||||
}
|
||||
|
||||
@Operation(summary = "新增字典项")
|
||||
@PostMapping("/{dictCode}/items")
|
||||
@PostMapping(value = "/{dictCode}/items")
|
||||
@PreAuthorize("@ss.hasPerm('sys:dict-item:create')")
|
||||
@RepeatSubmit
|
||||
@Log(module = LogModuleEnum.DICT, value = ActionTypeEnum.INSERT)
|
||||
@@ -174,22 +177,12 @@ public class DictController {
|
||||
|
||||
// 发送字典更新通知
|
||||
if (result) {
|
||||
sendDictChange(dictCode);
|
||||
sendDictChange(dictCode);
|
||||
}
|
||||
|
||||
return Result.judge(result);
|
||||
}
|
||||
|
||||
@Operation(summary = "字典项表单数据")
|
||||
@GetMapping("/{dictCode}/items/{itemId}/form")
|
||||
public Result<DictItemForm> getDictItemForm(
|
||||
@PathVariable String dictCode,
|
||||
@Parameter(description = "字典项ID") @PathVariable Long itemId
|
||||
) {
|
||||
DictItemForm formData = dictItemService.getDictItemForm(itemId);
|
||||
return Result.success(formData);
|
||||
}
|
||||
|
||||
@Operation(summary = "修改字典项")
|
||||
@PutMapping("/{dictCode}/items/{itemId}")
|
||||
@PreAuthorize("@ss.hasPerm('sys:dict-item:update')")
|
||||
@@ -216,15 +209,13 @@ public class DictController {
|
||||
@DeleteMapping("/{dictCode}/items/{itemIds}")
|
||||
@PreAuthorize("@ss.hasPerm('sys:dict-item:delete')")
|
||||
@Log(module = LogModuleEnum.DICT, value = ActionTypeEnum.DELETE)
|
||||
public Result<Void> deleteDictItems(
|
||||
public Result<Void> deleteDictItemByIds(
|
||||
@PathVariable String dictCode,
|
||||
@Parameter(description = "字典ID,多个以英文逗号(,)拼接") @PathVariable String itemIds
|
||||
@Parameter(description = "字典项ID,多个以英文逗号(,)拼接") @PathVariable String itemIds
|
||||
) {
|
||||
dictItemService.deleteDictItemByIds(itemIds);
|
||||
|
||||
// 发送字典更新通知
|
||||
sendDictChange(dictCode);
|
||||
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
|
||||
@@ -6,8 +6,10 @@ import com.youlai.boot.common.enums.ActionTypeEnum;
|
||||
import com.youlai.boot.common.enums.LogModuleEnum;
|
||||
import com.youlai.boot.common.model.Option;
|
||||
import com.youlai.boot.common.result.Result;
|
||||
import com.youlai.boot.system.model.form.MenuAiFillForm;
|
||||
import com.youlai.boot.system.model.form.MenuForm;
|
||||
import com.youlai.boot.system.model.query.MenuQuery;
|
||||
import com.youlai.boot.system.model.vo.MenuAiVO;
|
||||
import com.youlai.boot.system.model.vo.MenuVO;
|
||||
import com.youlai.boot.system.model.vo.RouteVO;
|
||||
import com.youlai.boot.system.service.MenuService;
|
||||
@@ -48,11 +50,8 @@ public class MenuController {
|
||||
@Operation(summary = "菜单下拉列表")
|
||||
@GetMapping("/options")
|
||||
public Result<List<Option<Long>>> getMenuOptions(
|
||||
@Parameter(description = "是否只查询父级菜单")
|
||||
@RequestParam(required = false, defaultValue = "false") boolean onlyParent
|
||||
) {
|
||||
List<Option<Long>> menus = menuService.listMenuOptions(onlyParent);
|
||||
return Result.success(menus);
|
||||
@Parameter(description = "菜单类型,可传多个,为空查全部") @RequestParam(required = false) List<String> types) {
|
||||
return Result.success(menuService.listMenuOptions(types));
|
||||
}
|
||||
|
||||
@Operation(summary = "当前用户菜单路由列表")
|
||||
@@ -118,5 +117,13 @@ public class MenuController {
|
||||
return Result.judge(result);
|
||||
}
|
||||
|
||||
// AI 辅助:未开启 AI 时调用返回业务异常
|
||||
|
||||
@Operation(summary = "AI 推断菜单的访问路径与权限标识")
|
||||
@PostMapping("/ai-fill")
|
||||
public Result<MenuAiVO> aiFillMenu(@RequestBody @Valid MenuAiFillForm form) {
|
||||
return Result.success(menuService.aiFillMenu(form));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -170,7 +170,8 @@ public class UserController {
|
||||
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||
response.setHeader("Content-Disposition", "attachment; filename=" + URLEncoder.encode(fileName, StandardCharsets.UTF_8));
|
||||
|
||||
String fileClassPath = "templates" + File.separator + "excel" + File.separator + fileName;
|
||||
// ClassLoader 只认 / 分隔,用 File.separator 打 jar 后(Linux)会取不到资源
|
||||
String fileClassPath = "excel/" + fileName;
|
||||
InputStream inputStream = this.getClass().getClassLoader().getResourceAsStream(fileClassPath);
|
||||
|
||||
try (ServletOutputStream outputStream = response.getOutputStream();
|
||||
|
||||
@@ -3,12 +3,9 @@ package com.youlai.boot.system.converter;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.youlai.boot.system.model.entity.DictItem;
|
||||
import com.youlai.boot.system.model.form.DictItemForm;
|
||||
import com.youlai.boot.system.model.vo.DictPageVO;
|
||||
import com.youlai.boot.common.model.Option;
|
||||
import com.youlai.boot.system.model.vo.DictItemPageVO;
|
||||
import org.mapstruct.Mapper;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 字典项对象转换器
|
||||
*
|
||||
@@ -18,12 +15,9 @@ import java.util.List;
|
||||
@Mapper(componentModel = "spring")
|
||||
public interface DictItemConverter {
|
||||
|
||||
Page<DictPageVO> toPageVo(Page<DictItem> page);
|
||||
Page<DictItemPageVO> toPageVo(Page<DictItem> page);
|
||||
|
||||
DictItemForm toForm(DictItem entity);
|
||||
|
||||
DictItem toEntity(DictItemForm formFata);
|
||||
|
||||
Option<Long> toOption(DictItem dictItem);
|
||||
List<Option<Long>> toOption(List<DictItem> dictData);
|
||||
DictItem toEntity(DictItemForm formData);
|
||||
}
|
||||
|
||||
@@ -18,10 +18,10 @@ public interface DictItemMapper extends BaseMapper<DictItem> {
|
||||
|
||||
/**
|
||||
* 字典项分页列表
|
||||
*
|
||||
* @param page 分页参数
|
||||
* @param queryParams 查询参数
|
||||
* @return 字典项分页列表
|
||||
*/
|
||||
Page<DictItemPageVO> getDictItemPage(Page<DictItemPageVO> page, DictItemQuery queryParams);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -26,7 +26,3 @@ public interface DictMapper extends BaseMapper<Dict> {
|
||||
Page<DictPageVO> getDictPage(Page<DictPageVO> page, DictQuery queryParams);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
/**
|
||||
* 字典实体
|
||||
* 字典实体对象
|
||||
*
|
||||
* @author Ray.Hao
|
||||
* @since 2022/12/17
|
||||
@@ -39,8 +39,8 @@ public class Dict extends BaseEntity {
|
||||
private String remark;
|
||||
|
||||
/**
|
||||
* 逻辑删除标识(0-未删除 1-已删除)
|
||||
* 逻辑删除(1-已删除 0-未删除)
|
||||
*/
|
||||
private Integer isDeleted;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ public class DictItem extends BaseEntity {
|
||||
private Integer sort;
|
||||
|
||||
/**
|
||||
* 状态(1-正常,0-禁用)
|
||||
* 状态(1-启用,0-停用)
|
||||
*/
|
||||
private Integer status;
|
||||
|
||||
@@ -64,4 +64,10 @@ public class DictItem extends BaseEntity {
|
||||
*/
|
||||
@TableField(fill = FieldFill.INSERT_UPDATE)
|
||||
private Long updateBy;
|
||||
}
|
||||
|
||||
/**
|
||||
* 逻辑删除(1-已删除 0-未删除)
|
||||
*/
|
||||
private Integer isDeleted;
|
||||
|
||||
}
|
||||
|
||||
@@ -94,11 +94,6 @@ public class Menu extends BaseEntity {
|
||||
*/
|
||||
private Integer keepAlive;
|
||||
|
||||
/**
|
||||
* 【目录】只有一个子路由是否始终显示(1:是 0:否)
|
||||
*/
|
||||
private Integer alwaysShow;
|
||||
|
||||
/**
|
||||
* 路由参数
|
||||
*/
|
||||
|
||||
@@ -4,12 +4,8 @@ package com.youlai.boot.system.model.form;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import org.hibernate.validator.constraints.Range;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 字典表单对象
|
||||
*
|
||||
|
||||
@@ -38,12 +38,12 @@ public class DictItemForm {
|
||||
@Range(min = 0, max = 9999, message = "排序值需在 0-9999 之间")
|
||||
private Integer sort;
|
||||
|
||||
@Schema(description = "状态(0:禁用,1:启用)")
|
||||
@Schema(description = "状态(1-启用,0-禁用)")
|
||||
@Range(min = 0, max = 1, message = "状态取值不正确")
|
||||
private Integer status;
|
||||
|
||||
@Schema(description = "字典类型(用于显示样式)")
|
||||
@Size(max = 30, message = "字典类型长度不能超过 30 个字符")
|
||||
@Schema(description = "标签样式(primary-主要 success-成功 warning-警告 danger-危险 info-信息)")
|
||||
@Size(max = 30, message = "标签样式长度不能超过 30 个字符")
|
||||
private String tagType;
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.youlai.boot.system.model.form;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* AI 推断菜单配置入参
|
||||
*
|
||||
* @author Ray.Hao
|
||||
* @since 0.1.0
|
||||
*/
|
||||
@Schema(description = "AI 推断菜单配置入参")
|
||||
@Data
|
||||
public class MenuAiFillForm {
|
||||
|
||||
@Schema(description = "菜单名称", example = "公告管理")
|
||||
@NotBlank(message = "菜单名称不能为空")
|
||||
private String name;
|
||||
|
||||
@Schema(description = "菜单类型(C-目录 M-菜单 E-外链 B-按钮)", example = "M")
|
||||
private String type;
|
||||
|
||||
@Schema(description = "上级菜单ID,用于参考同级菜单的命名风格", example = "1")
|
||||
private Long parentId;
|
||||
}
|
||||
@@ -60,10 +60,13 @@ public class MenuForm {
|
||||
@Schema(description = "【菜单】是否开启页面缓存", example = "1")
|
||||
private Integer keepAlive;
|
||||
|
||||
@Schema(description = "【目录】只有一个子路由是否始终显示", example = "1")
|
||||
private Integer alwaysShow;
|
||||
|
||||
@Schema(description = "路由参数")
|
||||
private List<KeyValue> params;
|
||||
|
||||
@Schema(description = "新增页面菜单时是否生成增删改查按钮")
|
||||
private Boolean generateCrudButtons;
|
||||
|
||||
@Schema(description = "按钮权限标识前缀,如 sys:user")
|
||||
private String buttonPermPrefix;
|
||||
|
||||
}
|
||||
|
||||
@@ -21,9 +21,7 @@ public class DictItemOptionVO {
|
||||
@Schema(description = "字典项标签")
|
||||
private String label;
|
||||
|
||||
@Schema(description = "标签类型")
|
||||
@Schema(description = "标签样式")
|
||||
private String tagType;
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -28,12 +28,13 @@ public class DictItemPageVO {
|
||||
@Schema(description = "字典值")
|
||||
private String value;
|
||||
|
||||
@Schema(description = "标签样式")
|
||||
private String tagType;
|
||||
|
||||
@Schema(description = "排序")
|
||||
private Integer sort;
|
||||
|
||||
@Schema(description = "状态(1:启用,0:禁用)")
|
||||
@Schema(description = "状态(1-启用,0-停用)")
|
||||
private Integer status;
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -24,8 +24,6 @@ public class DictPageVO {
|
||||
@Schema(description = "字典编码")
|
||||
private String dictCode;
|
||||
|
||||
@Schema(description = "字典状态(1-启用,0-禁用)")
|
||||
@Schema(description = "字典状态(1-启用,0-停用)")
|
||||
private Integer status;
|
||||
}
|
||||
|
||||
|
||||
26
src/main/java/com/youlai/boot/system/model/vo/MenuAiVO.java
Normal file
26
src/main/java/com/youlai/boot/system/model/vo/MenuAiVO.java
Normal file
@@ -0,0 +1,26 @@
|
||||
package com.youlai.boot.system.model.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* AI 推断的菜单配置
|
||||
*
|
||||
* @author Ray.Hao
|
||||
* @since 0.1.0
|
||||
*/
|
||||
@Schema(description = "AI 推断的菜单配置")
|
||||
@Data
|
||||
public class MenuAiVO {
|
||||
|
||||
@Schema(description = "访问路径")
|
||||
private String routePath;
|
||||
|
||||
@Schema(description = "权限标识")
|
||||
private String perm;
|
||||
|
||||
@Schema(description = "建议的图标关键词,按贴切程度排列")
|
||||
private List<String> iconKeywords;
|
||||
}
|
||||
@@ -50,10 +50,6 @@ public class RouteVO {
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
private Boolean keepAlive;
|
||||
|
||||
@Schema(description = "【目录】只有一个子路由是否始终显示", example = "true")
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
private Boolean alwaysShow;
|
||||
|
||||
@Schema(description = "外链地址(内嵌模式用)", example = "https://www.youlai.tech")
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
private String externalUrl;
|
||||
|
||||
@@ -6,7 +6,6 @@ import com.youlai.boot.common.model.Option;
|
||||
import com.youlai.boot.system.model.entity.Dict;
|
||||
import com.youlai.boot.system.model.form.DictForm;
|
||||
import com.youlai.boot.system.model.query.DictQuery;
|
||||
import com.youlai.boot.system.model.vo.DictItemOptionVO;
|
||||
import com.youlai.boot.system.model.vo.DictPageVO;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -2,10 +2,12 @@ package com.youlai.boot.system.service;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.youlai.boot.system.model.dto.MenuCreateDTO;
|
||||
import com.youlai.boot.system.model.form.MenuAiFillForm;
|
||||
import com.youlai.boot.system.model.form.MenuForm;
|
||||
import com.youlai.boot.common.model.Option;
|
||||
import com.youlai.boot.system.model.entity.Menu;
|
||||
import com.youlai.boot.system.model.query.MenuQuery;
|
||||
import com.youlai.boot.system.model.vo.MenuAiVO;
|
||||
import com.youlai.boot.system.model.vo.MenuVO;
|
||||
import com.youlai.boot.system.model.vo.RouteVO;
|
||||
|
||||
@@ -27,9 +29,9 @@ public interface MenuService extends IService<Menu> {
|
||||
/**
|
||||
* 获取菜单下拉列表
|
||||
*
|
||||
* @param onlyParent 是否只查询父级菜单
|
||||
* @param types 菜单类型,为空查全部
|
||||
*/
|
||||
List<Option<Long>> listMenuOptions(boolean onlyParent);
|
||||
List<Option<Long>> listMenuOptions(List<String> types);
|
||||
|
||||
/**
|
||||
* 新增菜单
|
||||
@@ -81,4 +83,12 @@ public interface MenuService extends IService<Menu> {
|
||||
* @param dto 创建菜单数据载体
|
||||
*/
|
||||
void addMenuForCodegen(Long parentMenuId, MenuCreateDTO dto);
|
||||
|
||||
/**
|
||||
* 用 AI 推断菜单的访问路径、权限标识与配套按钮,命名风格参照同级菜单
|
||||
*
|
||||
* @param form 菜单信息
|
||||
* @return 推断结果
|
||||
*/
|
||||
MenuAiVO aiFillMenu(MenuAiFillForm form);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.youlai.boot.system.service.impl;
|
||||
|
||||
import cn.hutool.core.lang.Assert;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
@@ -44,7 +45,6 @@ public class DictItemServiceImpl extends ServiceImpl<DictItemMapper, DictItem> i
|
||||
return this.baseMapper.getDictItemPage(page, queryParams);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取字典项列表
|
||||
*
|
||||
@@ -57,6 +57,7 @@ public class DictItemServiceImpl extends ServiceImpl<DictItemMapper, DictItem> i
|
||||
.eq(DictItem::getDictCode, dictCode)
|
||||
.eq(DictItem::getStatus, 1)
|
||||
.orderByAsc(DictItem::getSort)
|
||||
.orderByAsc(DictItem::getId)
|
||||
).stream()
|
||||
.map(item -> {
|
||||
DictItemOptionVO dictItemOptionVo = new DictItemOptionVO();
|
||||
@@ -67,8 +68,6 @@ public class DictItemServiceImpl extends ServiceImpl<DictItemMapper, DictItem> i
|
||||
}).toList();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 获取字典项表单
|
||||
*
|
||||
@@ -76,7 +75,7 @@ public class DictItemServiceImpl extends ServiceImpl<DictItemMapper, DictItem> i
|
||||
* @return 字典项表单
|
||||
*/
|
||||
@Override
|
||||
public DictItemForm getDictItemForm( Long itemId) {
|
||||
public DictItemForm getDictItemForm(Long itemId) {
|
||||
DictItem entity = this.getById(itemId);
|
||||
return dictItemConverter.toForm(entity);
|
||||
}
|
||||
@@ -89,6 +88,7 @@ public class DictItemServiceImpl extends ServiceImpl<DictItemMapper, DictItem> i
|
||||
*/
|
||||
@Override
|
||||
public boolean saveDictItem(DictItemForm formData) {
|
||||
validateValueUnique(formData.getDictCode(), formData.getValue(), null);
|
||||
DictItem entity = dictItemConverter.toEntity(formData);
|
||||
return this.save(entity);
|
||||
}
|
||||
@@ -101,6 +101,7 @@ public class DictItemServiceImpl extends ServiceImpl<DictItemMapper, DictItem> i
|
||||
*/
|
||||
@Override
|
||||
public boolean updateDictItem(DictItemForm formData) {
|
||||
validateValueUnique(formData.getDictCode(), formData.getValue(), formData.getId());
|
||||
DictItem entity = dictItemConverter.toEntity(formData);
|
||||
return this.updateById(entity);
|
||||
}
|
||||
@@ -118,8 +119,20 @@ public class DictItemServiceImpl extends ServiceImpl<DictItemMapper, DictItem> i
|
||||
this.removeByIds(idList);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验同一字典下字典项值唯一
|
||||
*
|
||||
* @param dictCode 字典编码
|
||||
* @param value 字典项值
|
||||
* @param excludeId 更新时传当前字典项ID以排除自身,新增传 null 不排除任何记录
|
||||
*/
|
||||
private void validateValueUnique(String dictCode, String value, Long excludeId) {
|
||||
long count = this.count(new LambdaQueryWrapper<DictItem>()
|
||||
.eq(DictItem::getDictCode, dictCode)
|
||||
.eq(DictItem::getValue, value)
|
||||
.ne(excludeId != null, DictItem::getId, excludeId)
|
||||
);
|
||||
Assert.isTrue(count == 0, "该字典下字典项值已存在");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -134,7 +134,7 @@ public class DictServiceImpl extends ServiceImpl<DictMapper, Dict> implements Di
|
||||
.eq(DictItem::getDictCode, entity.getDictCode())
|
||||
.select(DictItem::getId)
|
||||
);
|
||||
if (!dictItemList.isEmpty()){
|
||||
if (!dictItemList.isEmpty()) {
|
||||
List<Long> dictItemIds = dictItemList.stream().map(DictItem::getId).toList();
|
||||
DictItem dictItem = new DictItem();
|
||||
dictItem.setDictCode(dict.getDictCode());
|
||||
@@ -155,13 +155,14 @@ public class DictServiceImpl extends ServiceImpl<DictMapper, Dict> implements Di
|
||||
@Transactional
|
||||
@Override
|
||||
public void deleteDictByIds(List<String> ids) {
|
||||
// 先取字典编码:删除后再查会被逻辑删除条件过滤,导致字典项无法级联删除
|
||||
List<String> dictCodes = this.listByIds(ids).stream().map(Dict::getDictCode).toList();
|
||||
|
||||
// 删除字典
|
||||
this.removeByIds(ids);
|
||||
|
||||
// 删除字典项
|
||||
List<Dict> list = this.listByIds(ids);
|
||||
if (!list.isEmpty()) {
|
||||
List<String> dictCodes = list.stream().map(Dict::getDictCode).toList();
|
||||
if (!dictCodes.isEmpty()) {
|
||||
dictItemService.remove(new LambdaQueryWrapper<DictItem>()
|
||||
.in(DictItem::getDictCode, dictCodes)
|
||||
);
|
||||
@@ -181,7 +182,3 @@ public class DictServiceImpl extends ServiceImpl<DictMapper, Dict> implements Di
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -7,14 +7,19 @@ import cn.hutool.core.util.StrUtil;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.youlai.boot.common.base.IBaseEnum;
|
||||
import com.youlai.boot.common.exception.BusinessException;
|
||||
import com.youlai.boot.framework.security.util.SecurityUtils;
|
||||
import com.youlai.boot.support.ai.AiChatService;
|
||||
import com.youlai.boot.support.ai.PromptLoader;
|
||||
import com.youlai.boot.system.converter.MenuConverter;
|
||||
import com.youlai.boot.system.mapper.MenuMapper;
|
||||
import com.youlai.boot.system.model.dto.MenuCreateDTO;
|
||||
import com.youlai.boot.system.model.entity.Menu;
|
||||
import com.youlai.boot.system.model.form.MenuAiFillForm;
|
||||
import com.youlai.boot.system.model.form.MenuForm;
|
||||
import com.youlai.boot.system.model.query.MenuQuery;
|
||||
import com.youlai.boot.system.model.vo.MenuAiVO;
|
||||
import com.youlai.boot.system.model.vo.MenuVO;
|
||||
import com.youlai.boot.system.model.vo.RouteVO;
|
||||
import com.youlai.boot.common.constant.SystemConstants;
|
||||
@@ -46,6 +51,12 @@ public class MenuServiceImpl extends ServiceImpl<MenuMapper, Menu> implements Me
|
||||
|
||||
private final RoleMenuService roleMenuService;
|
||||
|
||||
/** 系统提示词名称,对应 resources/prompts 下的相对路径 */
|
||||
private static final String SYSTEM_PROMPT_PATH = "menu/system.md";
|
||||
|
||||
private final AiChatService aiChatService;
|
||||
private final PromptLoader promptLoader;
|
||||
|
||||
|
||||
/**
|
||||
* 菜单列表
|
||||
@@ -101,12 +112,12 @@ public class MenuServiceImpl extends ServiceImpl<MenuMapper, Menu> implements Me
|
||||
/**
|
||||
* 菜单下拉数据
|
||||
*
|
||||
* @param onlyParent 是否只查询父级菜单 如果为true,排除按钮
|
||||
* @param types 菜单类型,为空查全部
|
||||
*/
|
||||
@Override
|
||||
public List<Option<Long>> listMenuOptions(boolean onlyParent) {
|
||||
public List<Option<Long>> listMenuOptions(List<String> types) {
|
||||
List<Menu> menuList = this.list(new LambdaQueryWrapper<Menu>()
|
||||
.in(onlyParent, Menu::getType, MenuTypeEnum.CATALOG.getValue(), MenuTypeEnum.MENU.getValue())
|
||||
.in(CollectionUtil.isNotEmpty(types), Menu::getType, types)
|
||||
.orderByAsc(Menu::getSort)
|
||||
);
|
||||
return buildMenuOptions(SystemConstants.ROOT_NODE_ID, menuList);
|
||||
@@ -266,8 +277,6 @@ public class MenuServiceImpl extends ServiceImpl<MenuMapper, Menu> implements Me
|
||||
&& ObjectUtil.equals(menu.getKeepAlive(), 1)) {
|
||||
meta.setKeepAlive(true);
|
||||
}
|
||||
meta.setAlwaysShow(ObjectUtil.equals(menu.getAlwaysShow(), 1));
|
||||
|
||||
if (isEmbedded && StrUtil.isNotBlank(externalUrl)) {
|
||||
meta.setExternalUrl(externalUrl);
|
||||
}
|
||||
@@ -306,10 +315,16 @@ public class MenuServiceImpl extends ServiceImpl<MenuMapper, Menu> implements Me
|
||||
if (Objects.equals(menuForm.getParentId(), menuForm.getId())) {
|
||||
throw new BusinessException("父级菜单不能为当前菜单");
|
||||
}
|
||||
validateMenuParent(menuForm);
|
||||
Menu entity = menuConverter.toEntity(menuForm);
|
||||
String treePath = generateMenuTreePath(menuForm.getParentId());
|
||||
entity.setTreePath(treePath);
|
||||
|
||||
// 新增菜单未指定排序时排到同级末尾
|
||||
if (menuForm.getId() == null && entity.getSort() == null) {
|
||||
entity.setSort(resolveNextSort(menuForm.getParentId()));
|
||||
}
|
||||
|
||||
List<KeyValue> params = menuForm.getParams();
|
||||
if (CollectionUtil.isNotEmpty(params)) {
|
||||
entity.setParams(params.stream()
|
||||
@@ -331,9 +346,13 @@ public class MenuServiceImpl extends ServiceImpl<MenuMapper, Menu> implements Me
|
||||
|
||||
boolean result = this.saveOrUpdate(entity);
|
||||
if (result) {
|
||||
// 编辑刷新角色权限缓存
|
||||
if (menuForm.getId() != null) {
|
||||
// 编辑刷新角色权限缓存
|
||||
roleMenuService.refreshRolePermsCache();
|
||||
} else if (Boolean.TRUE.equals(menuForm.getGenerateCrudButtons())
|
||||
&& StrUtil.isNotBlank(menuForm.getButtonPermPrefix())) {
|
||||
// 新增页面菜单时按需生成增删改查按钮
|
||||
saveCrudButtons(entity.getId(), menuForm.getButtonPermPrefix().trim(), treePath);
|
||||
}
|
||||
}
|
||||
// 修改菜单如果有子菜单,则更新子菜单的树路径
|
||||
@@ -378,6 +397,77 @@ public class MenuServiceImpl extends ServiceImpl<MenuMapper, Menu> implements Me
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 同级菜单的最大排序 + 1
|
||||
*
|
||||
* @param parentId 父菜单ID
|
||||
* @return 下一个排序值
|
||||
*/
|
||||
private int resolveNextSort(Long parentId) {
|
||||
Menu maxSortMenu = this.getOne(new LambdaQueryWrapper<Menu>()
|
||||
.eq(Menu::getParentId, parentId)
|
||||
.orderByDesc(Menu::getSort)
|
||||
.last("LIMIT 1")
|
||||
);
|
||||
return maxSortMenu == null || maxSortMenu.getSort() == null ? 1 : maxSortMenu.getSort() + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验上级菜单层级:按钮只能挂在菜单下,其他类型只能挂在顶级或目录下
|
||||
*
|
||||
* @param menuForm 菜单表单
|
||||
*/
|
||||
private void validateMenuParent(MenuForm menuForm) {
|
||||
// 父级没变时跳过校验,让历史遗留的非法层级仍可编辑其他字段
|
||||
if (menuForm.getId() != null) {
|
||||
Menu current = this.getById(menuForm.getId());
|
||||
if (current != null && Objects.equals(current.getParentId(), menuForm.getParentId())) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
boolean isButton = MenuTypeEnum.BUTTON.getValue().equals(menuForm.getType());
|
||||
Long parentId = menuForm.getParentId();
|
||||
if (parentId == null || SystemConstants.ROOT_NODE_ID.equals(parentId)) {
|
||||
Assert.isFalse(isButton, "按钮权限只能挂在菜单下");
|
||||
return;
|
||||
}
|
||||
|
||||
Menu parent = this.getById(parentId);
|
||||
Assert.notNull(parent, "上级菜单不存在");
|
||||
if (isButton) {
|
||||
Assert.isTrue(MenuTypeEnum.MENU.getValue().equals(parent.getType()), "按钮权限只能挂在菜单下");
|
||||
} else {
|
||||
Assert.isFalse(MenuTypeEnum.MENU.getValue().equals(parent.getType()), "菜单下只能挂按钮权限");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成增删改查按钮权限
|
||||
*
|
||||
* @param menuId 所属菜单ID
|
||||
* @param permPrefix 权限标识前缀,不含末尾冒号,如 sys:user
|
||||
* @param menuTreePath 所属菜单的树路径
|
||||
*/
|
||||
private void saveCrudButtons(Long menuId, String permPrefix, String menuTreePath) {
|
||||
String[] names = {"查询", "新增", "修改", "删除"};
|
||||
String[] actions = {"list", "create", "update", "delete"};
|
||||
// 树路径记录祖先链,等于所属菜单的树路径加上所属菜单ID,删除菜单时据此级联
|
||||
String buttonTreePath = menuTreePath + "," + menuId;
|
||||
|
||||
for (int i = 0; i < names.length; i++) {
|
||||
Menu button = new Menu();
|
||||
button.setParentId(menuId);
|
||||
button.setType(MenuTypeEnum.BUTTON.getValue());
|
||||
button.setName(names[i]);
|
||||
button.setPerm(permPrefix + ":" + actions[i]);
|
||||
button.setSort(i + 1);
|
||||
button.setVisible(1);
|
||||
button.setTreePath(buttonTreePath);
|
||||
this.save(button);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 修改菜单显示状态
|
||||
@@ -456,15 +546,7 @@ public class MenuServiceImpl extends ServiceImpl<MenuMapper, Menu> implements Me
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取父级菜单子菜单最带的排序
|
||||
Menu maxSortMenu = this.getOne(new LambdaQueryWrapper<Menu>().eq(Menu::getParentId, parentMenuId)
|
||||
.orderByDesc(Menu::getSort)
|
||||
.last("limit 1")
|
||||
);
|
||||
int sort = 1;
|
||||
if (maxSortMenu != null) {
|
||||
sort = maxSortMenu.getSort() + 1;
|
||||
}
|
||||
int sort = resolveNextSort(parentMenuId);
|
||||
|
||||
Menu menu = new Menu();
|
||||
menu.setParentId(parentMenuId);
|
||||
@@ -485,24 +567,72 @@ public class MenuServiceImpl extends ServiceImpl<MenuMapper, Menu> implements Me
|
||||
this.updateById(menu);
|
||||
|
||||
// 生成 CRUD 按钮权限
|
||||
String permPrefix = dto.moduleName() + ":" + dto.tableName().replace("_", "-") + ":";
|
||||
String[] actions = {"查询", "新增", "修改", "删除"};
|
||||
String[] perms = {"list", "create", "update", "delete"};
|
||||
|
||||
for (int i = 0; i < actions.length; i++) {
|
||||
Menu button = new Menu();
|
||||
button.setParentId(menu.getId());
|
||||
button.setType(MenuTypeEnum.BUTTON.getValue());
|
||||
button.setName(actions[i]);
|
||||
button.setPerm(permPrefix + perms[i]);
|
||||
button.setSort(i + 1);
|
||||
this.save(button);
|
||||
|
||||
// 生成treePath
|
||||
button.setTreePath(treePath + "," + button.getId());
|
||||
this.updateById(button);
|
||||
}
|
||||
saveCrudButtons(menu.getId(), dto.moduleName() + ":" + dto.tableName().replace("_", "-"), treePath);
|
||||
}
|
||||
}
|
||||
|
||||
//---------------------------------------------------
|
||||
// AI 辅助:未开启 AI 时调用返回业务异常
|
||||
//---------------------------------------------------
|
||||
|
||||
/**
|
||||
* 用 AI 推断菜单的访问路径、权限标识与配套按钮
|
||||
*
|
||||
* @param form 菜单信息
|
||||
* @return 推断结果
|
||||
*/
|
||||
@Override
|
||||
public MenuAiVO aiFillMenu(MenuAiFillForm form) {
|
||||
return aiChatService.chatEntity(
|
||||
promptLoader.get(SYSTEM_PROMPT_PATH), buildAiUserPrompt(form), MenuAiVO.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造用户提示词,附带同级菜单样本供模型沿用既有命名风格
|
||||
*
|
||||
* @param form 菜单信息
|
||||
* @return 用户提示词
|
||||
*/
|
||||
private String buildAiUserPrompt(MenuAiFillForm form) {
|
||||
StringBuilder text = new StringBuilder();
|
||||
text.append("菜单名称:").append(form.getName()).append("\n");
|
||||
text.append("菜单类型:").append(menuTypeLabel(form.getType())).append("\n");
|
||||
|
||||
Long parentId = form.getParentId() == null ? SystemConstants.ROOT_NODE_ID : form.getParentId();
|
||||
Menu parent = this.getById(parentId);
|
||||
text.append("上级菜单:").append(parent == null ? "顶级" : parent.getName()).append("\n");
|
||||
|
||||
List<Menu> siblings = this.list(new LambdaQueryWrapper<Menu>()
|
||||
.eq(Menu::getParentId, parentId)
|
||||
.orderByAsc(Menu::getSort)
|
||||
.last("LIMIT 10")
|
||||
);
|
||||
text.append("同级菜单示例:\n");
|
||||
if (CollectionUtil.isEmpty(siblings)) {
|
||||
text.append("(无)\n");
|
||||
} else {
|
||||
siblings.forEach(menu -> text.append("- ")
|
||||
.append(menu.getName())
|
||||
.append("(类型 ").append(menuTypeLabel(menu.getType()))
|
||||
.append(",路径片段 ").append(StrUtil.blankToDefault(menu.getRoutePath(), "无"))
|
||||
.append(",权限 ").append(StrUtil.blankToDefault(menu.getPerm(), "无"))
|
||||
.append(")\n"));
|
||||
}
|
||||
return text.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 菜单类型取中文名称,枚举无匹配时沿用原值
|
||||
*
|
||||
* @param type 菜单类型
|
||||
* @return 中文名称
|
||||
*/
|
||||
private String menuTypeLabel(String type) {
|
||||
if (StrUtil.isBlank(type)) {
|
||||
return "未指定";
|
||||
}
|
||||
MenuTypeEnum menuType = IBaseEnum.getEnumByValue(type, MenuTypeEnum.class);
|
||||
return menuType == null ? type : menuType.getLabel();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user