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

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

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

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

View File

@@ -0,0 +1,41 @@
package tech.easyflow.ai.permission;
import cn.dev33.satoken.stp.StpUtil;
import org.springframework.stereotype.Component;
import tech.easyflow.common.web.exceptions.BusinessException;
/**
* MCP 查询与使用权限检查器。
*
* <p>MCP 当前没有独立的资源级 {@code USE} 权限,平台沿用 MCP 管理模块已有的
* {@code /api/v1/mcp/query} 权限作为查看、选择和使用 MCP 的授权边界。</p>
*/
@Component
public class McpAccessPermissionChecker {
/** MCP 模块现有查询权限码。 */
public static final String MCP_QUERY_PERMISSION = "/api/v1/mcp/query";
/**
* 判断当前登录用户是否可以查询和使用 MCP。
*
* @return 已登录且拥有 MCP 查询权限时返回 {@code true}
*/
public boolean canUseMcp() {
return StpUtil.isLogin() && StpUtil.hasPermission(MCP_QUERY_PERMISSION);
}
/**
* 校验当前登录用户是否可以查询和使用 MCP。
*
* @throws BusinessException 未登录或缺少 MCP 查询权限时抛出
*/
public void assertCanUseMcp() {
if (!StpUtil.isLogin()) {
throw new BusinessException(401, 401, "未登录或登录态无效");
}
if (!StpUtil.hasPermission(MCP_QUERY_PERMISSION)) {
throw new BusinessException(403, 403, "无权限查询或使用 MCP");
}
}
}

View File

@@ -166,6 +166,42 @@ public abstract class AbstractAiResourceLifecycleHandler<T> implements ApprovalS
protected void validateDelete(T resource, PublishStatus currentStatus) {
}
/**
* 构建删除审批使用的治理快照。
*
* <p>默认沿用资源快照;包含敏感配置或需要发布级校验的资源可覆盖此方法,
* 返回不依赖发布可用性的最小治理信息。</p>
*
* @param resource 资源
* @return 删除审批治理快照
*/
protected Map<String, Object> buildDeleteResourceSnapshot(T resource) {
return buildResourceSnapshot(resource);
}
/**
* {@inheritDoc}
*/
@Override
public boolean canAccessApprovalDetail(Object identifier) {
if (identifier == null) {
return false;
}
try {
T resource = requireResource(new BigInteger(String.valueOf(identifier)));
assertManagePermission(resource);
return true;
} catch (NumberFormatException exception) {
return false;
} catch (BusinessException exception) {
// 资源不存在或无权管理都按不可见处理;服务端异常仍向上抛出,避免静默掩盖故障。
if (exception.getHttpStatus() >= 400 && exception.getHttpStatus() < 500) {
return false;
}
throw exception;
}
}
/**
* 下线成功后的额外副作用。
*
@@ -286,7 +322,7 @@ public abstract class AbstractAiResourceLifecycleHandler<T> implements ApprovalS
throw new BusinessException("当前" + resourceLabel() + "存在进行中的审批,请先处理完成");
}
validateDelete(resource, currentStatus);
return buildResourceSnapshot(resource);
return buildDeleteResourceSnapshot(resource);
}
/**

View File

@@ -0,0 +1,69 @@
package tech.easyflow.ai.permission;
import cn.dev33.satoken.stp.StpUtil;
import org.junit.Test;
import org.mockito.MockedStatic;
import tech.easyflow.common.web.exceptions.BusinessException;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mockStatic;
/**
* {@link McpAccessPermissionChecker} 的现有 MCP RBAC 语义回归测试。
*/
public class McpAccessPermissionCheckerTest {
/**
* 未登录调用方必须收到 401。
*/
@Test
public void unauthenticatedCallerIsRejected() {
try (MockedStatic<StpUtil> stpUtil = mockStatic(StpUtil.class)) {
stpUtil.when(StpUtil::isLogin).thenReturn(false);
BusinessException exception = assertThrows(BusinessException.class,
() -> new McpAccessPermissionChecker().assertCanUseMcp());
assertEquals(401, exception.getHttpStatus());
assertFalse(new McpAccessPermissionChecker().canUseMcp());
}
}
/**
* 已登录但缺少 MCP 查询权限的调用方必须收到 403。
*/
@Test
public void callerWithoutMcpQueryPermissionIsRejected() {
try (MockedStatic<StpUtil> stpUtil = mockStatic(StpUtil.class)) {
stpUtil.when(StpUtil::isLogin).thenReturn(true);
stpUtil.when(() -> StpUtil.hasPermission(McpAccessPermissionChecker.MCP_QUERY_PERMISSION))
.thenReturn(false);
BusinessException exception = assertThrows(BusinessException.class,
() -> new McpAccessPermissionChecker().assertCanUseMcp());
assertEquals(403, exception.getHttpStatus());
assertFalse(new McpAccessPermissionChecker().canUseMcp());
}
}
/**
* MCP 查询权限同时授予 MCP 候选查看和绑定使用能力。
*/
@Test
public void mcpQueryPermissionAllowsUse() {
try (MockedStatic<StpUtil> stpUtil = mockStatic(StpUtil.class)) {
stpUtil.when(StpUtil::isLogin).thenReturn(true);
stpUtil.when(() -> StpUtil.hasPermission(McpAccessPermissionChecker.MCP_QUERY_PERMISSION))
.thenReturn(true);
McpAccessPermissionChecker checker = new McpAccessPermissionChecker();
checker.assertCanUseMcp();
assertTrue(checker.canUseMcp());
}
}
}