feat: 完善 Skill 管理与发布治理
- 实现标准资源存储、能力绑定及双格式导入导出 - 接入分类、可见范围、审批发布与资源权限校验 - 补充并发、租户隔离、安全边界和迁移契约测试
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package tech.easyflow.admin.controller.skill;
|
||||
|
||||
import org.testng.Assert;
|
||||
import org.testng.annotations.Test;
|
||||
import tech.easyflow.skill.service.SkillCategoryService;
|
||||
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
/**
|
||||
* {@link SkillCategoryController} 查询参数安全契约测试。
|
||||
*/
|
||||
public class SkillCategoryControllerContractTest {
|
||||
|
||||
/**
|
||||
* 分类排序只接受固定字段和方向,恶意片段应回退到默认排序。
|
||||
*/
|
||||
@Test
|
||||
public void categorySortUsesStrictAllowlist() {
|
||||
SkillCategoryController controller = new SkillCategoryController(mock(SkillCategoryService.class));
|
||||
|
||||
Assert.assertEquals(controller.resolveOrderBy("categoryName", "desc"),
|
||||
"category_name desc, id asc");
|
||||
Assert.assertEquals(controller.resolveOrderBy("sort_no desc; drop table tb_skill", null),
|
||||
"sort_no asc, id asc");
|
||||
Assert.assertEquals(controller.resolveOrderBy("id", "unexpected"), "id asc");
|
||||
}
|
||||
|
||||
/**
|
||||
* 分类控制器不得继承未加租户范围的通用 list、page 和 detail 入口。
|
||||
*/
|
||||
@Test
|
||||
public void categoryControllerDoesNotExposeInheritedCrudQueries() {
|
||||
Assert.expectThrows(NoSuchMethodException.class,
|
||||
() -> SkillCategoryController.class.getMethod("detail", String.class));
|
||||
Assert.assertFalse(java.util.Arrays.stream(SkillCategoryController.class.getMethods())
|
||||
.anyMatch(method -> "list".equals(method.getName()) || "page".equals(method.getName())));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
package tech.easyflow.admin.controller.skill;
|
||||
|
||||
import com.alibaba.fastjson.JSON;
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||
import cn.dev33.satoken.stp.StpUtil;
|
||||
import org.testng.Assert;
|
||||
import org.testng.annotations.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
import tech.easyflow.admin.controller.ai.support.AiResourceCreatorNameSupport;
|
||||
import tech.easyflow.admin.controller.skill.vo.SkillCapabilityBindingRequest;
|
||||
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.common.domain.Result;
|
||||
import tech.easyflow.common.web.jsonbody.JsonBody;
|
||||
import tech.easyflow.common.web.jsonbody.JsonBodyParser;
|
||||
import tech.easyflow.skill.capability.SkillCapabilityBindingService;
|
||||
import tech.easyflow.skill.entity.Skill;
|
||||
import tech.easyflow.skill.entity.SkillCapabilityBinding;
|
||||
import tech.easyflow.skill.file.SkillFileService;
|
||||
import tech.easyflow.skill.imports.SkillExportService;
|
||||
import tech.easyflow.skill.imports.SkillImportFormat;
|
||||
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.skill.validation.SkillValidationResult;
|
||||
import tech.easyflow.system.service.CategoryPermissionService;
|
||||
import tech.easyflow.system.service.ResourceAccessService;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.ParameterizedType;
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* {@link SkillController} 写入 DTO 与返回视图静态契约测试。
|
||||
*/
|
||||
public class SkillControllerContractTest {
|
||||
|
||||
/**
|
||||
* 验证当前 Fastjson 与 JsonBody 解析链路支持 Skill 草稿 record。
|
||||
*
|
||||
* @throws Exception DTO 反序列化失败
|
||||
*/
|
||||
@Test
|
||||
public void jsonBodyParserDeserializesSkillDraftRecord() throws Exception {
|
||||
JSONObject json = JSON.parseObject("""
|
||||
{
|
||||
"id": 101,
|
||||
"categoryId": 9,
|
||||
"displayName": "演示 Skill",
|
||||
"skillContent": "---\\nname: demo-skill\\ndescription: Demo\\n---\\n# Demo\\n",
|
||||
"enabled": true,
|
||||
"visibilityScope": "PRIVATE"
|
||||
}
|
||||
""");
|
||||
|
||||
SkillDraftRequest request = (SkillDraftRequest) JsonBodyParser.parseJsonBody(
|
||||
json, SkillDraftRequest.class, SkillDraftRequest.class, "");
|
||||
|
||||
Assert.assertEquals(request.id(), BigInteger.valueOf(101));
|
||||
Assert.assertEquals(request.categoryId(), BigInteger.valueOf(9));
|
||||
Assert.assertEquals(request.displayName(), "演示 Skill");
|
||||
Assert.assertTrue(request.enabled());
|
||||
Assert.assertEquals(request.visibilityScope(), "PRIVATE");
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证当前 Fastjson 与 JsonBody 解析链路支持含集合和映射的能力绑定 record。
|
||||
*
|
||||
* @throws Exception DTO 反序列化失败
|
||||
*/
|
||||
@Test
|
||||
public void jsonBodyParserDeserializesCapabilityBindingRecord() throws Exception {
|
||||
JSONObject json = JSON.parseObject("""
|
||||
{
|
||||
"capabilityType": "MCP",
|
||||
"targetId": 77,
|
||||
"targetLogicalRef": "mcp://demo",
|
||||
"runtimeName": "demo_mcp",
|
||||
"enabled": true,
|
||||
"selectionMode": "SELECTED",
|
||||
"selectedToolNamesJson": ["search", "fetch"],
|
||||
"executionMode": "SYNC",
|
||||
"hitlEnabled": true,
|
||||
"hitlConfigJson": {"prompt": "确认执行"},
|
||||
"optionsJson": {"timeoutMs": 3000},
|
||||
"sortNo": 2
|
||||
}
|
||||
""");
|
||||
|
||||
SkillCapabilityBindingRequest request = (SkillCapabilityBindingRequest) JsonBodyParser.parseJsonBody(
|
||||
json, SkillCapabilityBindingRequest.class, SkillCapabilityBindingRequest.class, "");
|
||||
|
||||
Assert.assertEquals(request.capabilityType(), "MCP");
|
||||
Assert.assertEquals(request.targetId(), BigInteger.valueOf(77));
|
||||
Assert.assertEquals(request.selectedToolNamesJson(), List.of("search", "fetch"));
|
||||
Assert.assertEquals(request.hitlConfigJson().get("prompt"), "确认执行");
|
||||
Assert.assertEquals(((Number) request.optionsJson().get("timeoutMs")).intValue(), 3000);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证草稿写入口使用 JsonBody 白名单 DTO,并返回 SkillView。
|
||||
*
|
||||
* @throws Exception 控制器方法反射失败
|
||||
*/
|
||||
@Test
|
||||
public void saveEndpointUsesDraftRequestAndSkillView() throws Exception {
|
||||
Method method = SkillController.class.getMethod("save", SkillDraftRequest.class);
|
||||
JsonBody jsonBody = method.getParameters()[0].getAnnotation(JsonBody.class);
|
||||
ParameterizedType returnType = (ParameterizedType) method.getGenericReturnType();
|
||||
|
||||
Assert.assertNotNull(jsonBody);
|
||||
Assert.assertEquals(returnType.getRawType(), Result.class);
|
||||
Assert.assertEquals(returnType.getActualTypeArguments()[0], SkillView.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证详情视图按 MANAGE 权限隐藏或保留当前环境目标 ID。
|
||||
*/
|
||||
@Test
|
||||
public void detailViewProjectsCapabilityTargetIdByManagePermission() {
|
||||
SkillCapabilityBinding binding = binding(BigInteger.valueOf(77));
|
||||
Skill skill = new Skill();
|
||||
skill.setCapabilityBindings(List.of(binding));
|
||||
|
||||
SkillView readOnly = SkillView.from(skill, true, false);
|
||||
SkillView manageable = SkillView.from(skill, true, true);
|
||||
|
||||
Assert.assertNull(readOnly.bindings().get(0).targetId());
|
||||
Assert.assertEquals(manageable.bindings().get(0).targetId(), BigInteger.valueOf(77));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证能力替换响应沿用可编辑投影并保留目标 ID。
|
||||
*/
|
||||
@Test
|
||||
public void replaceResponseProjectionKeepsEditableTargetId() {
|
||||
SkillView.CapabilityView view = SkillView.CapabilityView.from(binding(BigInteger.valueOf(88)));
|
||||
|
||||
Assert.assertEquals(view.targetId(), BigInteger.valueOf(88));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证能力列表端点只调用按权限脱敏的读取方法。
|
||||
*/
|
||||
@Test
|
||||
public void capabilityListEndpointUsesPermissionAwareBindingRead() {
|
||||
BigInteger skillId = BigInteger.valueOf(101);
|
||||
SkillCapabilityBindingService bindingService = mock(SkillCapabilityBindingService.class);
|
||||
SkillCapabilityBinding redacted = binding(null);
|
||||
when(bindingService.listVisibleBindings(skillId)).thenReturn(List.of(redacted));
|
||||
SkillController controller = controller(bindingService);
|
||||
|
||||
Result<List<SkillView.CapabilityView>> result = controller.capabilityList(skillId);
|
||||
|
||||
Assert.assertNull(result.getData().get(0).targetId());
|
||||
verify(bindingService).listVisibleBindings(skillId);
|
||||
verify(bindingService, never()).listBindings(skillId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证复制需要新建和能力绑定双重操作权限。
|
||||
*
|
||||
* @throws Exception 控制器方法反射失败
|
||||
*/
|
||||
@Test
|
||||
public void copyEndpointDeclaresIndependentOperationPermissions() throws Exception {
|
||||
SaCheckPermission copyPermission = SkillController.class
|
||||
.getMethod("copy", SkillCopyRequest.class).getAnnotation(SaCheckPermission.class);
|
||||
|
||||
Assert.assertEquals(copyPermission.value(),
|
||||
new String[]{"/api/v1/skill/save", "/api/v1/skill/capability"});
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证正式删除审批入口使用真实操作权限,不引用历史死权限。
|
||||
*
|
||||
* @throws Exception 控制器方法反射失败
|
||||
*/
|
||||
@Test
|
||||
public void deleteEndpointUsesCanonicalDeletePermission() throws Exception {
|
||||
SaCheckPermission submitPermission = SkillController.class
|
||||
.getMethod("submitDeleteApproval", BigInteger.class).getAnnotation(SaCheckPermission.class);
|
||||
|
||||
Assert.assertEquals(submitPermission.value(),
|
||||
new String[]{"/api/v1/skill/submitDeleteApproval"});
|
||||
Assert.assertFalse(java.util.Arrays.stream(SkillController.class.getDeclaredMethods())
|
||||
.map(method -> method.getAnnotation(SaCheckPermission.class))
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.flatMap(permission -> java.util.Arrays.stream(permission.value()))
|
||||
.anyMatch("/api/v1/skill/remove"::equals));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证发布预检复用发布权限,并明确调用发布级校验。
|
||||
*
|
||||
* @throws Exception 控制器方法反射失败
|
||||
*/
|
||||
@Test
|
||||
public void publishValidationUsesPublishPermissionAndFullValidation() throws Exception {
|
||||
BigInteger skillId = BigInteger.valueOf(101);
|
||||
SkillService skillService = mock(SkillService.class);
|
||||
SkillValidationResult validation = new SkillValidationResult();
|
||||
validation.setValid(true);
|
||||
when(skillService.validateSkill(skillId, true)).thenReturn(validation);
|
||||
SkillController controller = controller(skillService, mock(SkillCapabilityBindingService.class));
|
||||
|
||||
Result<SkillValidationResult> result = controller.validatePublish(skillId);
|
||||
SaCheckPermission permission = SkillController.class
|
||||
.getMethod("validatePublish", BigInteger.class)
|
||||
.getAnnotation(SaCheckPermission.class);
|
||||
|
||||
Assert.assertSame(result.getData(), validation);
|
||||
Assert.assertEquals(permission.value(), new String[]{"/api/v1/skill/submitPublishApproval"});
|
||||
verify(skillService).validateSkill(skillId, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证标准导出不追加能力权限,EasyFlow 增强导出必须检查能力绑定查看权限。
|
||||
*/
|
||||
@Test
|
||||
public void enhancedExportRequiresCapabilityPermission() {
|
||||
SkillController controller = controller(mock(SkillCapabilityBindingService.class));
|
||||
|
||||
try (MockedStatic<StpUtil> stp = mockStatic(StpUtil.class)) {
|
||||
controller.assertEnhancedExportPermission(SkillImportFormat.STANDARD);
|
||||
stp.verify(() -> StpUtil.checkPermission("/api/v1/skill/capability"), never());
|
||||
|
||||
controller.assertEnhancedExportPermission(SkillImportFormat.EASYFLOW);
|
||||
stp.verify(() -> StpUtil.checkPermission("/api/v1/skill/capability"), times(1));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建测试能力绑定。
|
||||
*
|
||||
* @param targetId 目标 ID
|
||||
* @return 能力绑定
|
||||
*/
|
||||
private SkillCapabilityBinding binding(BigInteger targetId) {
|
||||
SkillCapabilityBinding binding = new SkillCapabilityBinding();
|
||||
binding.setId(BigInteger.ONE);
|
||||
binding.setCapabilityType("MCP");
|
||||
binding.setTargetId(targetId);
|
||||
binding.setTargetLogicalRef("mcp:demo");
|
||||
binding.setRuntimeName("demo_mcp");
|
||||
binding.setEnabled(true);
|
||||
binding.setSelectionMode("ALL");
|
||||
binding.setHitlEnabled(false);
|
||||
return binding;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建只注入能力服务的控制器测试实例。
|
||||
*
|
||||
* @param bindingService 能力绑定服务
|
||||
* @return 控制器实例
|
||||
*/
|
||||
private SkillController controller(SkillCapabilityBindingService bindingService) {
|
||||
return controller(mock(SkillService.class), bindingService);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建注入指定 Skill 与能力服务的控制器测试实例。
|
||||
*
|
||||
* @param skillService Skill 服务
|
||||
* @param bindingService 能力绑定服务
|
||||
* @return 控制器实例
|
||||
*/
|
||||
private SkillController controller(SkillService skillService, SkillCapabilityBindingService bindingService) {
|
||||
return new SkillController(skillService, mock(SkillApprovalStateService.class),
|
||||
mock(SkillPublishAppService.class), mock(SkillImportService.class), mock(SkillExportService.class),
|
||||
mock(SkillFileService.class), bindingService, mock(ResourceAccessService.class),
|
||||
mock(CategoryPermissionService.class), mock(SkillVisibilityQueryHelper.class),
|
||||
mock(AiResourceCreatorNameSupport.class));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package tech.easyflow.admin.controller.skill;
|
||||
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import org.testng.Assert;
|
||||
import org.testng.annotations.Test;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Locale;
|
||||
|
||||
import static org.mockito.Answers.CALLS_REAL_METHODS;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
/**
|
||||
* Skill 列表轻量投影的权限字段回归测试。
|
||||
*/
|
||||
public class SkillControllerProjectionTenantTest {
|
||||
|
||||
/**
|
||||
* 验证列表投影包含内部 tenant_id,以便资源权限派生时不会将合法记录误判为不可读。
|
||||
*
|
||||
* @throws Exception 反射调用失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void descriptorProjectionShouldIncludeTenantId() throws Exception {
|
||||
SkillController controller = mock(SkillController.class, CALLS_REAL_METHODS);
|
||||
Method method = SkillController.class.getDeclaredMethod("descriptorQuery");
|
||||
method.setAccessible(true);
|
||||
|
||||
QueryWrapper query = (QueryWrapper) method.invoke(controller);
|
||||
|
||||
Assert.assertTrue(query.toSQL().toLowerCase(Locale.ROOT).contains("tenant_id"),
|
||||
"Skill descriptor projection 缺少 tenant_id: " + query.toSQL());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证列表投影不会加载正文或发布快照等重字段。
|
||||
*
|
||||
* @throws Exception 反射调用失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void descriptorProjectionShouldExcludeHeavyContent() throws Exception {
|
||||
SkillController controller = mock(SkillController.class, CALLS_REAL_METHODS);
|
||||
Method method = SkillController.class.getDeclaredMethod("descriptorQuery");
|
||||
method.setAccessible(true);
|
||||
|
||||
String sql = ((QueryWrapper) method.invoke(controller)).toSQL().toLowerCase(Locale.ROOT);
|
||||
|
||||
Assert.assertFalse(sql.contains("skill_content"), "列表投影不应加载 SKILL.md 正文: " + sql);
|
||||
Assert.assertFalse(sql.contains("published_snapshot_json"), "列表投影不应加载发布快照: " + sql);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user