feat: 增加技能管理模块试验性功能,等待优化

This commit is contained in:
2026-06-08 16:52:41 +08:00
parent cb379e071c
commit 950148b3f7
68 changed files with 4901 additions and 135 deletions

View File

@@ -24,6 +24,10 @@
<groupId>tech.easyflow</groupId>
<artifactId>easyflow-module-agent</artifactId>
</dependency>
<dependency>
<groupId>tech.easyflow</groupId>
<artifactId>easyflow-module-skill</artifactId>
</dependency>
<dependency>
<groupId>tech.easyflow</groupId>
<artifactId>easyflow-module-chatlog</artifactId>

View File

@@ -6,6 +6,7 @@ import tech.easyflow.ai.entity.Bot;
import tech.easyflow.ai.entity.DocumentCollection;
import tech.easyflow.ai.entity.Plugin;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.skill.entity.Skill;
import tech.easyflow.system.service.SysAccountService;
import javax.annotation.Resource;
@@ -76,6 +77,15 @@ public class AiResourceCreatorNameSupport {
fillCreatorNames(agents, Agent::getCreatedBy, Agent::setCreatedByName);
}
/**
* 批量填充 Skill 创建人名称。
*
* @param skills Skill 集合
*/
public void fillSkillCreatorNames(Collection<Skill> skills) {
fillCreatorNames(skills, Skill::getCreatedBy, Skill::setCreatedByName);
}
/**
* 通用的创建人名称填充逻辑。
*

View File

@@ -0,0 +1,91 @@
package tech.easyflow.admin.controller.skill;
import com.mybatisflex.core.query.QueryWrapper;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import tech.easyflow.common.annotation.UsePermission;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.web.controller.BaseCurdController;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.skill.entity.Skill;
import tech.easyflow.skill.entity.SkillCategory;
import tech.easyflow.skill.mapper.SkillMapper;
import tech.easyflow.skill.service.SkillCategoryService;
import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot;
import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.service.CategoryPermissionService;
import javax.annotation.Resource;
import java.io.Serializable;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
/**
* Skill 分类管理控制器。
*/
@RestController
@RequestMapping("/api/v1/skillCategory")
@UsePermission(moduleName = "/api/v1/skill")
public class SkillCategoryController extends BaseCurdController<SkillCategoryService, SkillCategory> {
@Resource
private SkillMapper skillMapper;
@Resource
private CategoryPermissionService categoryPermissionService;
/**
* 创建 Skill 分类管理控制器。
*
* @param service Skill 分类服务
*/
public SkillCategoryController(SkillCategoryService service) {
super(service);
}
/**
* 查询当前用户可见的 Skill 分类。
*
* @param entity 查询条件
* @param asTree 是否转树
* @param sortKey 排序字段
* @param sortType 排序方式
* @return 可见分类列表
*/
@GetMapping("visibleList")
public Result<List<SkillCategory>> visibleList(SkillCategory entity, Boolean asTree, String sortKey, String sortType) {
QueryWrapper queryWrapper = QueryWrapper.create(entity, buildOperators(entity));
RoleCategoryAccessSnapshot access = categoryPermissionService.getCurrentAccess(CategoryResourceType.SKILL.getCode());
if (access.isRestricted()) {
if (access.getCategoryIds().isEmpty()) {
return Result.ok(Collections.emptyList());
}
queryWrapper.in("id", access.getCategoryIds());
}
queryWrapper.orderBy(buildOrderBy(sortKey, sortType, getDefaultOrderBy()));
return Result.ok(service.list(queryWrapper));
}
/**
* 删除分类前校验是否仍被 Skill 使用。
*
* @param ids 分类 ID 集合
* @return 校验结果
*/
@Override
protected Result<?> onRemoveBefore(Collection<Serializable> ids) {
for (Serializable id : ids) {
List<Skill> skills = skillMapper.selectListByQuery(QueryWrapper.create().eq(Skill::getCategoryId, id));
if (skills != null && !skills.isEmpty()) {
throw new BusinessException("请先迁移或删除该分类下的 Skill");
}
List<SkillCategory> children = service.list(QueryWrapper.create().eq(SkillCategory::getParentId, id));
if (children != null && !children.isEmpty()) {
throw new BusinessException("请先删除子分类");
}
}
return super.onRemoveBefore(ids);
}
}

