feat: 重构标准 Skill 管理与发布链路
- 统一标准 ZIP 导入导出与通用资源模型 - 收口分类范围权限和创建人查询 - 完善发布快照、审批幂等与数据库清理迁移
This commit is contained in:
@@ -1,12 +1,14 @@
|
||||
package tech.easyflow.admin.controller.ai.support;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import tech.easyflow.agent.entity.Agent;
|
||||
import tech.easyflow.ai.entity.DocumentCollection;
|
||||
import tech.easyflow.ai.entity.Plugin;
|
||||
import tech.easyflow.ai.entity.Workflow;
|
||||
import tech.easyflow.skill.entity.Skill;
|
||||
import tech.easyflow.system.service.SysAccountService;
|
||||
import tech.easyflow.system.entity.SysAccount;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.math.BigInteger;
|
||||
@@ -73,7 +75,38 @@ public class AiResourceCreatorNameSupport {
|
||||
* @param skills Skill 集合
|
||||
*/
|
||||
public void fillSkillCreatorNames(Collection<Skill> skills) {
|
||||
fillCreatorNames(skills, Skill::getCreatedBy, Skill::setCreatedByName);
|
||||
if (skills == null || skills.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
LinkedHashSet<BigInteger> creatorIds = skills.stream().map(Skill::getCreatedBy)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new));
|
||||
if (creatorIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Map<BigInteger, String> labels = sysAccountService.list(QueryWrapper.create()
|
||||
.select(SysAccount::getId, SysAccount::getNickname, SysAccount::getLoginName)
|
||||
.in(SysAccount::getId, creatorIds)).stream()
|
||||
.collect(java.util.stream.Collectors.toMap(SysAccount::getId, this::skillCreatorLabel,
|
||||
(left, right) -> left, java.util.LinkedHashMap::new));
|
||||
skills.forEach(skill -> skill.setCreatedByName(
|
||||
labels.getOrDefault(skill.getCreatedBy(), String.valueOf(skill.getCreatedBy()))));
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Skill 创建人格式化为“昵称(账号)”。
|
||||
*
|
||||
* @param account 创建人账号
|
||||
* @return 创建人展示标签
|
||||
*/
|
||||
private String skillCreatorLabel(SysAccount account) {
|
||||
String nickname = account.getNickname() == null ? "" : account.getNickname().trim();
|
||||
String loginName = account.getLoginName() == null ? "" : account.getLoginName().trim();
|
||||
if (nickname.isBlank()) {
|
||||
return loginName.isBlank() ? String.valueOf(account.getId()) : loginName;
|
||||
}
|
||||
return loginName.isBlank() || nickname.equals(loginName)
|
||||
? nickname : nickname + "(" + loginName + ")";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,7 +21,6 @@ import tech.easyflow.system.service.CategoryPermissionService;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigInteger;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Locale;
|
||||
@@ -77,14 +76,17 @@ public class SkillCategoryController {
|
||||
}
|
||||
}
|
||||
RoleCategoryAccessSnapshot access = categoryPermissionService.getCurrentAccess(CategoryResourceType.SKILL.getCode());
|
||||
if (access.isRestricted()) {
|
||||
if (access.getCategoryIds().isEmpty()) {
|
||||
return Result.ok(Collections.emptyList());
|
||||
}
|
||||
queryWrapper.in("id", access.getCategoryIds());
|
||||
}
|
||||
queryWrapper.orderBy(resolveOrderBy(sortKey, sortType));
|
||||
List<SkillCategory> categories = service.list(queryWrapper);
|
||||
if (access.isRestricted()) {
|
||||
Set<BigInteger> visibleIds = new java.util.LinkedHashSet<>(access.getCategoryIds());
|
||||
categories.stream().filter(category -> access.getCategoryIds().contains(category.getId()))
|
||||
.map(SkillCategory::getAncestors).filter(value -> value != null && !value.isBlank())
|
||||
.flatMap(value -> java.util.Arrays.stream(value.split(",")))
|
||||
.map(String::trim).filter(value -> !value.isBlank() && !"0".equals(value))
|
||||
.map(BigInteger::new).forEach(visibleIds::add);
|
||||
categories = categories.stream().filter(category -> visibleIds.contains(category.getId())).toList();
|
||||
}
|
||||
return Result.ok(Boolean.FALSE.equals(asTree) ? categories : toTree(categories));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
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.HttpServletResponse;
|
||||
@@ -12,12 +10,12 @@ 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.bind.annotation.RequestPart;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import tech.easyflow.admin.controller.ai.support.AiResourceCreatorNameSupport;
|
||||
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.SkillImportBatchResultView;
|
||||
import tech.easyflow.admin.controller.skill.vo.SkillView;
|
||||
import tech.easyflow.admin.controller.skill.vo.SkillPublishStatusView;
|
||||
import tech.easyflow.approval.entity.vo.ApprovalActionResult;
|
||||
@@ -27,11 +25,7 @@ import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||
import tech.easyflow.common.util.SearchKeywordUtil;
|
||||
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;
|
||||
@@ -41,7 +35,6 @@ 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;
|
||||
@@ -65,14 +58,14 @@ import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Skill 管理端 API,统一负责轻量查询、白名单写入、文件工作台、能力绑定和导入导出。
|
||||
* Skill 管理端 API,统一负责轻量查询、白名单写入、文件工作台和标准包导入导出。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/skill")
|
||||
public class SkillController {
|
||||
|
||||
private static final Set<String> PAGE_SORT_COLUMNS = Set.of(
|
||||
"id", "name", "display_name", "created", "modified", "publish_status", "resource_count", "capability_count");
|
||||
"id", "name", "display_name", "created", "modified", "publish_status");
|
||||
|
||||
private final SkillService skillService;
|
||||
private final SkillApprovalStateService skillApprovalStateService;
|
||||
@@ -80,7 +73,6 @@ public class SkillController {
|
||||
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;
|
||||
@@ -95,7 +87,6 @@ public class SkillController {
|
||||
* @param skillImportService 导入服务
|
||||
* @param skillExportService 导出服务
|
||||
* @param skillFileService 文件服务
|
||||
* @param capabilityBindingService 能力绑定服务
|
||||
* @param resourceAccessService 资源权限服务
|
||||
* @param categoryPermissionService 分类权限服务
|
||||
* @param visibilityQueryHelper 可见性查询助手
|
||||
@@ -107,7 +98,6 @@ public class SkillController {
|
||||
SkillImportService skillImportService,
|
||||
SkillExportService skillExportService,
|
||||
SkillFileService skillFileService,
|
||||
SkillCapabilityBindingService capabilityBindingService,
|
||||
ResourceAccessService resourceAccessService,
|
||||
CategoryPermissionService categoryPermissionService,
|
||||
SkillVisibilityQueryHelper visibilityQueryHelper,
|
||||
@@ -118,7 +108,6 @@ public class SkillController {
|
||||
this.skillImportService = skillImportService;
|
||||
this.skillExportService = skillExportService;
|
||||
this.skillFileService = skillFileService;
|
||||
this.capabilityBindingService = capabilityBindingService;
|
||||
this.resourceAccessService = resourceAccessService;
|
||||
this.categoryPermissionService = categoryPermissionService;
|
||||
this.visibilityQueryHelper = visibilityQueryHelper;
|
||||
@@ -134,9 +123,9 @@ public class SkillController {
|
||||
* @param categoryScope 分类范围,UNCATEGORIZED 表示未分类
|
||||
* @param name 名称关键词
|
||||
* @param displayName 展示名称关键词
|
||||
* @param keyword 名称、用途或创建人模糊关键词
|
||||
* @param publishStatus 发布状态
|
||||
* @param sourceType 来源类型
|
||||
* @param capabilityType 能力类型
|
||||
* @param visibilityScope 使用范围
|
||||
* @param sortKey 排序字段
|
||||
* @param sortType 排序方向
|
||||
* @return 轻量分页结果
|
||||
@@ -144,8 +133,8 @@ public class SkillController {
|
||||
@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) {
|
||||
String name, String displayName, String keyword, String publishStatus,
|
||||
String visibilityScope, 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();
|
||||
@@ -155,19 +144,16 @@ public class SkillController {
|
||||
} 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 = SearchKeywordUtil.literalContainsPattern(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.eq("publish_status", publishStatus, hasText(publishStatus))
|
||||
.eq("visibility_scope", visibilityScope, hasText(visibilityScope));
|
||||
String effectiveKeyword = hasText(keyword) ? keyword : hasText(displayName) ? displayName : name;
|
||||
if (hasText(effectiveKeyword)) {
|
||||
String pattern = SearchKeywordUtil.literalContainsPattern(effectiveKeyword);
|
||||
query.and("(name LIKE ? ESCAPE '\\\\' OR display_name LIKE ? ESCAPE '\\\\' "
|
||||
+ "OR description LIKE ? ESCAPE '\\\\' OR EXISTS (SELECT 1 FROM tb_sys_account a "
|
||||
+ "WHERE a.id = tb_skill.created_by AND a.tenant_id = tb_skill.tenant_id "
|
||||
+ "AND (a.nickname LIKE ? ESCAPE '\\\\' OR a.login_name LIKE ? ESCAPE '\\\\'))) ",
|
||||
pattern, pattern, pattern, pattern, pattern);
|
||||
}
|
||||
query.orderBy(resolveSortColumn(sortKey) + ("asc".equalsIgnoreCase(sortType) ? " asc" : " desc"));
|
||||
Page<Skill> source = skillService.page(new Page<>(normalizedPage, normalizedSize), query);
|
||||
@@ -189,10 +175,6 @@ public class SkillController {
|
||||
@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));
|
||||
}
|
||||
@@ -234,7 +216,7 @@ public class SkillController {
|
||||
* @return 新建的 Skill 草稿
|
||||
*/
|
||||
@PostMapping("/copy")
|
||||
@SaCheckPermission(value = {"/api/v1/skill/save", "/api/v1/skill/capability"})
|
||||
@SaCheckPermission("/api/v1/skill/save")
|
||||
public Result<SkillView> copy(@JsonBody(required = true, skipConvertError = false) SkillCopyRequest request) {
|
||||
if (request == null) {
|
||||
throw new BusinessException("复制参数不能为空");
|
||||
@@ -247,7 +229,7 @@ public class SkillController {
|
||||
* 在展示发布确认前执行发布级全量校验。
|
||||
*
|
||||
* @param id Skill ID
|
||||
* @return 包含实时能力解析的结构化校验结果
|
||||
* @return 标准包结构化校验结果
|
||||
*/
|
||||
@PostMapping("/validatePublish")
|
||||
@SaCheckPermission("/api/v1/skill/submitPublishApproval")
|
||||
@@ -343,6 +325,7 @@ public class SkillController {
|
||||
*
|
||||
* @param skillId Skill ID
|
||||
* @param path 文件路径
|
||||
* @param expectedContentHash 目标文件预期内容哈希,替换时必填
|
||||
* @param file 上传文件
|
||||
* @return 文件摘要
|
||||
*/
|
||||
@@ -384,77 +367,28 @@ public class SkillController {
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询 Skill 能力绑定。
|
||||
* 批量预览标准 ZIP 导入内容。
|
||||
*
|
||||
* @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 化预览
|
||||
* @param files 导入文件
|
||||
* @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));
|
||||
public Result<List<SkillImportPreview>> importPreview(
|
||||
@RequestPart(value = "files", required = false) List<MultipartFile> files,
|
||||
@RequestPart(value = "file", required = false) MultipartFile file) {
|
||||
List<MultipartFile> uploads = new java.util.ArrayList<>(files == null ? List.of() : files);
|
||||
if (file != null) {
|
||||
uploads.add(file);
|
||||
}
|
||||
if (uploads.isEmpty()) {
|
||||
throw new BusinessException("请选择要导入的标准 Skill ZIP");
|
||||
}
|
||||
if (uploads.size() > 20) {
|
||||
throw new BusinessException("单次最多预检 20 个 Skill ZIP");
|
||||
}
|
||||
return Result.ok(uploads.stream().map(skillImportService::preview).toList());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -470,6 +404,35 @@ public class SkillController {
|
||||
return Result.ok(skillImportService.confirm(request).stream().map(this::toView).toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 独立确认多个已预检的标准 Skill ZIP;单包业务失败不回滚其他包。
|
||||
*
|
||||
* @param requests 导入确认请求,按预检 token 一一对应
|
||||
* @return 各包独立导入结果
|
||||
*/
|
||||
@PostMapping("/import/confirmBatch")
|
||||
@SaCheckPermission("/api/v1/skill/import")
|
||||
public Result<List<SkillImportBatchResultView>> importConfirmBatch(
|
||||
@JsonBody(required = true, skipConvertError = false) List<SkillImportConfirmRequest> requests) {
|
||||
if (requests == null || requests.isEmpty()) {
|
||||
throw new BusinessException("请选择要确认导入的 Skill");
|
||||
}
|
||||
if (requests.size() > 20) {
|
||||
throw new BusinessException("单次最多确认导入 20 个 Skill");
|
||||
}
|
||||
List<SkillImportBatchResultView> results = new java.util.ArrayList<>(requests.size());
|
||||
for (SkillImportConfirmRequest request : requests) {
|
||||
String token = request == null ? null : request.getImportToken();
|
||||
try {
|
||||
List<SkillView> skills = skillImportService.confirm(request).stream().map(this::toView).toList();
|
||||
results.add(SkillImportBatchResultView.succeeded(token, skills));
|
||||
} catch (BusinessException exception) {
|
||||
results.add(SkillImportBatchResultView.failed(token, exception.getMessage()));
|
||||
}
|
||||
}
|
||||
return Result.ok(results);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消导入并清理临时包。
|
||||
*
|
||||
@@ -485,7 +448,7 @@ public class SkillController {
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出标准 Skill ZIP 或 EasyFlow 增强包。
|
||||
* 导出标准 Skill ZIP。
|
||||
*
|
||||
* @param request 导出请求
|
||||
* @param response HTTP 响应
|
||||
@@ -500,9 +463,7 @@ public class SkillController {
|
||||
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)) {
|
||||
try (SkillExportArtifact artifact = skillExportService.prepare(request.getIds())) {
|
||||
response.setContentType(artifact.getMediaType());
|
||||
response.setHeader("Content-Disposition", attachment(artifact.getFileName()));
|
||||
artifact.transferTo(output(response));
|
||||
@@ -510,32 +471,35 @@ public class SkillController {
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出单个标准或增强 Skill 包。
|
||||
* 导出单个标准 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) {
|
||||
public void exportOne(BigInteger id, HttpServletResponse response) {
|
||||
if (id == null) {
|
||||
throw new BusinessException("Skill ID 不能为空");
|
||||
}
|
||||
writeExport(List.of(id), SkillImportFormat.from(format), response);
|
||||
writeExport(List.of(id), response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交发布审批。
|
||||
*
|
||||
* @param id Skill ID
|
||||
* @param applicationReason 发布说明
|
||||
* @return 审批实例 ID
|
||||
*/
|
||||
@PostMapping("/submitPublishApproval")
|
||||
@SaCheckPermission("/api/v1/skill/submitPublishApproval")
|
||||
public Result<BigInteger> submitPublishApproval(
|
||||
@JsonBody(value = "id", required = true, skipConvertError = false) BigInteger id) {
|
||||
return approvalResult(skillPublishAppService.submitPublishApproval(id), "已提交发布审批", "已直接发布");
|
||||
@JsonBody(value = "id", required = true, skipConvertError = false) BigInteger id,
|
||||
@JsonBody(value = "applicationReason", required = true, skipConvertError = false)
|
||||
String applicationReason) {
|
||||
return approvalResult(skillPublishAppService.submitPublishApproval(id, applicationReason),
|
||||
"已提交发布审批", "已直接发布");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -587,31 +551,18 @@ public class SkillController {
|
||||
|
||||
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",
|
||||
"visibility_scope", "package_hash", "snapshot_hash",
|
||||
"publish_status", "current_approval_instance_id", "created", "created_by", "modified", "modified_by");
|
||||
}
|
||||
|
||||
private void writeExport(List<BigInteger> ids, SkillImportFormat format, HttpServletResponse response) {
|
||||
assertEnhancedExportPermission(format);
|
||||
try (SkillExportArtifact artifact = skillExportService.prepare(ids, format)) {
|
||||
private void writeExport(List<BigInteger> ids, HttpServletResponse response) {
|
||||
try (SkillExportArtifact artifact = skillExportService.prepare(ids)) {
|
||||
response.setContentType(artifact.getMediaType());
|
||||
response.setHeader("Content-Disposition", attachment(artifact.getFileName()));
|
||||
artifact.transferTo(output(response));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* EasyFlow 增强包包含能力配置,导出时额外校验能力绑定查看权限。
|
||||
*
|
||||
* @param format 导出格式
|
||||
*/
|
||||
void assertEnhancedExportPermission(SkillImportFormat format) {
|
||||
if (SkillImportFormat.EASYFLOW == format) {
|
||||
StpUtil.checkPermission("/api/v1/skill/capability");
|
||||
}
|
||||
}
|
||||
|
||||
private void fillListState(List<Skill> skills) {
|
||||
skillApprovalStateService.fillSkillApprovalState(skills);
|
||||
creatorNameSupport.fillSkillCreatorNames(skills);
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
package tech.easyflow.admin.controller.skill.vo;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 能力绑定原子替换结果。
|
||||
*
|
||||
* @param bindings 保存后的白名单绑定视图
|
||||
* @param capabilityHash 新能力配置哈希
|
||||
*/
|
||||
public record SkillCapabilityReplaceView(List<SkillView.CapabilityView> bindings,
|
||||
String capabilityHash) {
|
||||
}
|
||||
@@ -11,14 +11,12 @@ import java.math.BigInteger;
|
||||
* @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) {
|
||||
|
||||
/**
|
||||
@@ -32,7 +30,6 @@ public record SkillDraftRequest(BigInteger id,
|
||||
skill.setCategoryId(categoryId);
|
||||
skill.setDisplayName(displayName);
|
||||
skill.setSkillContent(skillContent);
|
||||
skill.setEnabled(enabled);
|
||||
skill.setVisibilityScope(visibilityScope);
|
||||
return skill;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package tech.easyflow.admin.controller.skill.vo;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 单个标准 Skill ZIP 的独立导入结果。
|
||||
*
|
||||
* @param importToken 预检 token
|
||||
* @param success 是否成功
|
||||
* @param message 失败原因,成功时为空
|
||||
* @param skills 导入成功的 Skill
|
||||
*/
|
||||
public record SkillImportBatchResultView(
|
||||
String importToken,
|
||||
boolean success,
|
||||
String message,
|
||||
List<SkillView> skills
|
||||
) {
|
||||
|
||||
/**
|
||||
* 构造成功结果。
|
||||
*
|
||||
* @param importToken 预检 token
|
||||
* @param skills 导入的 Skill
|
||||
* @return 成功结果
|
||||
*/
|
||||
public static SkillImportBatchResultView succeeded(String importToken, List<SkillView> skills) {
|
||||
return new SkillImportBatchResultView(importToken, true, null, List.copyOf(skills));
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造失败结果。
|
||||
*
|
||||
* @param importToken 预检 token
|
||||
* @param message 失败原因
|
||||
* @return 失败结果
|
||||
*/
|
||||
public static SkillImportBatchResultView failed(String importToken, String message) {
|
||||
return new SkillImportBatchResultView(importToken, false, message, List.of());
|
||||
}
|
||||
}
|
||||
@@ -1,66 +1,46 @@
|
||||
package tech.easyflow.admin.controller.skill.vo;
|
||||
|
||||
import com.easyagents.skill.util.SkillResources;
|
||||
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 视图,不暴露租户字段、二进制内部引用和发布快照。
|
||||
* 管理端 Skill 安全视图。
|
||||
*
|
||||
* @param id Skill ID
|
||||
* @param categoryId 分类 ID
|
||||
* @param name 规范名称
|
||||
* @param name 标准名称
|
||||
* @param displayName 展示名称
|
||||
* @param description 描述
|
||||
* @param metadataJson frontmatter 扩展元数据
|
||||
* @param description 用途描述
|
||||
* @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 visibilityScope 使用范围
|
||||
* @param packageHash 标准包哈希
|
||||
* @param snapshotHash 发布快照哈希
|
||||
* @param publishStatus 发布状态
|
||||
* @param currentApprovalInstanceId 当前审批实例 ID
|
||||
* @param approvalPending 是否审批中
|
||||
* @param currentApprovalActionType 当前审批动作
|
||||
* @param displayPublishStatus 展示发布状态
|
||||
* @param displayPublishStatus 展示状态
|
||||
* @param created 创建时间
|
||||
* @param modified 修改时间
|
||||
* @param createdByName 创建人名称
|
||||
* @param readable 当前用户是否可读
|
||||
* @param manageable 当前用户是否可管理
|
||||
* @param resources 包内资源摘要
|
||||
* @param bindings 能力绑定
|
||||
* @param createdByName 创建人昵称与账号
|
||||
* @param readable 是否可读
|
||||
* @param manageable 是否可管理
|
||||
* @param resources 资源摘要
|
||||
*/
|
||||
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,
|
||||
@@ -71,114 +51,50 @@ public record SkillView(BigInteger id,
|
||||
String createdByName,
|
||||
boolean readable,
|
||||
boolean manageable,
|
||||
List<ResourceView> resources,
|
||||
List<CapabilityView> bindings) {
|
||||
List<ResourceView> resources) {
|
||||
|
||||
/**
|
||||
* 从业务实体创建安全视图。
|
||||
* 从领域实体构造管理端视图。
|
||||
*
|
||||
* @param skill Skill 实体
|
||||
* @param readable 是否可读
|
||||
* @param manageable 是否可管理
|
||||
* @return Skill 管理视图
|
||||
* @return 管理端视图
|
||||
*/
|
||||
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.getDescription(), skill.getSkillContent(), skill.getVisibilityScope(), skill.getPackageHash(),
|
||||
skill.getSnapshotHash(), skill.getPublishStatus(), skill.getCurrentApprovalInstanceId(),
|
||||
skill.getApprovalPending(), skill.getCurrentApprovalActionType(), skill.getDisplayPublishStatus(),
|
||||
skill.getCreated(), skill.getModified(), skill.getCreatedByName(), readable, manageable, resources);
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill 包内资源摘要。
|
||||
*
|
||||
* @param id 资源 ID
|
||||
* @param path 路径
|
||||
* @param kind 类型
|
||||
* @param language 脚本语言
|
||||
* @param path 标准相对路径
|
||||
* @param kind 按路径派生的语义类型
|
||||
* @param mediaType 媒体类型
|
||||
* @param isText 是否文本
|
||||
* @param contentHash 内容 hash
|
||||
* @param contentHash 内容哈希
|
||||
* @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) {
|
||||
public record ResourceView(BigInteger id, String path, String kind, String mediaType,
|
||||
Boolean isText, String contentHash, Long size) {
|
||||
|
||||
/**
|
||||
* 转换资源实体。
|
||||
*
|
||||
* @param resource 资源实体
|
||||
* @return 资源视图
|
||||
* @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());
|
||||
String path = resource.getNormalizedPath();
|
||||
return new ResourceView(resource.getId(), path, SkillResources.classify(path).name(),
|
||||
resource.getMediaType(), resource.getIsText(), resource.getContentHash(), resource.getSize());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,66 +1,62 @@
|
||||
package tech.easyflow.admin.controller.skill;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||
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.SkillImportBatchResultView;
|
||||
import tech.easyflow.admin.controller.skill.vo.SkillView;
|
||||
import tech.easyflow.ai.enums.PublishStatus;
|
||||
import tech.easyflow.approval.entity.vo.ApprovalActionResult;
|
||||
import tech.easyflow.common.domain.Result;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
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.SkillImportConfirmRequest;
|
||||
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.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
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 与返回视图静态契约测试。
|
||||
* {@link SkillController} 标准包管理 API 契约测试。
|
||||
*/
|
||||
public class SkillControllerContractTest {
|
||||
|
||||
/**
|
||||
* 验证当前 Fastjson 与 JsonBody 解析链路支持 Skill 草稿 record。
|
||||
* 草稿白名单 DTO 只接受标准包治理字段。
|
||||
*
|
||||
* @throws Exception DTO 反序列化失败
|
||||
* @throws Exception 反序列化失败
|
||||
*/
|
||||
@Test
|
||||
public void jsonBodyParserDeserializesSkillDraftRecord() throws Exception {
|
||||
public void jsonBodyParserDeserializesStandardDraftRecord() throws Exception {
|
||||
JSONObject json = JSON.parseObject("""
|
||||
{
|
||||
"id": 101,
|
||||
"categoryId": 9,
|
||||
"displayName": "演示 Skill",
|
||||
"skillContent": "---\\nname: demo-skill\\ndescription: Demo\\n---\\n# Demo\\n",
|
||||
"enabled": true,
|
||||
"skillContent": "---\nname: demo-skill\ndescription: Demo\n---\n# Demo\n",
|
||||
"visibilityScope": "PRIVATE"
|
||||
}
|
||||
""");
|
||||
@@ -71,48 +67,15 @@ public class SkillControllerContractTest {
|
||||
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");
|
||||
Assert.assertFalse(Arrays.stream(SkillDraftRequest.class.getRecordComponents())
|
||||
.anyMatch(component -> "enabled".equals(component.getName())));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证当前 Fastjson 与 JsonBody 解析链路支持含集合和映射的能力绑定 record。
|
||||
* 保存入口使用白名单 DTO 并返回 Skill 视图。
|
||||
*
|
||||
* @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 控制器方法反射失败
|
||||
* @throws Exception 反射失败
|
||||
*/
|
||||
@Test
|
||||
public void saveEndpointUsesDraftRequestAndSkillView() throws Exception {
|
||||
@@ -126,163 +89,76 @@ public class SkillControllerContractTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证详情视图按 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证复制需要新建和能力绑定双重操作权限。
|
||||
* 复制只依赖 Skill 新建权限,不再暴露能力绑定权限。
|
||||
*
|
||||
* @throws Exception 控制器方法反射失败
|
||||
* @throws Exception 反射失败
|
||||
*/
|
||||
@Test
|
||||
public void copyEndpointDeclaresIndependentOperationPermissions() throws Exception {
|
||||
SaCheckPermission copyPermission = SkillController.class
|
||||
public void copyEndpointUsesOnlySavePermission() throws Exception {
|
||||
SaCheckPermission permission = 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())
|
||||
Assert.assertEquals(permission.value(), new String[]{"/api/v1/skill/save"});
|
||||
Assert.assertFalse(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));
|
||||
.flatMap(item -> Arrays.stream(item.value()))
|
||||
.anyMatch("/api/v1/skill/capability"::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));
|
||||
public void publishEndpointForwardsRequiredReason() {
|
||||
BigInteger id = BigInteger.valueOf(101);
|
||||
SkillPublishAppService publishService = mock(SkillPublishAppService.class);
|
||||
when(publishService.submitPublishApproval(id, "补充审核规则"))
|
||||
.thenReturn(ApprovalActionResult.direct());
|
||||
SkillController controller = controller(mock(SkillImportService.class), publishService);
|
||||
|
||||
Result<SkillValidationResult> result = controller.validatePublish(skillId);
|
||||
SaCheckPermission permission = SkillController.class
|
||||
.getMethod("validatePublish", BigInteger.class)
|
||||
.getAnnotation(SaCheckPermission.class);
|
||||
controller.submitPublishApproval(id, "补充审核规则");
|
||||
|
||||
Assert.assertSame(result.getData(), validation);
|
||||
Assert.assertEquals(permission.value(), new String[]{"/api/v1/skill/submitPublishApproval"});
|
||||
verify(skillService).validateSkill(skillId, true);
|
||||
verify(publishService).submitPublishApproval(id, "补充审核规则");
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证标准导出不追加能力权限,EasyFlow 增强导出必须检查能力绑定查看权限。
|
||||
* 批量确认按 token 隔离业务失败,成功项仍保留结果。
|
||||
*/
|
||||
@Test
|
||||
public void enhancedExportRequiresCapabilityPermission() {
|
||||
SkillController controller = controller(mock(SkillCapabilityBindingService.class));
|
||||
public void batchConfirmKeepsIndependentResults() {
|
||||
SkillImportService importService = mock(SkillImportService.class);
|
||||
SkillImportConfirmRequest first = request("a".repeat(32));
|
||||
SkillImportConfirmRequest second = request("b".repeat(32));
|
||||
when(importService.confirm(first)).thenThrow(new BusinessException("名称不可用"));
|
||||
Skill imported = new Skill();
|
||||
imported.setId(BigInteger.ONE);
|
||||
imported.setName("demo-skill");
|
||||
imported.setPublishStatus(PublishStatus.DRAFT.getCode());
|
||||
when(importService.confirm(second)).thenReturn(List.of(imported));
|
||||
|
||||
try (MockedStatic<StpUtil> stp = mockStatic(StpUtil.class)) {
|
||||
controller.assertEnhancedExportPermission(SkillImportFormat.STANDARD);
|
||||
stp.verify(() -> StpUtil.checkPermission("/api/v1/skill/capability"), never());
|
||||
List<SkillImportBatchResultView> results = controller(importService, mock(SkillPublishAppService.class))
|
||||
.importConfirmBatch(List.of(first, second)).getData();
|
||||
|
||||
controller.assertEnhancedExportPermission(SkillImportFormat.EASYFLOW);
|
||||
stp.verify(() -> StpUtil.checkPermission("/api/v1/skill/capability"), times(1));
|
||||
}
|
||||
Assert.assertFalse(results.get(0).success());
|
||||
Assert.assertEquals(results.get(0).message(), "名称不可用");
|
||||
Assert.assertTrue(results.get(1).success());
|
||||
Assert.assertEquals(results.get(1).skills().get(0).name(), "demo-skill");
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建测试能力绑定。
|
||||
*
|
||||
* @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;
|
||||
private SkillImportConfirmRequest request(String token) {
|
||||
SkillImportConfirmRequest request = new SkillImportConfirmRequest();
|
||||
request.setImportToken(token);
|
||||
request.setVisibilityScope("PRIVATE");
|
||||
return request;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建只注入能力服务的控制器测试实例。
|
||||
*
|
||||
* @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),
|
||||
private SkillController controller(SkillImportService importService, SkillPublishAppService publishService) {
|
||||
ResourceAccessService accessService = mock(ResourceAccessService.class);
|
||||
when(accessService.canAccess(any(), any(), any())).thenReturn(true);
|
||||
return new SkillController(mock(SkillService.class), mock(SkillApprovalStateService.class),
|
||||
publishService, importService, mock(SkillExportService.class), mock(SkillFileService.class),
|
||||
accessService, mock(CategoryPermissionService.class), mock(SkillVisibilityQueryHelper.class),
|
||||
mock(AiResourceCreatorNameSupport.class));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user