feat: 完善 Skill 管理与发布治理

- 实现标准资源存储、能力绑定及双格式导入导出

- 接入分类、可见范围、审批发布与资源权限校验

- 补充并发、租户隔离、安全边界和迁移契约测试
This commit is contained in:
2026-07-27 18:54:20 +08:00
parent aedefe6b5e
commit 2a9e882ac6
165 changed files with 23737 additions and 1088 deletions

View File

@@ -1,38 +1,45 @@
package tech.easyflow.admin.controller.skill;
import cn.dev33.satoken.annotation.SaCheckPermission;
import com.mybatisflex.core.query.QueryWrapper;
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 tech.easyflow.common.annotation.UsePermission;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.web.controller.BaseCurdController;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.skill.entity.Skill;
import tech.easyflow.common.web.jsonbody.JsonBody;
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.math.BigInteger;
import java.util.Collections;
import java.util.List;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
/**
* Skill 分类管理控制器。
*/
@RestController
@RequestMapping("/api/v1/skillCategory")
@RequestMapping("/api/v1/skill/category")
@UsePermission(moduleName = "/api/v1/skill")
public class SkillCategoryController extends BaseCurdController<SkillCategoryService, SkillCategory> {
public class SkillCategoryController {
@Resource
private SkillMapper skillMapper;
@Resource
private static final Set<String> SORT_COLUMNS = Set.of(
"id", "category_name", "parent_id", "level_no", "sort_no", "status", "created", "modified");
private final SkillCategoryService service;
@javax.annotation.Resource
private CategoryPermissionService categoryPermissionService;
/**
@@ -41,7 +48,7 @@ public class SkillCategoryController extends BaseCurdController<SkillCategorySer
* @param service Skill 分类服务
*/
public SkillCategoryController(SkillCategoryService service) {
super(service);
this.service = service;
}
/**
@@ -54,8 +61,18 @@ public class SkillCategoryController extends BaseCurdController<SkillCategorySer
* @return 可见分类列表
*/
@GetMapping("visibleList")
@SaCheckPermission("/api/v1/skill/query")
public Result<List<SkillCategory>> visibleList(SkillCategory entity, Boolean asTree, String sortKey, String sortType) {
QueryWrapper queryWrapper = QueryWrapper.create(entity, buildOperators(entity));
QueryWrapper queryWrapper = QueryWrapper.create()
.eq(SkillCategory::getTenantId, currentAccount().getTenantId());
if (entity != null) {
queryWrapper.eq(SkillCategory::getId, entity.getId(), entity.getId() != null)
.eq(SkillCategory::getParentId, entity.getParentId(), entity.getParentId() != null)
.eq(SkillCategory::getLevelNo, entity.getLevelNo(), entity.getLevelNo() != null)
.eq(SkillCategory::getStatus, entity.getStatus(), entity.getStatus() != null)
.like(SkillCategory::getCategoryName, entity.getCategoryName(),
entity.getCategoryName() != null && !entity.getCategoryName().isBlank());
}
RoleCategoryAccessSnapshot access = categoryPermissionService.getCurrentAccess(CategoryResourceType.SKILL.getCode());
if (access.isRestricted()) {
if (access.getCategoryIds().isEmpty()) {
@@ -63,29 +80,157 @@ public class SkillCategoryController extends BaseCurdController<SkillCategorySer
}
queryWrapper.in("id", access.getCategoryIds());
}
queryWrapper.orderBy(buildOrderBy(sortKey, sortType, getDefaultOrderBy()));
return Result.ok(service.list(queryWrapper));
queryWrapper.orderBy(resolveOrderBy(sortKey, sortType));
List<SkillCategory> categories = service.list(queryWrapper);
return Result.ok(Boolean.FALSE.equals(asTree) ? categories : toTree(categories));
}
/**
* 删除分类前校验是否仍被 Skill 使用
* 查询当前租户完整分类管理树,包含停用分类
*
* @param ids 分类 ID 集合
* @return 校验结果
* @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("请先删除子分类");
@GetMapping("tree")
@SaCheckPermission("/api/v1/skill/category")
public Result<List<SkillCategory>> tree() {
List<SkillCategory> categories = service.list(QueryWrapper.create()
.eq(SkillCategory::getTenantId, currentAccount().getTenantId())
.orderBy("sort_no asc, id asc"));
return Result.ok(toTree(categories));
}
/**
* 移动 Skill 分类到新的父级。
*
* @param id 分类 ID
* @param parentId 新父级 ID根分类为空
* @return 更新结果
*/
@PostMapping("move")
@SaCheckPermission("/api/v1/skill/category")
public Result<?> move(
@JsonBody(value = "id", required = true, skipConvertError = false) BigInteger id,
@JsonBody(value = "parentId", skipConvertError = false) BigInteger parentId) {
SkillCategory category = service.getOne(QueryWrapper.create()
.eq(SkillCategory::getId, id)
.eq(SkillCategory::getTenantId, currentAccount().getTenantId()));
if (category == null) {
throw new BusinessException(404, 404, "Skill 分类不存在");
}
category.setParentId(parentId);
if (!service.updateById(category)) {
throw new BusinessException(500, 500, "移动 Skill 分类失败,请稍后重试");
}
return Result.ok();
}
/**
* 创建 Skill 分类。
*
* @param entity 分类
* @return 保存结果
*/
@PostMapping("save")
@SaCheckPermission("/api/v1/skill/category")
public Result<?> save(@JsonBody(required = true, skipConvertError = false) SkillCategory entity) {
if (entity != null) {
entity.setId(null);
entity.setTenantId(null);
entity.setAncestors(null);
entity.setLevelNo(null);
entity.setCreated(null);
entity.setCreatedBy(null);
entity.setModified(null);
entity.setModifiedBy(null);
}
if (!service.save(entity)) {
throw new BusinessException(500, 500, "创建 Skill 分类失败,请稍后重试");
}
return Result.ok(entity);
}
/**
* 更新 Skill 分类。
*
* @param entity 分类
* @return 更新结果
*/
@PostMapping("update")
@SaCheckPermission("/api/v1/skill/category")
public Result<?> update(@JsonBody(required = true, skipConvertError = false) SkillCategory entity) {
if (entity != null) {
entity.setTenantId(null);
entity.setAncestors(null);
entity.setLevelNo(null);
entity.setCreated(null);
entity.setCreatedBy(null);
entity.setModified(null);
entity.setModifiedBy(null);
}
if (entity == null || entity.getId() == null) {
throw new BusinessException("Skill 分类 ID 不能为空");
}
if (!service.updateById(entity)) {
throw new BusinessException(500, 500, "更新 Skill 分类失败,请稍后重试");
}
return Result.ok(entity);
}
/**
* 删除 Skill 分类。
*
* @param id 分类 ID
* @return 删除结果
*/
@PostMapping("remove")
@SaCheckPermission("/api/v1/skill/category")
public Result<?> remove(
@JsonBody(value = "id", required = true, skipConvertError = false) Serializable id) {
if (!service.removeById(id)) {
throw new BusinessException(500, 500, "删除 Skill 分类失败,请稍后重试");
}
return Result.ok();
}
private LoginAccount currentAccount() {
LoginAccount account = SaTokenUtil.getLoginAccount();
if (account == null || account.getId() == null || account.getTenantId() == null) {
throw new BusinessException(401, 401, "未登录或登录态无效");
}
return account;
}
/**
* 将分类排序参数收敛到固定字段白名单,禁止原始 SQL 片段进入查询。
*
* @param sortKey 排序字段
* @param sortType 排序方向
* @return 安全排序表达式
*/
String resolveOrderBy(String sortKey, String sortType) {
String snake = sortKey == null ? "" : sortKey
.replaceAll("([a-z0-9])([A-Z])", "$1_$2")
.toLowerCase(Locale.ROOT);
String column = SORT_COLUMNS.contains(snake) ? snake : "sort_no";
String direction = "desc".equalsIgnoreCase(sortType) ? "desc" : "asc";
return column + " " + direction + ("id".equals(column) ? "" : ", id asc");
}
private List<SkillCategory> toTree(List<SkillCategory> categories) {
Map<java.math.BigInteger, SkillCategory> byId = new LinkedHashMap<>();
categories.forEach(category -> {
category.setChildren(null);
byId.put(category.getId(), category);
});
List<SkillCategory> roots = new java.util.ArrayList<>();
for (SkillCategory category : categories) {
SkillCategory parent = category.getParentId() == null ? null : byId.get(category.getParentId());
if (parent == null) {
roots.add(category);
} else {
parent.getChildren().add(category);
}
}
return super.onRemoveBefore(ids);
return roots;
}
}

View File

@@ -1,9 +1,10 @@
package tech.easyflow.admin.controller.skill;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.dev33.satoken.annotation.SaMode;
import cn.dev33.satoken.stp.StpUtil;
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;
@@ -11,151 +12,247 @@ 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.admin.controller.skill.vo.SkillCapabilityBindingRequest;
import tech.easyflow.admin.controller.skill.vo.SkillCapabilityReplaceView;
import tech.easyflow.admin.controller.skill.vo.SkillCopyRequest;
import tech.easyflow.admin.controller.skill.vo.SkillDraftRequest;
import tech.easyflow.admin.controller.skill.vo.SkillView;
import tech.easyflow.admin.controller.skill.vo.SkillPublishStatusView;
import tech.easyflow.approval.entity.vo.ApprovalActionResult;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.web.controller.BaseCurdController;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.skill.capability.SkillCapabilityBindingService;
import tech.easyflow.skill.capability.SkillCapabilityCandidate;
import tech.easyflow.skill.entity.Skill;
import tech.easyflow.skill.entity.SkillCapabilityBinding;
import tech.easyflow.skill.enums.SkillCapabilityType;
import tech.easyflow.skill.file.SkillFileContent;
import tech.easyflow.skill.file.SkillFileNode;
import tech.easyflow.skill.file.SkillFileRenameRequest;
import tech.easyflow.skill.file.SkillFileSaveRequest;
import tech.easyflow.skill.file.SkillFileService;
import tech.easyflow.skill.imports.SkillExportRequest;
import tech.easyflow.skill.imports.SkillExportArtifact;
import tech.easyflow.skill.imports.SkillExportService;
import tech.easyflow.skill.imports.SkillImportConfirmRequest;
import tech.easyflow.skill.imports.SkillImportFormat;
import tech.easyflow.skill.imports.SkillImportPreview;
import tech.easyflow.skill.imports.SkillImportService;
import tech.easyflow.skill.publish.SkillPublishAppService;
import tech.easyflow.skill.security.SkillVisibilityQueryHelper;
import tech.easyflow.skill.service.SkillApprovalStateService;
import tech.easyflow.skill.service.SkillService;
import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot;
import tech.easyflow.skill.validation.SkillValidationResult;
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.IOException;
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;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
/**
* Skill 管理端控制器
* Skill 管理端 API统一负责轻量查询、白名单写入、文件工作台、能力绑定和导入导出
*/
@RestController
@RequestMapping("/api/v1/skill")
public class SkillController extends BaseCurdController<SkillService, Skill> {
public class SkillController {
@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;
private static final Set<String> PAGE_SORT_COLUMNS = Set.of(
"id", "name", "display_name", "created", "modified", "publish_status", "resource_count", "capability_count");
private final SkillService skillService;
private final SkillApprovalStateService skillApprovalStateService;
private final SkillPublishAppService skillPublishAppService;
private final SkillImportService skillImportService;
private final SkillExportService skillExportService;
private final SkillFileService skillFileService;
private final SkillCapabilityBindingService capabilityBindingService;
private final ResourceAccessService resourceAccessService;
private final CategoryPermissionService categoryPermissionService;
private final SkillVisibilityQueryHelper visibilityQueryHelper;
private final AiResourceCreatorNameSupport creatorNameSupport;
/**
* 创建 Skill 控制器。
* 创建 Skill 管理控制器。
*
* @param service Skill 服务
* @param skillService Skill 服务
* @param skillApprovalStateService 审批状态服务
* @param skillPublishAppService 发布服务
* @param skillImportService 导入服务
* @param skillExportService 导出服务
* @param skillFileService 文件服务
* @param capabilityBindingService 能力绑定服务
* @param resourceAccessService 资源权限服务
* @param categoryPermissionService 分类权限服务
* @param visibilityQueryHelper 可见性查询助手
* @param creatorNameSupport 创建人名称助手
*/
public SkillController(SkillService service) {
super(service);
public SkillController(SkillService skillService,
SkillApprovalStateService skillApprovalStateService,
SkillPublishAppService skillPublishAppService,
SkillImportService skillImportService,
SkillExportService skillExportService,
SkillFileService skillFileService,
SkillCapabilityBindingService capabilityBindingService,
ResourceAccessService resourceAccessService,
CategoryPermissionService categoryPermissionService,
SkillVisibilityQueryHelper visibilityQueryHelper,
AiResourceCreatorNameSupport creatorNameSupport) {
this.skillService = skillService;
this.skillApprovalStateService = skillApprovalStateService;
this.skillPublishAppService = skillPublishAppService;
this.skillImportService = skillImportService;
this.skillExportService = skillExportService;
this.skillFileService = skillFileService;
this.capabilityBindingService = capabilityBindingService;
this.resourceAccessService = resourceAccessService;
this.categoryPermissionService = categoryPermissionService;
this.visibilityQueryHelper = visibilityQueryHelper;
this.creatorNameSupport = creatorNameSupport;
}
/**
* 获取 Skill 详情
* 分页查询当前用户可读的 Skill 描述信息
*
* @param pageNumber 页码
* @param pageSize 每页数量
* @param categoryId 分类 ID
* @param categoryScope 分类范围UNCATEGORIZED 表示未分类
* @param name 名称关键词
* @param displayName 展示名称关键词
* @param publishStatus 发布状态
* @param sourceType 来源类型
* @param capabilityType 能力类型
* @param sortKey 排序字段
* @param sortType 排序方向
* @return 轻量分页结果
*/
@GetMapping("/page")
@SaCheckPermission("/api/v1/skill/query")
public Result<Page<SkillView>> page(Long pageNumber, Long pageSize, BigInteger categoryId, String categoryScope,
String name, String displayName, String publishStatus, String sourceType,
String capabilityType, String sortKey, String sortType) {
long normalizedPage = pageNumber == null || pageNumber < 1 ? 1 : pageNumber;
long normalizedSize = pageSize == null || pageSize < 1 ? 10 : Math.min(pageSize, 100);
QueryWrapper query = descriptorQuery();
visibilityQueryHelper.applyReadableAccess(query);
if ("UNCATEGORIZED".equalsIgnoreCase(categoryScope)) {
query.isNull("category_id");
} else {
query.eq("category_id", categoryId, categoryId != null);
}
query
.eq("publish_status", publishStatus, hasText(publishStatus))
.eq("source_type", sourceType, hasText(sourceType));
String keyword = hasText(displayName) ? displayName : name;
if (hasText(keyword)) {
String pattern = "%" + keyword + "%";
query.and("(name LIKE ? OR display_name LIKE ? OR description LIKE ?)", pattern, pattern, pattern);
}
if (hasText(capabilityType)) {
SkillCapabilityType normalizedCapabilityType = SkillCapabilityType.from(capabilityType);
query.and("EXISTS (SELECT 1 FROM tb_skill_capability_binding b "
+ "WHERE b.skill_id = tb_skill.id AND b.tenant_id = tb_skill.tenant_id "
+ "AND b.capability_type = ?)", normalizedCapabilityType.name());
}
query.orderBy(resolveSortColumn(sortKey) + ("asc".equalsIgnoreCase(sortType) ? " asc" : " desc"));
Page<Skill> source = skillService.page(new Page<>(normalizedPage, normalizedSize), query);
fillListState(source.getRecords());
LoginAccount account = SaTokenUtil.getLoginAccount();
boolean superAdmin = account != null && categoryPermissionService.isSuperAdmin(account);
List<SkillView> records = source.getRecords().stream()
.map(skill -> toPageView(skill, account, superAdmin)).toList();
return Result.ok(new Page<>(records, source.getPageNumber(), source.getPageSize(), source.getTotalRow()));
}
/**
* 获取 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);
@GetMapping("/detail")
@SaCheckPermission("/api/v1/skill/getDetail")
public Result<SkillView> detail(BigInteger id) {
Skill skill = skillService.getManagementDetail(id);
if (!StpUtil.hasPermission("/api/v1/skill/capability")) {
skill.setCapabilityBindings(null);
skill.setCapabilityHash(null);
}
fillListState(List.of(skill));
return Result.ok(toView(skill));
}
/**
* 保存 Skill 草稿。
* 创建 Skill 草稿。
*
* @param skill Skill 草稿
* @return 保存后的 Skill
* @param request 草稿白名单请求
* @return 创建后的 Skill
*/
@Override
@PostMapping("save")
public Result<?> save(@JsonBody Skill skill) {
return Result.ok(service.saveDraft(skill));
@PostMapping("/save")
@SaCheckPermission("/api/v1/skill/save")
public Result<SkillView> save(@JsonBody(required = true, skipConvertError = false) SkillDraftRequest request) {
if (request == null || request.id() != null) {
throw new BusinessException("创建 Skill 时不能指定 ID");
}
return Result.ok(toView(skillService.saveDraft(request.toEntity())));
}
/**
* 更新 Skill 草稿。
*
* @param skill Skill 草稿
* @return 保存后的 Skill
* @param request 草稿白名单请求
* @return 更新后的 Skill
*/
@Override
@PostMapping("update")
public Result<?> update(@JsonBody Skill skill) {
return Result.ok(service.updateDraft(skill));
@PostMapping("/update")
@SaCheckPermission("/api/v1/skill/update")
public Result<SkillView> update(@JsonBody(required = true, skipConvertError = false) SkillDraftRequest request) {
if (request == null || request.id() == null) {
throw new BusinessException("Skill ID 不能为空");
}
return Result.ok(toView(skillService.updateDraft(request.toUpdateEntity())));
}
/**
* 预览 zip 导入结果
* 复制已有 Skill 为当前用户拥有的新草稿
*
* @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()));
* @param request 复制请求
* @return 新建的 Skill 草稿
*/
@PostMapping("/copy")
@SaCheckPermission(value = {"/api/v1/skill/save", "/api/v1/skill/capability"})
public Result<SkillView> copy(@JsonBody(required = true, skipConvertError = false) SkillCopyRequest request) {
if (request == null) {
throw new BusinessException("复制参数不能为空");
}
return Result.ok(toView(skillService.copyDraft(request.sourceId(), request.name(),
request.displayName(), request.categoryId())));
}
/**
* 确认导入 zip
* 在展示发布确认前执行发布级全量校验
*
* @param file zip 文件
* @param categoryId 分类 ID
* @param overwriteDraft 是否覆盖草稿
* @return 导入后的 Skill 列表
* @param id Skill ID
* @return 包含实时能力解析的结构化校验结果
*/
@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());
@PostMapping("/validatePublish")
@SaCheckPermission("/api/v1/skill/submitPublishApproval")
public Result<SkillValidationResult> validatePublish(
@JsonBody(value = "id", required = true, skipConvertError = false) BigInteger id) {
return Result.ok(skillService.validateSkill(id, true));
}
/**
@@ -165,78 +262,266 @@ public class SkillController extends BaseCurdController<SkillService, Skill> {
* @return 文件树
*/
@GetMapping("/file/tree")
@SaCheckPermission("/api/v1/skill/getDetail")
public Result<List<SkillFileNode>> fileTree(BigInteger skillId) {
return Result.ok(skillFileService.tree(skillId));
}
/**
* 获取 Skill 文件内容。
* 获取 Skill 文本文件内容或二进制摘要
*
* @param skillId Skill ID
* @param path 逻辑路径
* @param path 包内路径
* @return 文件内容
*/
@GetMapping("/file/content")
@SaCheckPermission("/api/v1/skill/getDetail")
public Result<SkillFileContent> fileContent(BigInteger skillId, String path) {
return Result.ok(skillFileService.getContent(skillId, path));
}
/**
* 保存 Skill 文本文件。
* 保存已有文本文件。
*
* @param request 保存请求
* @return 保存后的文件内容
* @return 最新文件内容
*/
@PostMapping("/file/save")
@SaCheckPermission("/api/v1/skill/save")
public Result<SkillFileContent> saveFile(@JsonBody SkillFileSaveRequest request) {
@SaCheckPermission("/api/v1/skill/file")
public Result<SkillFileContent> saveFile(
@JsonBody(required = true, skipConvertError = false) SkillFileSaveRequest request) {
return Result.ok(skillFileService.saveContent(request));
}
/**
* 删除 Skill 逻辑文件。
* 创建文本文件。
*
* @param request 创建请求
* @return 文件内容
*/
@PostMapping("/file/create")
@SaCheckPermission("/api/v1/skill/file")
public Result<SkillFileContent> createFile(
@JsonBody(required = true, skipConvertError = false) SkillFileSaveRequest request) {
return Result.ok(skillFileService.createTextFile(request));
}
/**
* 重命名文件。
*
* @param request 重命名请求
* @return 最新文件内容
*/
@PostMapping("/file/rename")
@SaCheckPermission("/api/v1/skill/file")
public Result<SkillFileContent> renameFile(
@JsonBody(required = true, skipConvertError = false) SkillFileRenameRequest request) {
return Result.ok(skillFileService.renameFile(request));
}
/**
* 删除包内文件。
*
* @param skillId Skill ID
* @param path 逻辑路径
* @return 操作结果
* @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);
@SaCheckPermission("/api/v1/skill/file")
public Result<Void> deleteFile(
@JsonBody(value = "skillId", required = true, skipConvertError = false) BigInteger skillId,
@JsonBody(value = "path", required = true, skipConvertError = false) String path,
@JsonBody(value = "expectedContentHash", required = true, skipConvertError = false)
String expectedContentHash) {
skillFileService.deleteFile(skillId, path, expectedContentHash);
return Result.ok();
}
/**
* 上传 Skill asset
* 上传任意包内二进制资源
*
* @param skillId Skill ID
* @param path 逻辑路径
* @param path 文件路径
* @param file 上传文件
* @return asset 内容
* @return 文件摘要
*/
@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));
@PostMapping(value = "/file/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@SaCheckPermission("/api/v1/skill/file")
public Result<SkillFileContent> uploadFile(BigInteger skillId,
String path,
String expectedContentHash,
MultipartFile file) {
return Result.ok(skillFileService.uploadResource(skillId, path, file, expectedContentHash));
}
/**
* 下载或预览 Skill asset
* 下载包内文件
*
* @param skillId Skill ID
* @param path asset 逻辑路径
* @param path 文件路径
* @param response HTTP 响应
* @throws IOException 响应写入失败
*/
@GetMapping("/file/download")
@SaCheckPermission("/api/v1/skill/getDetail")
public void downloadFile(BigInteger skillId, String path, HttpServletResponse response) throws IOException {
transferFile(skillId, path, response, false);
}
/**
* 安全预览包内文件;主动内容强制下载。
*
* @param skillId Skill ID
* @param path 文件路径
* @param response HTTP 响应
* @throws IOException 响应写入失败
*/
@GetMapping("/file/preview")
@SaCheckPermission("/api/v1/skill/getDetail")
public void previewFile(BigInteger skillId, String path, HttpServletResponse response) throws IOException {
transferFile(skillId, path, response, true);
}
/**
* 查询 Skill 能力绑定。
*
* @param skillId Skill ID
* @return 绑定列表
*/
@GetMapping("/capability/list")
@SaCheckPermission(value = {"/api/v1/skill/getDetail", "/api/v1/skill/capability"})
public Result<List<SkillView.CapabilityView>> capabilityList(BigInteger skillId) {
return Result.ok(capabilityBindingService.listVisibleBindings(skillId).stream()
.map(SkillView.CapabilityView::from).toList());
}
/**
* 查询当前用户可绑定的能力候选。
*
* @param type 能力类型
* @param keyword 关键词
* @return 候选列表
*/
@GetMapping("/capability/candidates")
@SaCheckPermission(value = {"/api/v1/skill/capability", "/api/v1/skill/import"}, mode = SaMode.OR)
public Result<List<SkillCapabilityCandidate>> capabilityCandidates(String type, String keyword) {
return Result.ok(capabilityBindingService.listCandidates(SkillCapabilityType.from(type), keyword));
}
/**
* 获取 MCP 工具名清单。
*
* @param targetId MCP ID
* @return MCP 候选详情
*/
@GetMapping("/capability/tools")
@SaCheckPermission(value = {"/api/v1/skill/capability", "/api/v1/skill/import"}, mode = SaMode.OR)
public Result<SkillCapabilityCandidate> capabilityTools(BigInteger targetId) {
return Result.ok(capabilityBindingService.getMcpTools(targetId));
}
/**
* 原子替换 Skill 能力绑定。
*
* @param skillId Skill ID
* @param requests 绑定白名单请求
* @return 保存后的绑定
*/
@PostMapping("/capability/replace")
@SaCheckPermission("/api/v1/skill/capability")
public Result<SkillCapabilityReplaceView> replaceCapabilities(
@JsonBody(value = "skillId", required = true, skipConvertError = false) BigInteger skillId,
@JsonBody(value = "expectedCapabilityHash", required = true, skipConvertError = false)
String expectedCapabilityHash,
@JsonBody(value = "bindings", required = true, skipConvertError = false)
List<SkillCapabilityBindingRequest> requests) {
List<SkillCapabilityBinding> bindings = requests == null ? List.of()
: requests.stream().map(SkillCapabilityBindingRequest::toEntity).toList();
List<SkillCapabilityBinding> saved = capabilityBindingService.replaceBindings(
skillId, bindings, expectedCapabilityHash);
return Result.ok(new SkillCapabilityReplaceView(
saved.stream().map(SkillView.CapabilityView::from).toList(),
capabilityBindingService.calculateHash(saved)));
}
/**
* 预览标准 ZIP 或 EasyFlow Bundle 导入内容。
*
* @param file 导入文件
* @return token 化预览
*/
@PostMapping(value = "/import/preview", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@SaCheckPermission("/api/v1/skill/import")
public Result<SkillImportPreview> importPreview(MultipartFile file) {
return Result.ok(skillImportService.preview(file));
}
/**
* 使用一次性 token 确认导入。
*
* @param request 导入确认请求
* @return 导入后的 Skill
*/
@PostMapping("/import/confirm")
@SaCheckPermission("/api/v1/skill/import")
public Result<List<SkillView>> importConfirm(
@JsonBody(required = true, skipConvertError = false) SkillImportConfirmRequest request) {
return Result.ok(skillImportService.confirm(request).stream().map(this::toView).toList());
}
/**
* 取消导入并清理临时包。
*
* @param importToken 导入 token
* @return 空结果
*/
@PostMapping("/import/cancel")
@SaCheckPermission("/api/v1/skill/import")
public Result<Void> importCancel(
@JsonBody(value = "importToken", required = true, skipConvertError = false) String importToken) {
skillImportService.cancel(importToken);
return Result.ok();
}
/**
* 导出标准 Skill ZIP 或 EasyFlow 增强包。
*
* @param request 导出请求
* @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());
@PostMapping("/export")
@SaCheckPermission("/api/v1/skill/export")
public void export(@JsonBody(required = true, skipConvertError = false) SkillExportRequest request,
HttpServletResponse response) {
if (request == null || request.getIds().isEmpty()) {
throw new BusinessException("请选择要导出的 Skill");
}
if (request.getIds().size() > 100) {
throw new BusinessException("单次最多导出 100 个 Skill");
}
SkillImportFormat format = SkillImportFormat.from(request.getFormat());
assertEnhancedExportPermission(format);
try (SkillExportArtifact artifact = skillExportService.prepare(request.getIds(), format)) {
response.setContentType(artifact.getMediaType());
response.setHeader("Content-Disposition", attachment(artifact.getFileName()));
artifact.transferTo(output(response));
}
}
/**
* 导出单个标准或增强 Skill 包。
*
* @param id Skill ID
* @param format 导出格式
* @param response HTTP 响应
*/
@GetMapping("/export")
@SaCheckPermission("/api/v1/skill/export")
public void exportOne(BigInteger id, String format, HttpServletResponse response) {
if (id == null) {
throw new BusinessException("Skill ID 不能为空");
}
writeExport(List.of(id), SkillImportFormat.from(format), response);
}
/**
@@ -246,9 +531,10 @@ public class SkillController extends BaseCurdController<SkillService, Skill> {
* @return 审批实例 ID
*/
@PostMapping("/submitPublishApproval")
@SaCheckPermission("/api/v1/skill/save")
public Result<BigInteger> submitPublishApproval(@JsonBody("id") BigInteger id) {
return buildApprovalActionResult(skillPublishAppService.submitPublishApproval(id), "已提交发布审批", "已直接发布");
@SaCheckPermission("/api/v1/skill/submitPublishApproval")
public Result<BigInteger> submitPublishApproval(
@JsonBody(value = "id", required = true, skipConvertError = false) BigInteger id) {
return approvalResult(skillPublishAppService.submitPublishApproval(id), "已提交发布审批", "已直接发布");
}
/**
@@ -258,9 +544,10 @@ public class SkillController extends BaseCurdController<SkillService, Skill> {
* @return 审批实例 ID
*/
@PostMapping("/submitOfflineApproval")
@SaCheckPermission("/api/v1/skill/save")
public Result<BigInteger> submitOfflineApproval(@JsonBody("id") BigInteger id) {
return buildApprovalActionResult(skillPublishAppService.submitOfflineApproval(id), "已提交下线审批", "已直接下线");
@SaCheckPermission("/api/v1/skill/submitOfflineApproval")
public Result<BigInteger> submitOfflineApproval(
@JsonBody(value = "id", required = true, skipConvertError = false) BigInteger id) {
return approvalResult(skillPublishAppService.submitOfflineApproval(id), "已提交下线审批", "已直接下线");
}
/**
@@ -270,90 +557,142 @@ public class SkillController extends BaseCurdController<SkillService, Skill> {
* @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);
@SaCheckPermission("/api/v1/skill/submitDeleteApproval")
public Result<BigInteger> submitDeleteApproval(
@JsonBody(value = "id", required = true, skipConvertError = false) BigInteger id) {
return approvalResult(skillPublishAppService.submitDeleteApproval(id), "已提交删除审批", "已直接删除");
}
/**
* 查询 Skill 分页
* 查询 Skill 发布和审批派生状态
*
* @param page 分页参数
* @param queryWrapper 查询条件
* @return Skill 分页
* @param id Skill ID
* @return 发布状态
*/
@Override
protected Page<Skill> queryPage(Page<Skill> page, QueryWrapper queryWrapper) {
if (!applyCategoryPermission(queryWrapper)) {
return new Page<>(Collections.emptyList(), page.getPageNumber(), page.getPageSize(), 0L);
@GetMapping("/publish/status")
@SaCheckPermission("/api/v1/skill/getDetail")
public Result<SkillPublishStatusView> publishStatus(BigInteger id) {
QueryWrapper query = descriptorQuery().eq(Skill::getId, id);
visibilityQueryHelper.applyReadableAccess(query);
Skill skill = skillService.getOne(query);
if (skill == null) {
throw new BusinessException(404, 404, "Skill 不存在");
}
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;
fillListState(List.of(skill));
return Result.ok(new SkillPublishStatusView(skill.getId(), skill.getPublishStatus(),
skill.getApprovalPending(), skill.getCurrentApprovalActionType(), skill.getDisplayPublishStatus(),
skill.getCurrentApprovalInstanceId()));
}
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 QueryWrapper descriptorQuery() {
return QueryWrapper.create().select("id", "tenant_id", "dept_id", "category_id", "name", "display_name", "description",
"enabled", "visibility_scope", "source_type", "package_hash", "capability_hash", "snapshot_hash",
"resource_count", "capability_count", "reference_count", "script_count", "asset_count",
"publish_status", "current_approval_instance_id", "created", "created_by", "modified", "modified_by");
}
private void applyPublishedOnlyFilter(QueryWrapper queryWrapper) {
if (isPublishedOnlyRequest()) {
queryWrapper.eq("publish_status", PublishStatus.PUBLISHED.getCode());
private void writeExport(List<BigInteger> ids, SkillImportFormat format, HttpServletResponse response) {
assertEnhancedExportPermission(format);
try (SkillExportArtifact artifact = skillExportService.prepare(ids, format)) {
response.setContentType(artifact.getMediaType());
response.setHeader("Content-Disposition", attachment(artifact.getFileName()));
artifact.transferTo(output(response));
}
}
private boolean isPublishedOnlyRequest() {
HttpServletRequest request = currentRequest();
if (request == null) {
return false;
/**
* EasyFlow 增强包包含能力配置,导出时额外校验能力绑定查看权限。
*
* @param format 导出格式
*/
void assertEnhancedExportPermission(SkillImportFormat format) {
if (SkillImportFormat.EASYFLOW == format) {
StpUtil.checkPermission("/api/v1/skill/capability");
}
return "true".equalsIgnoreCase(request.getParameter("publishedOnly"));
}
private HttpServletRequest currentRequest() {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes == null) {
return null;
}
return attributes.getRequest();
private void fillListState(List<Skill> skills) {
skillApprovalStateService.fillSkillApprovalState(skills);
creatorNameSupport.fillSkillCreatorNames(skills);
}
private Result<BigInteger> buildApprovalActionResult(ApprovalActionResult actionResult,
String approvalMessage,
String directMessage) {
return Result.ok(actionResult.isApprovalRequired() ? approvalMessage : directMessage, actionResult.getInstanceId());
private SkillView toView(Skill skill) {
boolean readable = resourceAccessService.canAccess(CategoryResourceType.SKILL, skill, ResourceAction.READ);
boolean manageable = resourceAccessService.canAccess(CategoryResourceType.SKILL, skill, ResourceAction.MANAGE);
return SkillView.from(skill, readable, manageable);
}
private SkillView toPageView(Skill skill, LoginAccount account, boolean superAdmin) {
boolean sameTenant = account != null && account.getTenantId() != null
&& Objects.equals(account.getTenantId(), skill.getTenantId());
boolean manageable = sameTenant && (superAdmin || Objects.equals(account.getId(), skill.getCreatedBy()));
return SkillView.from(skill, sameTenant, manageable);
}
private void transferFile(BigInteger skillId, String path, HttpServletResponse response, boolean preview) throws IOException {
SkillFileContent content = skillFileService.getContent(skillId, path);
String mediaType = content.getMediaType() == null ? MediaType.APPLICATION_OCTET_STREAM_VALUE : content.getMediaType();
boolean inline = preview && isSafeInline(mediaType);
response.setContentType(inline ? mediaType : MediaType.APPLICATION_OCTET_STREAM_VALUE);
response.setHeader("X-Content-Type-Options", "nosniff");
response.setHeader("Content-Security-Policy", "sandbox; default-src 'none'");
response.setHeader("Content-Disposition", (inline ? "inline" : "attachment") + filenameParameter(fileName(path)));
if (Boolean.TRUE.equals(content.getIsText())) {
response.getOutputStream().write((content.getContent() == null ? "" : content.getContent())
.getBytes(StandardCharsets.UTF_8));
return;
}
try (InputStream inputStream = skillFileService.openResource(skillId, path)) {
StreamUtils.copy(inputStream, response.getOutputStream());
}
}
private boolean isSafeInline(String mediaType) {
String normalized = mediaType.toLowerCase(Locale.ROOT).split(";", 2)[0];
return normalized.equals("application/pdf") || normalized.equals("text/plain")
|| normalized.equals("text/markdown") || normalized.equals("image/png")
|| normalized.equals("image/jpeg") || normalized.equals("image/gif")
|| normalized.equals("image/webp") || normalized.equals("image/avif");
}
private String resolveSortColumn(String sortKey) {
if (!hasText(sortKey)) {
return "modified";
}
String snake = sortKey.replaceAll("([a-z0-9])([A-Z])", "$1_$2").toLowerCase(Locale.ROOT);
return PAGE_SORT_COLUMNS.contains(snake) ? snake : "modified";
}
private String attachment(String fileName) {
return "attachment" + filenameParameter(fileName);
}
private String filenameParameter(String fileName) {
String encoded = URLEncoder.encode(fileName, StandardCharsets.UTF_8).replace("+", "%20");
return "; filename*=UTF-8''" + encoded;
}
private String fileName(String path) {
if (path == null || path.isBlank()) {
return "asset";
if (!hasText(path)) {
return "resource.bin";
}
int index = path.lastIndexOf('/');
return index < 0 ? path : path.substring(index + 1);
}
private Result<BigInteger> approvalResult(ApprovalActionResult result, String approvalMessage, String directMessage) {
return Result.ok(result.isApprovalRequired() ? approvalMessage : directMessage, result.getInstanceId());
}
private java.io.OutputStream output(HttpServletResponse response) {
try {
return response.getOutputStream();
} catch (IOException exception) {
throw new BusinessException(500, 500, "创建 Skill 导出响应失败", exception);
}
}
private boolean hasText(String value) {
return value != null && !value.isBlank();
}
}

View File

@@ -0,0 +1,59 @@
package tech.easyflow.admin.controller.skill.vo;
import tech.easyflow.skill.entity.SkillCapabilityBinding;
import java.math.BigInteger;
import java.util.List;
import java.util.Map;
/**
* Skill 能力绑定写入白名单。
*
* @param capabilityType 能力类型
* @param targetId 当前环境目标 ID
* @param targetLogicalRef 跨环境逻辑引用
* @param runtimeName 运行时名称
* @param enabled 是否启用
* @param selectionMode MCP 工具选择模式
* @param selectedToolNamesJson 已选 MCP 工具
* @param executionMode 执行模式
* @param hitlEnabled 是否需要人工确认
* @param hitlConfigJson 人工确认安全配置
* @param optionsJson 执行安全配置
* @param sortNo 排序号
*/
public record SkillCapabilityBindingRequest(String capabilityType,
BigInteger targetId,
String targetLogicalRef,
String runtimeName,
Boolean enabled,
String selectionMode,
List<String> selectedToolNamesJson,
String executionMode,
Boolean hitlEnabled,
Map<String, Object> hitlConfigJson,
Map<String, Object> optionsJson,
Integer sortNo) {
/**
* 转换为能力绑定业务实体。
*
* @return 仅包含可写字段的绑定实体
*/
public SkillCapabilityBinding toEntity() {
SkillCapabilityBinding binding = new SkillCapabilityBinding();
binding.setCapabilityType(capabilityType);
binding.setTargetId(targetId);
binding.setTargetLogicalRef(targetLogicalRef);
binding.setRuntimeName(runtimeName);
binding.setEnabled(enabled);
binding.setSelectionMode(selectionMode);
binding.setSelectedToolNamesJson(selectedToolNamesJson);
binding.setExecutionMode(executionMode);
binding.setHitlEnabled(hitlEnabled);
binding.setHitlConfigJson(hitlConfigJson);
binding.setOptionsJson(optionsJson);
binding.setSortNo(sortNo);
return binding;
}
}

View File

@@ -0,0 +1,13 @@
package tech.easyflow.admin.controller.skill.vo;
import java.util.List;
/**
* 能力绑定原子替换结果。
*
* @param bindings 保存后的白名单绑定视图
* @param capabilityHash 新能力配置哈希
*/
public record SkillCapabilityReplaceView(List<SkillView.CapabilityView> bindings,
String capabilityHash) {
}

View File

@@ -0,0 +1,17 @@
package tech.easyflow.admin.controller.skill.vo;
import java.math.BigInteger;
/**
* Skill 复制请求白名单。
*
* @param sourceId 源 Skill ID
* @param name 新 Skill 标准名称
* @param displayName 新 Skill 展示名称
* @param categoryId 目标分类 ID可为空
*/
public record SkillCopyRequest(BigInteger sourceId,
String name,
String displayName,
BigInteger categoryId) {
}

View File

@@ -0,0 +1,50 @@
package tech.easyflow.admin.controller.skill.vo;
import tech.easyflow.skill.entity.Skill;
import java.math.BigInteger;
/**
* Skill 草稿写入白名单,拒绝客户端覆盖租户、归属人、发布态、快照和 hash 等服务端字段。
*
* @param id Skill ID创建时为空
* @param categoryId 分类 ID
* @param displayName 展示名称
* @param skillContent SKILL.md 内容,仅创建时使用;已有草稿正文通过文件接口原子保存
* @param enabled 是否启用
* @param visibilityScope 可见范围
*/
public record SkillDraftRequest(BigInteger id,
BigInteger categoryId,
String displayName,
String skillContent,
Boolean enabled,
String visibilityScope) {
/**
* 转换为仅包含可写字段的业务实体。
*
* @return Skill 草稿实体
*/
public Skill toEntity() {
Skill skill = new Skill();
skill.setId(id);
skill.setCategoryId(categoryId);
skill.setDisplayName(displayName);
skill.setSkillContent(skillContent);
skill.setEnabled(enabled);
skill.setVisibilityScope(visibilityScope);
return skill;
}
/**
* 转换为不包含 SKILL.md 正文的基础配置更新实体。
*
* @return Skill 基础配置实体
*/
public Skill toUpdateEntity() {
Skill skill = toEntity();
skill.setSkillContent(null);
return skill;
}
}

View File

@@ -0,0 +1,21 @@
package tech.easyflow.admin.controller.skill.vo;
import java.math.BigInteger;
/**
* Skill 发布和审批派生状态。
*
* @param id Skill ID
* @param publishStatus 真实发布状态
* @param approvalPending 是否存在进行中审批
* @param currentApprovalActionType 当前审批动作
* @param displayPublishStatus 前端展示状态
* @param currentApprovalInstanceId 当前审批实例 ID
*/
public record SkillPublishStatusView(BigInteger id,
String publishStatus,
Boolean approvalPending,
String currentApprovalActionType,
String displayPublishStatus,
BigInteger currentApprovalInstanceId) {
}

View File

@@ -0,0 +1,184 @@
package tech.easyflow.admin.controller.skill.vo;
import tech.easyflow.skill.entity.Skill;
import tech.easyflow.skill.entity.SkillCapabilityBinding;
import tech.easyflow.skill.entity.SkillResource;
import java.math.BigInteger;
import java.util.Date;
import java.util.List;
import java.util.Map;
/**
* 管理端 Skill 视图,不暴露租户字段、二进制内部引用和发布快照。
*
* @param id Skill ID
* @param categoryId 分类 ID
* @param name 规范名称
* @param displayName 展示名称
* @param description 描述
* @param metadataJson frontmatter 扩展元数据
* @param skillContent SKILL.md 内容
* @param enabled 是否启用
* @param visibilityScope 可见范围
* @param sourceType 来源类型
* @param packageHash 包 hash
* @param capabilityHash 能力 hash
* @param snapshotHash 发布快照 hash
* @param resourceCount 资源数
* @param capabilityCount 能力数
* @param referenceCount 参考文档数
* @param scriptCount 脚本数
* @param assetCount 二进制资源数
* @param publishStatus 发布状态
* @param currentApprovalInstanceId 当前审批实例 ID
* @param approvalPending 是否审批中
* @param currentApprovalActionType 当前审批动作
* @param displayPublishStatus 展示发布状态
* @param created 创建时间
* @param modified 修改时间
* @param createdByName 创建人名称
* @param readable 当前用户是否可读
* @param manageable 当前用户是否可管理
* @param resources 包内资源摘要
* @param bindings 能力绑定
*/
public record SkillView(BigInteger id,
BigInteger categoryId,
String name,
String displayName,
String description,
Map<String, Object> metadataJson,
String skillContent,
Boolean enabled,
String visibilityScope,
String sourceType,
String packageHash,
String capabilityHash,
String snapshotHash,
Integer resourceCount,
Integer capabilityCount,
Integer referenceCount,
Integer scriptCount,
Integer assetCount,
String publishStatus,
BigInteger currentApprovalInstanceId,
Boolean approvalPending,
String currentApprovalActionType,
String displayPublishStatus,
Date created,
Date modified,
String createdByName,
boolean readable,
boolean manageable,
List<ResourceView> resources,
List<CapabilityView> bindings) {
/**
* 从业务实体创建安全视图。
*
* @param skill Skill 实体
* @param readable 是否可读
* @param manageable 是否可管理
* @return Skill 管理视图
*/
public static SkillView from(Skill skill, boolean readable, boolean manageable) {
List<ResourceView> resources = skill.getResources() == null ? null
: skill.getResources().stream().map(ResourceView::from).toList();
List<CapabilityView> bindings = skill.getCapabilityBindings() == null ? null
: skill.getCapabilityBindings().stream()
.map(binding -> CapabilityView.from(binding, manageable)).toList();
return new SkillView(skill.getId(), skill.getCategoryId(), skill.getName(), skill.getDisplayName(),
skill.getDescription(), skill.getMetadataJson(), skill.getSkillContent(), skill.getEnabled(),
skill.getVisibilityScope(), skill.getSourceType(), skill.getPackageHash(), skill.getCapabilityHash(),
skill.getSnapshotHash(), skill.getResourceCount(), skill.getCapabilityCount(), skill.getReferenceCount(),
skill.getScriptCount(), skill.getAssetCount(), skill.getPublishStatus(),
skill.getCurrentApprovalInstanceId(), skill.getApprovalPending(), skill.getCurrentApprovalActionType(),
skill.getDisplayPublishStatus(), skill.getCreated(), skill.getModified(), skill.getCreatedByName(),
readable, manageable, resources, bindings);
}
/**
* Skill 包内资源摘要。
*
* @param id 资源 ID
* @param path 路径
* @param kind 类型
* @param language 脚本语言
* @param mediaType 媒体类型
* @param isText 是否文本
* @param contentHash 内容 hash
* @param size 字节数
* @param metadataJson 扩展元数据
*/
public record ResourceView(BigInteger id, String path, String kind, String language, String mediaType,
Boolean isText, String contentHash, Long size, Map<String, Object> metadataJson) {
/**
* 转换资源实体。
*
* @param resource 资源实体
* @return 资源视图
*/
public static ResourceView from(SkillResource resource) {
return new ResourceView(resource.getId(), resource.getNormalizedPath(), resource.getKind(),
resource.getLanguage(), resource.getMediaType(), resource.getIsText(), resource.getContentHash(),
resource.getSize(), resource.getMetadataJson());
}
}
/**
* Skill 能力绑定视图。
*
* @param id 绑定 ID
* @param capabilityType 能力类型
* @param targetId 目标 ID
* @param targetLogicalRef 跨环境逻辑引用
* @param runtimeName 运行时名称
* @param enabled 是否启用
* @param selectionMode 工具选择模式
* @param selectedToolNamesJson 已选工具
* @param executionMode 执行模式
* @param hitlEnabled 是否人工确认
* @param hitlConfigJson 人工确认安全配置
* @param optionsJson 执行安全配置
* @param sortNo 排序号
* @param targetName 目标名称
* @param targetStatus 目标状态
* @param resolvedToolNames 已解析工具
*/
public record CapabilityView(BigInteger id, String capabilityType, BigInteger targetId, String targetLogicalRef,
String runtimeName, Boolean enabled, String selectionMode,
List<String> selectedToolNamesJson, String executionMode, Boolean hitlEnabled,
Map<String, Object> hitlConfigJson, Map<String, Object> optionsJson, Integer sortNo,
String targetName, String targetStatus, List<String> resolvedToolNames) {
/**
* 转换绑定实体。
*
* @param binding 绑定实体
* @return 绑定视图
*/
public static CapabilityView from(SkillCapabilityBinding binding) {
return from(binding, true);
}
/**
* 按管理权限转换绑定实体READ 用户看不到当前环境内部目标 ID。
*
* @param binding 绑定实体
* @param includeTargetId 是否包含目标 ID
* @return 绑定视图
*/
public static CapabilityView from(SkillCapabilityBinding binding, boolean includeTargetId) {
boolean hideUnavailableTarget = !includeTargetId && "NO_PERMISSION".equals(binding.getTargetStatus());
return new CapabilityView(binding.getId(), binding.getCapabilityType(),
includeTargetId ? binding.getTargetId() : null,
binding.getTargetLogicalRef(), binding.getRuntimeName(), binding.getEnabled(),
binding.getSelectionMode(), binding.getSelectedToolNamesJson(), binding.getExecutionMode(),
binding.getHitlEnabled(), binding.getHitlConfigJson(), binding.getOptionsJson(), binding.getSortNo(),
hideUnavailableTarget ? null : binding.getTargetName(), binding.getTargetStatus(),
hideUnavailableTarget ? List.of() : binding.getResolvedToolNames());
}
}
}