View File

@@ -0,0 +1,359 @@
package tech.easyflow.admin.controller.skill;
import cn.dev33.satoken.annotation.SaCheckPermission;
import com.mybatisflex.core.paginate.Page;
import com.mybatisflex.core.query.QueryWrapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.MediaType;
import org.springframework.util.StreamUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.multipart.MultipartFile;
import tech.easyflow.admin.controller.ai.support.AiResourceCreatorNameSupport;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.approval.entity.vo.ApprovalActionResult;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.web.controller.BaseCurdController;
import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.skill.entity.Skill;
import tech.easyflow.skill.file.SkillFileContent;
import tech.easyflow.skill.file.SkillFileNode;
import tech.easyflow.skill.file.SkillFileSaveRequest;
import tech.easyflow.skill.file.SkillFileService;
import tech.easyflow.skill.imports.SkillExportService;
import tech.easyflow.skill.imports.SkillImportPreview;
import tech.easyflow.skill.imports.SkillImportService;
import tech.easyflow.skill.publish.SkillPublishAppService;
import tech.easyflow.skill.service.SkillApprovalStateService;
import tech.easyflow.skill.service.SkillService;
import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot;
import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.enums.ResourceAction;
import tech.easyflow.system.service.CategoryPermissionService;
import tech.easyflow.system.service.ResourceAccessService;
import javax.annotation.Resource;
import java.io.InputStream;
import java.io.Serializable;
import java.math.BigInteger;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import static tech.easyflow.skill.entity.table.SkillTableDef.SKILL;
/**
* Skill 管理端控制器。
*/
@RestController
@RequestMapping("/api/v1/skill")
public class SkillController extends BaseCurdController<SkillService, Skill> {
@Resource
private SkillApprovalStateService skillApprovalStateService;
@Resource
private SkillPublishAppService skillPublishAppService;
@Resource
private SkillImportService skillImportService;
@Resource
private SkillExportService skillExportService;
@Resource
private SkillFileService skillFileService;
@Resource
private ResourceAccessService resourceAccessService;
@Resource
private CategoryPermissionService categoryPermissionService;
@Resource
private AiResourceCreatorNameSupport aiResourceCreatorNameSupport;
/**
* 创建 Skill 控制器。
*
* @param service Skill 服务
*/
public SkillController(SkillService service) {
super(service);
}
/**
* 获取 Skill 详情。
*
* @param id Skill ID
* @return Skill 详情
*/
@GetMapping("/getDetail")
public Result<Skill> getDetail(BigInteger id) {
Skill skill = service.getDetail(id);
skillApprovalStateService.fillSkillApprovalState(skill);
return Result.ok(skill);
}
/**
* 保存 Skill 草稿。
*
* @param skill Skill 草稿
* @return 保存后的 Skill
*/
@Override
@PostMapping("save")
public Result<?> save(@JsonBody Skill skill) {
return Result.ok(service.saveDraft(skill));
}
/**
* 更新 Skill 草稿。
*
* @param skill Skill 草稿
* @return 保存后的 Skill
*/
@Override
@PostMapping("update")
public Result<?> update(@JsonBody Skill skill) {
return Result.ok(service.updateDraft(skill));
}
/**
* 预览 zip 导入结果。
*
* @param file zip 文件
* @return 导入预览
*/
@PostMapping(value = "/import/preview", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@SaCheckPermission("/api/v1/skill/save")
public Result<SkillImportPreview> importPreview(MultipartFile file) throws Exception {
return Result.ok(skillImportService.preview(file.getInputStream()));
}
/**
* 确认导入 zip。
*
* @param file zip 文件
* @param categoryId 分类 ID
* @param overwriteDraft 是否覆盖草稿
* @return 导入后的 Skill 列表
*/
@PostMapping(value = "/import/confirm", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@SaCheckPermission("/api/v1/skill/save")
public Result<List<Skill>> importConfirm(MultipartFile file, BigInteger categoryId, Boolean overwriteDraft) throws Exception {
return Result.ok(skillImportService.importZip(file.getInputStream(), categoryId, Boolean.TRUE.equals(overwriteDraft)));
}
/**
* 导出 Skill zip。
*
* @param ids Skill ID 集合
* @param response HTTP 响应
*/
@PostMapping("/export")
public void export(@JsonBody(value = "ids", required = true) List<BigInteger> ids, HttpServletResponse response) throws Exception {
response.setContentType("application/zip");
response.setHeader("Content-Disposition", "attachment; filename=\"" + URLEncoder.encode("skills.zip", StandardCharsets.UTF_8) + "\"");
skillExportService.exportZip(ids, response.getOutputStream());
}
/**
* 获取 Skill 文件树。
*
* @param skillId Skill ID
* @return 文件树
*/
@GetMapping("/file/tree")
public Result<List<SkillFileNode>> fileTree(BigInteger skillId) {
return Result.ok(skillFileService.tree(skillId));
}
/**
* 获取 Skill 文件内容。
*
* @param skillId Skill ID
* @param path 逻辑路径
* @return 文件内容
*/
@GetMapping("/file/content")
public Result<SkillFileContent> fileContent(BigInteger skillId, String path) {
return Result.ok(skillFileService.getContent(skillId, path));
}
/**
* 保存 Skill 文本文件。
*
* @param request 保存请求
* @return 保存后的文件内容
*/
@PostMapping("/file/save")
@SaCheckPermission("/api/v1/skill/save")
public Result<SkillFileContent> saveFile(@JsonBody SkillFileSaveRequest request) {
return Result.ok(skillFileService.saveContent(request));
}
/**
* 删除 Skill 逻辑文件。
*
* @param skillId Skill ID
* @param path 逻辑路径
* @return 操作结果
*/
@PostMapping("/file/delete")
@SaCheckPermission("/api/v1/skill/save")
public Result<Void> deleteFile(@JsonBody(value = "skillId", required = true) BigInteger skillId,
@JsonBody(value = "path", required = true) String path) {
skillFileService.deleteFile(skillId, path);
return Result.ok();
}
/**
* 上传 Skill asset。
*
* @param skillId Skill ID
* @param path 逻辑路径
* @param file 上传文件
* @return asset 内容
*/
@PostMapping(value = "/file/asset/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@SaCheckPermission("/api/v1/skill/save")
public Result<SkillFileContent> uploadAsset(BigInteger skillId, String path, MultipartFile file) {
return Result.ok(skillFileService.uploadAsset(skillId, path, file));
}
/**
* 下载或预览 Skill asset。
*
* @param skillId Skill ID
* @param path asset 逻辑路径
* @param response HTTP 响应
*/
@GetMapping("/file/asset")
public void asset(BigInteger skillId, String path, HttpServletResponse response) throws Exception {
SkillFileContent content = skillFileService.getContent(skillId, path);
response.setContentType(content.getMediaType() == null ? MediaType.APPLICATION_OCTET_STREAM_VALUE : content.getMediaType());
response.setHeader("Content-Disposition", "inline; filename=\"" + URLEncoder.encode(fileName(path), StandardCharsets.UTF_8) + "\"");
try (InputStream inputStream = skillFileService.openAsset(skillId, path)) {
StreamUtils.copy(inputStream, response.getOutputStream());
}
}
/**
* 提交发布审批。
*
* @param id Skill ID
* @return 审批实例 ID
*/
@PostMapping("/submitPublishApproval")
@SaCheckPermission("/api/v1/skill/save")
public Result<BigInteger> submitPublishApproval(@JsonBody("id") BigInteger id) {
return buildApprovalActionResult(skillPublishAppService.submitPublishApproval(id), "已提交发布审批", "已直接发布");
}
/**
* 提交下线审批。
*
* @param id Skill ID
* @return 审批实例 ID
*/
@PostMapping("/submitOfflineApproval")
@SaCheckPermission("/api/v1/skill/save")
public Result<BigInteger> submitOfflineApproval(@JsonBody("id") BigInteger id) {
return buildApprovalActionResult(skillPublishAppService.submitOfflineApproval(id), "已提交下线审批", "已直接下线");
}
/**
* 提交删除审批。
*
* @param id Skill ID
* @return 审批实例 ID
*/
@PostMapping("/submitDeleteApproval")
@SaCheckPermission("/api/v1/skill/remove")
public Result<BigInteger> submitDeleteApproval(@JsonBody("id") BigInteger id) {
return buildApprovalActionResult(skillPublishAppService.submitDeleteApproval(id), "已提交删除审批", "已直接删除");
}
@Override
protected Result<?> onRemoveBefore(Collection<Serializable> ids) {
for (Serializable id : ids) {
Skill skill = service.getById(String.valueOf(id));
if (skill != null) {
resourceAccessService.assertAccess(CategoryResourceType.SKILL, skill, ResourceAction.MANAGE, "无权限删除该 Skill");
}
}
return super.onRemoveBefore(ids);
}
/**
* 查询 Skill 分页。
*
* @param page 分页参数
* @param queryWrapper 查询条件
* @return Skill 分页
*/
@Override
protected Page<Skill> queryPage(Page<Skill> page, QueryWrapper queryWrapper) {
if (!applyCategoryPermission(queryWrapper)) {
return new Page<>(Collections.emptyList(), page.getPageNumber(), page.getPageSize(), 0L);
}
applyPublishedOnlyFilter(queryWrapper);
Page<Skill> result = super.queryPage(page, queryWrapper);
if (isPublishedOnlyRequest()) {
result.setRecords(result.getRecords().stream().map(skill -> service.fromSnapshot(skill.getPublishedSnapshotJson())).toList());
}
skillApprovalStateService.fillSkillApprovalState(result.getRecords());
aiResourceCreatorNameSupport.fillSkillCreatorNames(result.getRecords());
return result;
}
private boolean applyCategoryPermission(QueryWrapper queryWrapper) {
RoleCategoryAccessSnapshot access = categoryPermissionService.getCurrentAccess(CategoryResourceType.SKILL.getCode());
if (!access.isRestricted()) {
return true;
}
if (access.getCategoryIds().isEmpty()) {
queryWrapper.eq(Skill::getCreatedBy, access.getAccountId());
return true;
}
queryWrapper.and(SKILL.CREATED_BY.eq(access.getAccountId()).or(SKILL.CATEGORY_ID.in(access.getCategoryIds())));
return true;
}
private void applyPublishedOnlyFilter(QueryWrapper queryWrapper) {
if (isPublishedOnlyRequest()) {
queryWrapper.eq("publish_status", PublishStatus.PUBLISHED.getCode());
}
}
private boolean isPublishedOnlyRequest() {
HttpServletRequest request = currentRequest();
if (request == null) {
return false;
}
return "true".equalsIgnoreCase(request.getParameter("publishedOnly"));
}
private HttpServletRequest currentRequest() {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes == null) {
return null;
}
return attributes.getRequest();
}
private Result<BigInteger> buildApprovalActionResult(ApprovalActionResult actionResult,
String approvalMessage,
String directMessage) {
return Result.ok(actionResult.isApprovalRequired() ? approvalMessage : directMessage, actionResult.getInstanceId());
}
private String fileName(String path) {
if (path == null || path.isBlank()) {
return "asset";
}
int index = path.lastIndexOf('/');
return index < 0 ? path : path.substring(index + 1);
}
}