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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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())));
}
}

View File

@@ -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));
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,82 @@
package tech.easyflow.common.cache;
import com.alicp.jetcache.anno.SerialPolicy;
import com.alicp.jetcache.support.CacheEncodeException;
import com.alicp.jetcache.support.JavaValueDecoder;
import org.springframework.core.ConfigurableObjectInputStream;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.util.Objects;
/**
* 使用应用类加载器反序列化 JetCache Java 缓存值。
*
* <p>异步线程的上下文类加载器可能无法访问 Spring Boot 可执行包中的嵌套依赖,
* 因此解码时固定使用本类的定义类加载器。</p>
*/
public class ApplicationClassLoaderJavaValueDecoder extends JavaValueDecoder {
private final ClassLoader applicationClassLoader;
/**
* 创建使用 EasyFlow 应用类加载器的 Java 缓存解码器。
*/
public ApplicationClassLoaderJavaValueDecoder() {
this(ApplicationClassLoaderJavaValueDecoder.class.getClassLoader());
}
/**
* 创建使用指定类加载器的 Java 缓存解码器。
*
* @param applicationClassLoader 反序列化缓存对象时使用的类加载器
* @throws NullPointerException 类加载器为空时抛出
*/
ApplicationClassLoaderJavaValueDecoder(ClassLoader applicationClassLoader) {
super(true);
this.applicationClassLoader = Objects.requireNonNull(
applicationClassLoader,
"applicationClassLoader must not be null"
);
}
/**
* 解码带 JetCache Java 编码标识的缓存值。
*
* @param buffer Redis 中读取的缓存字节
* @return 反序列化后的缓存对象
* @throws CacheEncodeException 缓存内容为空、编码类型不匹配或反序列化失败时抛出
*/
@Override
public Object apply(byte[] buffer) {
try {
if (buffer == null || buffer.length < Integer.BYTES) {
throw new CacheEncodeException("decode error: invalid java cache payload");
}
int identityNumber = parseHeader(buffer);
if (identityNumber != SerialPolicy.IDENTITY_NUMBER_JAVA) {
throw new CacheEncodeException(
"decode error: unsupported cache identity number " + identityNumber
);
}
return doApply(buffer);
} catch (CacheEncodeException e) {
throw e;
} catch (Throwable e) {
throw new CacheEncodeException("decode error", e);
}
}
/**
* 创建绑定应用类加载器的对象输入流。
*
* @param input 缓存对象字节输入流
* @return 可从应用依赖中解析类的对象输入流
* @throws IOException 对象输入流初始化失败时抛出
*/
@Override
protected ObjectInputStream buildObjectInputStream(ByteArrayInputStream input) throws IOException {
return new ConfigurableObjectInputStream(input, applicationClassLoader);
}
}

View File

@@ -10,6 +10,11 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.function.Function;
/**
* EasyFlow 缓存基础配置。
*/
@Configuration
public class CacheConfig {
@@ -20,6 +25,9 @@ public class CacheConfig {
private Cache<String, Object> defaultCache;
/**
* 根据平台配置初始化默认缓存。
*/
@PostConstruct
public void init() {
CacheType type = CacheType.LOCAL;
@@ -35,8 +43,23 @@ public class CacheConfig {
defaultCache = cacheManager.getOrCreateCache(quickConfig);
}
/**
* 获取平台默认缓存。
*
* @return 默认缓存实例
*/
@Bean("defaultCache")
public Cache<String, Object> getDefaultCache() {
return defaultCache;
}
/**
* 创建固定使用应用类加载器的 JetCache Java 解码器。
*
* @return JetCache 缓存值解码函数
*/
@Bean("easyFlowJetCacheValueDecoder")
public static Function<byte[], Object> easyFlowJetCacheValueDecoder() {
return new ApplicationClassLoaderJavaValueDecoder();
}
}

View File

@@ -0,0 +1,60 @@
package tech.easyflow.common.cache;
import com.alicp.jetcache.CacheValueHolder;
import com.alicp.jetcache.support.JavaValueEncoder;
import org.junit.Assert;
import org.junit.Test;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
/**
* {@link ApplicationClassLoaderJavaValueDecoder} 回归测试。
*/
public class ApplicationClassLoaderJavaValueDecoderTest {
/**
* 验证异步线程上下文类加载器不可见应用依赖时仍可解码缓存值。
*
* @throws Exception 异步任务执行失败时抛出
*/
@Test
public void applyShouldUseApplicationClassLoaderInAsyncThread() throws Exception {
ApplicationClassLoaderJavaValueDecoder decoder = new ApplicationClassLoaderJavaValueDecoder();
CacheValueHolder<String> holder = new CacheValueHolder<>("workflow-state", TimeUnit.MINUTES.toMillis(1));
byte[] encoded = new JavaValueEncoder(true).apply(holder);
ClassLoader isolatedClassLoader = new ClassLoader(null) {
};
assertClassIsInvisible(isolatedClassLoader, CacheValueHolder.class.getName());
ExecutorService executor = Executors.newSingleThreadExecutor(task -> {
Thread thread = new Thread(task, "jetcache-decoder-test");
thread.setContextClassLoader(isolatedClassLoader);
return thread;
});
try {
Object decoded = executor.submit(() -> decoder.apply(encoded)).get(5, TimeUnit.SECONDS);
Assert.assertTrue(decoded instanceof CacheValueHolder<?>);
Assert.assertEquals("workflow-state", ((CacheValueHolder<?>) decoded).getValue());
} finally {
executor.shutdownNow();
}
}
/**
* 验证指定类加载器无法加载目标类。
*
* @param classLoader 待验证类加载器
* @param className 目标类名
*/
private void assertClassIsInvisible(ClassLoader classLoader, String className) {
try {
classLoader.loadClass(className);
Assert.fail("isolated class loader should not load " + className);
} catch (ClassNotFoundException expected) {
// 隔离类加载器符合测试前提。
}
}
}

View File

@@ -61,6 +61,13 @@
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -10,46 +10,225 @@ import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.util.Objects;
import java.util.function.Function;
import java.util.function.Supplier;
/**
* 根据平台配置路由文件存储操作的统一入口。
*
* <p>旧版操作每次使用当前后端;可恢复操作在 prepare 阶段固化后端,并在后续写入、检查及
* 删除时严格按照句柄路由,避免配置切换后误操作另一个后端。</p>
*/
@Component("default")
public class FileStorageManager implements FileStorageService {
/** 当前存储后端名称提供器。 */
private final Supplier<String> backendSupplier;
/** 按 bean 名称解析存储后端的函数。 */
private final Function<String, FileStorageService> serviceResolver;
/**
* 创建使用 Spring 上下文与当前存储配置的管理器。
*/
public FileStorageManager() {
this(FileStorageManager::configuredBackend, FileStorageManager::springService);
}
/**
* 创建使用指定路由提供器的管理器,供隔离测试使用。
*
* @param backendSupplier 当前存储后端名称提供器
* @param serviceResolver 按名称解析存储服务的函数
*/
FileStorageManager(Supplier<String> backendSupplier,
Function<String, FileStorageService> serviceResolver) {
this.backendSupplier = Objects.requireNonNull(backendSupplier, "backendSupplier 不能为空");
this.serviceResolver = Objects.requireNonNull(serviceResolver, "serviceResolver 不能为空");
}
/**
* 使用当前后端保存文件。
*
* @param file 上传文件
* @return 文件 URL
*/
@Override
public String save(MultipartFile file) {
return getService().save(file);
return currentService().save(file);
}
/**
* 使用当前后端及指定前置目录保存文件。
*
* @param file 上传文件
* @param prePath 前置目录
* @return 文件 URL
*/
@Override
public String save(MultipartFile file,String prePath) {
return getService().save(file,prePath);
public String save(MultipartFile file, String prePath) {
return currentService().save(file, prePath);
}
@Override
/**
* 使用当前后端删除旧版 URL 或路径。
*
* @param path 文件 URL 或路径
*/
@Override
public void delete(String path) {
getService().delete(path);
currentService().delete(path);
}
/**
* 使用当前后端保存本地文件。
*
* @param file 本地文件
* @param prePath 前置目录
* @return 文件 URL
*/
@Override
public String save(File file, String prePath) {
return getService().save(file, prePath);
return currentService().save(file, prePath);
}
/**
* 使用当前后端打开文件流。
*
* @param path 文件 URL 或路径
* @return 文件输入流
* @throws IOException 无法读取文件时抛出
*/
@Override
public InputStream readStream(String path) throws IOException {
return getService().readStream(path);
return currentService().readStream(path);
}
/**
* 使用当前后端获取文件大小。
*
* @param path 文件 URL 或路径
* @return 文件大小
*/
@Override
public long getFileSize(String path) {
return getService().getFileSize(path);
return currentService().getFileSize(path);
}
private FileStorageService getService() {
String type = StorageConfig.getInstance().getType();
if (!StringUtils.hasText(type)) {
return SpringContextUtil.getBean(LocalFileStorageServiceImpl.class);
} else {
return SpringContextUtil.getBean(type);
/**
* 委托当前后端准备可恢复写句柄。
*
* @param path 相对目录
* @param filename 固定文件名
* @return 包含当前后端路由的句柄
*/
@Override
public FileStorageWriteHandle prepareRecoverableWrite(String path, String filename) {
return currentService().prepareRecoverableWrite(path, filename);
}
/**
* 严格按句柄中的后端完成精确写入。
*
* @param file 上传文件
* @param handle 预先准备的句柄
* @return 文件 URL 与恢复 locator
*/
@Override
public FileStorageWriteResult saveRecoverable(MultipartFile file, FileStorageWriteHandle handle) {
return serviceForHandle(handle).saveRecoverable(file, handle);
}
/**
* 严格按句柄中的后端精确删除物理对象。
*
* @param handle 物理对象句柄
*/
@Override
public void deleteRecoverable(FileStorageWriteHandle handle) {
serviceForHandle(handle).deleteRecoverable(handle);
}
/**
* 严格按句柄中的后端检查物理对象。
*
* @param handle 物理对象句柄
* @return 物理对象存在时返回 true
*/
@Override
public boolean existsRecoverable(FileStorageWriteHandle handle) {
return serviceForHandle(handle).existsRecoverable(handle);
}
/**
* 解析当前配置对应的文件存储服务。
*
* @return 当前文件存储服务
*/
private FileStorageService currentService() {
return serviceForBackend(normalizeBackend(backendSupplier.get()));
}
/**
* 从句柄解析固定文件存储服务。
*
* @param handle 文件存储句柄
* @return 句柄指定的文件存储服务
*/
private FileStorageService serviceForHandle(FileStorageWriteHandle handle) {
if (handle == null) {
throw new IllegalArgumentException("文件存储写句柄不能为空");
}
return serviceForBackend(handle.getBackend());
}
/**
* 按已固化的后端名称解析服务,禁止回路由到管理器自身。
*
* @param backend 后端 bean 名称
* @return 具体文件存储服务
*/
private FileStorageService serviceForBackend(String backend) {
if ("default".equals(backend)) {
throw new IllegalArgumentException("恢复句柄不能路由到 default 管理器");
}
FileStorageService service = serviceResolver.apply(backend);
if (service == null || service == this) {
throw new IllegalStateException("文件存储后端不可用: " + backend);
}
return service;
}
/**
* 读取并规范化当前配置中的后端名称。
*
* @return 后端 bean 名称
*/
private static String configuredBackend() {
String type = StorageConfig.getInstance().getType();
return normalizeBackend(type);
}
/**
* 将空配置映射到本地后端。
*
* @param backend 配置值
* @return 非空后端 bean 名称
*/
private static String normalizeBackend(String backend) {
return StringUtils.hasText(backend) ? backend.trim() : "local";
}
/**
* 从 Spring 上下文按名称取得具体文件存储服务。
*
* @param backend 后端 bean 名称
* @return 具体服务
*/
private static FileStorageService springService(String backend) {
if ("local".equals(backend)) {
return SpringContextUtil.getBean(LocalFileStorageServiceImpl.class);
}
return SpringContextUtil.getBean(backend, FileStorageService.class);
}
}

View File

@@ -6,34 +6,123 @@ import java.io.File;
import java.io.IOException;
import java.io.InputStream;
/**
* EasyFlow 文件存储统一接口。
*
* <p>旧版 URL API 保持兼容;可恢复写入 API 允许调用方在物理写入前持久化精确定位信息。</p>
*/
public interface FileStorageService {
/**
* 使用后端默认路径保存上传文件。
*
* @param file 上传文件
* @return 文件读取 URL
*/
String save(MultipartFile file);
/**
* 按旧版 URL 或路径删除文件。
*
* @param path 文件 URL 或路径
*/
void delete(String path);
/**
* 上传文件
* 使用指定前置目录保存上传文件
*
* @param file 文件
* @param prePath 存储桶和文件名中间的路径(不用加斜杠)
* @return 文件url
*/
default String save(MultipartFile file, String prePath){
default String save(MultipartFile file, String prePath) {
return "";
}
default String save(File file, String prePath){
/**
* 使用指定前置目录保存本地文件。
*
* @param file 本地文件
* @param prePath 存储前置目录
* @return 文件读取 URL
*/
default String save(File file, String prePath) {
return "";
}
/**
* 打开文件读取流。
*
* @param path 文件 URL 或路径
* @return 文件输入流,由调用方关闭
* @throws IOException 无法打开文件时抛出
*/
InputStream readStream(String path) throws IOException;
/**
* 获取文件大小
* @param path
* 获取文件大小
*
* @param path 文件 URL 或路径
* @return 文件大小 单位字节
*/
public long getFileSize(String path);
long getFileSize(String path);
/**
* 在物理写入前准备一个具有稳定位置的恢复句柄。
*
* @param path 基础路径下的相对目录
* @param filename 固定文件名
* @return 可在数据库中预先持久化的写入句柄
* @throws UnsupportedOperationException 当前后端尚未实现可恢复写入时抛出
*/
default FileStorageWriteHandle prepareRecoverableWrite(String path, String filename) {
throw unsupportedRecoverableOperation("prepareRecoverableWrite");
}
/**
* 将上传内容写入句柄指定的精确物理位置。
*
* @param file 上传文件
* @param handle 预先准备的写入句柄
* @return 同时包含现有读取 URL 与恢复 locator 的写入结果
* @throws UnsupportedOperationException 当前后端尚未实现可恢复写入时抛出
*/
default FileStorageWriteResult saveRecoverable(MultipartFile file, FileStorageWriteHandle handle) {
throw unsupportedRecoverableOperation("saveRecoverable");
}
/**
* 精确且幂等地删除句柄对应的物理对象。
*
* <p>仅在后端确认对象不存在后才能正常返回。</p>
*
* @param handle 物理对象写入句柄
* @throws UnsupportedOperationException 当前后端尚未实现可恢复删除时抛出
* @throws RuntimeException 删除后仍能检测到物理对象时抛出
*/
default void deleteRecoverable(FileStorageWriteHandle handle) {
throw unsupportedRecoverableOperation("deleteRecoverable");
}
/**
* 精确判断句柄对应的物理对象是否存在。
*
* @param handle 物理对象写入句柄
* @return 物理对象存在时返回 true
* @throws UnsupportedOperationException 当前后端尚未实现精确存在检查时抛出
*/
default boolean existsRecoverable(FileStorageWriteHandle handle) {
throw unsupportedRecoverableOperation("existsRecoverable");
}
/**
* 创建统一的可恢复操作未实现异常。
*
* @param operation 操作名称
* @return fail-fast 异常
*/
private UnsupportedOperationException unsupportedRecoverableOperation(String operation) {
return new UnsupportedOperationException(
getClass().getName() + " 不支持可恢复文件操作: " + operation);
}
}

View File

@@ -0,0 +1,431 @@
package tech.easyflow.common.filestorage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;
import java.util.Locale;
import java.util.Objects;
import java.util.regex.Pattern;
/**
* 描述一次可恢复文件写入的不可变物理定位信息。
*
* <p>句柄在上传前生成,随后可编码为有版本的 Base64URL locator 持久化。locator
* 只承担稳定、安全的结构化传输与损坏检测,不是访问凭证,也不提供防伪能力;调用方不得
* 接受未经授权的外部 locator。</p>
*/
public final class FileStorageWriteHandle {
/** locator 文本前缀,其中包含当前编码版本。 */
private static final String LOCATOR_PREFIX = "efsw1.";
/** 二进制编码版本。 */
private static final int BINARY_VERSION = 1;
/** SHA-256 校验值长度。 */
private static final int CHECKSUM_BYTES = 32;
/** locator 最大字符数,与数据库 storage_locator VARCHAR(2048) 契约一致。 */
private static final int MAX_LOCATOR_CHARS = 2_048;
/** 后端名称最大 UTF-8 字节数。 */
private static final int MAX_BACKEND_BYTES = 64;
/** 平台名称最大 UTF-8 字节数。 */
private static final int MAX_PLATFORM_BYTES = 128;
/** 基础路径最大 UTF-8 字节数。 */
private static final int MAX_BASE_PATH_BYTES = 4_096;
/** 相对路径最大 UTF-8 字节数。 */
private static final int MAX_PATH_BYTES = 2_048;
/** 文件名最大 UTF-8 字节数。 */
private static final int MAX_FILENAME_BYTES = 255;
/** 可安全作为 Spring bean 名称及持久化路由键的标识符。 */
private static final Pattern ROUTE_PATTERN = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]*");
/** Base64URL 无填充文本允许的字符。 */
private static final Pattern BASE64_URL_PATTERN = Pattern.compile("[A-Za-z0-9_-]+");
/** Windows 保留设备名,避免 locator 在跨平台恢复时产生歧义。 */
private static final Pattern WINDOWS_RESERVED_NAME = Pattern.compile(
"(?i)(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\\..*)?");
/** 负责处理该句柄的 EasyFlow 文件存储后端 bean 名称。 */
private final String backend;
/** x-file-storage 平台名称;非 x-file-storage 后端可为空。 */
private final String platform;
/** 准备写入时解析得到的持久基础路径或本地存储根目录。 */
private final String basePath;
/** 基础路径下的规范化相对目录,以斜杠结尾;根目录使用空字符串。 */
private final String path;
/** 目标对象的固定文件名。 */
private final String filename;
/**
* 创建并严格校验一个文件存储写句柄。
*
* @param backend 存储后端路由名称
* @param platform x-file-storage 平台名称,非该类后端可为空
* @param basePath 持久基础路径或本地存储根目录
* @param path 基础路径下的相对目录,可为空
* @param filename 固定文件名
* @throws IllegalArgumentException 任一字段为空、过长或包含不安全路径时抛出
*/
public FileStorageWriteHandle(String backend,
String platform,
String basePath,
String path,
String filename) {
this.backend = validateRoute("backend", backend, false, MAX_BACKEND_BYTES);
this.platform = validateRoute("platform", platform, true, MAX_PLATFORM_BYTES);
this.basePath = validateBasePath(basePath);
this.path = normalizeRelativePath(path);
this.filename = validatePathSegment("filename", filename, MAX_FILENAME_BYTES);
if (buildLocator().length() > MAX_LOCATOR_CHARS) {
throw new IllegalArgumentException("文件存储 locator 超过 2048 字符持久化限制");
}
}
/**
* 获取负责处理该句柄的存储后端路由名称。
*
* @return 存储后端 bean 名称
*/
public String getBackend() {
return backend;
}
/**
* 获取 x-file-storage 平台名称。
*
* @return 平台名称,非 x-file-storage 后端时可为空字符串
*/
public String getPlatform() {
return platform;
}
/**
* 获取准备写入时固化的基础路径。
*
* @return 基础路径或本地绝对根目录
*/
public String getBasePath() {
return basePath;
}
/**
* 获取规范化相对目录。
*
* @return 空字符串或以斜杠结尾的相对目录
*/
public String getPath() {
return path;
}
/**
* 获取固定文件名。
*
* @return 文件名
*/
public String getFilename() {
return filename;
}
/**
* 将句柄编码为带版本、无填充且具有完整性校验的 Base64URL locator。
*
* @return 可安全持久化到文本字段的 locator
* @throws IllegalStateException 当前 JVM 不支持 SHA-256 或编码失败时抛出
*/
public String encodeLocator() {
String locator = buildLocator();
if (locator.length() > MAX_LOCATOR_CHARS) {
throw new IllegalStateException("文件存储 locator 超过 2048 字符持久化限制");
}
return locator;
}
/**
* 构造 locator 文本,长度检查由调用方在最终返回或构造校验阶段完成。
*
* @return locator 文本
*/
private String buildLocator() {
try {
ByteArrayOutputStream bodyBuffer = new ByteArrayOutputStream();
try (DataOutputStream output = new DataOutputStream(bodyBuffer)) {
output.writeByte(BINARY_VERSION);
writeString(output, backend);
writeString(output, platform);
writeString(output, basePath);
writeString(output, path);
writeString(output, filename);
}
byte[] body = bodyBuffer.toByteArray();
byte[] checksum = sha256(body);
ByteBuffer encoded = ByteBuffer.allocate(body.length + checksum.length);
encoded.put(body).put(checksum);
return LOCATOR_PREFIX + Base64.getUrlEncoder().withoutPadding().encodeToString(encoded.array());
} catch (IOException exception) {
throw new IllegalStateException("编码文件存储 locator 失败", exception);
}
}
/**
* 解码并严格校验一个文件存储 locator。
*
* @param locator 由 {@link #encodeLocator()} 生成的 locator
* @return 不可变文件存储写句柄
* @throws IllegalArgumentException locator 版本、编码、校验值或字段不合法时抛出
*/
public static FileStorageWriteHandle decodeLocator(String locator) {
if (locator == null || locator.length() <= LOCATOR_PREFIX.length()
|| locator.length() > MAX_LOCATOR_CHARS || !locator.startsWith(LOCATOR_PREFIX)) {
throw new IllegalArgumentException("文件存储 locator 格式不正确");
}
String encoded = locator.substring(LOCATOR_PREFIX.length());
if (!BASE64_URL_PATTERN.matcher(encoded).matches()) {
throw new IllegalArgumentException("文件存储 locator 不是无填充 Base64URL 编码");
}
final byte[] bytes;
try {
bytes = Base64.getUrlDecoder().decode(encoded);
} catch (IllegalArgumentException exception) {
throw new IllegalArgumentException("文件存储 locator Base64URL 编码不正确", exception);
}
if (bytes.length <= CHECKSUM_BYTES + 1) {
throw new IllegalArgumentException("文件存储 locator 数据不完整");
}
byte[] body = java.util.Arrays.copyOf(bytes, bytes.length - CHECKSUM_BYTES);
byte[] checksum = java.util.Arrays.copyOfRange(bytes, body.length, bytes.length);
if (!MessageDigest.isEqual(checksum, sha256(body))) {
throw new IllegalArgumentException("文件存储 locator 完整性校验失败");
}
try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(body))) {
int version = input.readUnsignedByte();
if (version != BINARY_VERSION) {
throw new IllegalArgumentException("不支持的文件存储 locator 版本: " + version);
}
FileStorageWriteHandle handle = new FileStorageWriteHandle(
readString(input, "backend", MAX_BACKEND_BYTES),
readString(input, "platform", MAX_PLATFORM_BYTES),
readString(input, "basePath", MAX_BASE_PATH_BYTES),
readString(input, "path", MAX_PATH_BYTES),
readString(input, "filename", MAX_FILENAME_BYTES));
if (input.available() != 0 || !handle.encodeLocator().equals(locator)) {
throw new IllegalArgumentException("文件存储 locator 包含非规范数据");
}
return handle;
} catch (IOException exception) {
throw new IllegalArgumentException("文件存储 locator 数据不完整", exception);
}
}
/**
* 将字符串以长度前缀 UTF-8 格式写入 locator 载荷。
*
* @param output 目标数据流
* @param value 字符串值
* @throws IOException 写入失败时抛出
*/
private static void writeString(DataOutputStream output, String value) throws IOException {
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
output.writeInt(bytes.length);
output.write(bytes);
}
/**
* 从 locator 载荷读取一个有界、严格 UTF-8 字符串。
*
* @param input locator 数据流
* @param field 字段名
* @param maxBytes 最大 UTF-8 字节数
* @return 解码字符串
* @throws IOException 数据流不完整时抛出
* @throws IllegalArgumentException 长度或 UTF-8 编码不合法时抛出
*/
private static String readString(DataInputStream input, String field, int maxBytes) throws IOException {
int length = input.readInt();
if (length < 0 || length > maxBytes || length > input.available()) {
throw new IllegalArgumentException(field + " 长度不正确");
}
byte[] bytes = input.readNBytes(length);
try {
return StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.decode(ByteBuffer.wrap(bytes))
.toString();
} catch (CharacterCodingException exception) {
throw new IllegalArgumentException(field + " 不是合法 UTF-8", exception);
}
}
/**
* 校验存储后端或平台路由标识。
*
* @param field 字段名
* @param value 字段值
* @param allowEmpty 是否允许空字符串
* @param maxBytes 最大 UTF-8 字节数
* @return 经校验的原值
*/
private static String validateRoute(String field, String value, boolean allowEmpty, int maxBytes) {
if (value == null || (!allowEmpty && value.isBlank())) {
throw new IllegalArgumentException(field + " 不能为空");
}
if (value.isEmpty() && allowEmpty) {
return value;
}
if (!value.equals(value.trim()) || utf8Length(value) > maxBytes || !ROUTE_PATTERN.matcher(value).matches()) {
throw new IllegalArgumentException(field + " 不是合法路由标识");
}
return value;
}
/**
* 校验句柄中的基础路径。
*
* @param value 基础路径
* @return 经校验的原值
*/
private static String validateBasePath(String value) {
if (value == null || utf8Length(value) > MAX_BASE_PATH_BYTES || containsControlCharacter(value)) {
throw new IllegalArgumentException("basePath 不合法或超过长度限制");
}
validateNoTraversalSegments(value, "basePath");
return value;
}
/**
* 规范化并校验相对目录。
*
* @param value 相对目录
* @return 空字符串或以斜杠结尾的规范目录
*/
private static String normalizeRelativePath(String value) {
if (value == null || value.isEmpty()) {
return "";
}
if (!value.equals(value.trim()) || value.startsWith("/") || value.startsWith("\\")
|| value.contains("\\") || value.contains("//") || containsControlCharacter(value)) {
throw new IllegalArgumentException("path 必须是规范的安全相对路径");
}
String withoutTrailingSlash = value.endsWith("/") ? value.substring(0, value.length() - 1) : value;
if (withoutTrailingSlash.isEmpty() || utf8Length(withoutTrailingSlash) + 1 > MAX_PATH_BYTES) {
throw new IllegalArgumentException("path 不合法或超过长度限制");
}
String[] segments = withoutTrailingSlash.split("/", -1);
for (String segment : segments) {
validatePathSegment("path", segment, MAX_FILENAME_BYTES);
}
return withoutTrailingSlash + "/";
}
/**
* 校验一个可移植的文件路径片段。
*
* @param field 字段名
* @param value 路径片段
* @param maxBytes 最大 UTF-8 字节数
* @return 经校验的原值
*/
private static String validatePathSegment(String field, String value, int maxBytes) {
if (value == null || value.isBlank() || !value.equals(value.trim()) || ".".equals(value) || "..".equals(value)
|| utf8Length(value) > maxBytes || containsControlCharacter(value)
|| value.indexOf('/') >= 0 || value.indexOf('\\') >= 0
|| value.matches(".*[<>:\"|?*].*") || value.endsWith(".")
|| WINDOWS_RESERVED_NAME.matcher(value.toUpperCase(Locale.ROOT)).matches()) {
throw new IllegalArgumentException(field + " 包含不安全路径片段");
}
return value;
}
/**
* 拒绝基础路径中的当前目录和父目录片段。
*
* @param value 待检查路径
* @param field 字段名
*/
private static void validateNoTraversalSegments(String value, String field) {
for (String segment : value.split("[/\\\\]", -1)) {
if (".".equals(segment) || "..".equals(segment)) {
throw new IllegalArgumentException(field + " 包含路径穿越片段");
}
}
}
/**
* 判断字符串是否包含 ASCII 或 Unicode 控制字符。
*
* @param value 待检查字符串
* @return 包含控制字符时返回 true
*/
private static boolean containsControlCharacter(String value) {
return value.codePoints().anyMatch(codePoint -> Character.isISOControl(codePoint));
}
/**
* 计算字符串的 UTF-8 字节数。
*
* @param value 字符串
* @return UTF-8 字节数
*/
private static int utf8Length(String value) {
return value.getBytes(StandardCharsets.UTF_8).length;
}
/**
* 计算 SHA-256 完整性校验值。
*
* @param bytes 输入字节
* @return 32 字节 SHA-256 值
*/
private static byte[] sha256(byte[] bytes) {
try {
return MessageDigest.getInstance("SHA-256").digest(bytes);
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("当前 JVM 不支持 SHA-256", exception);
}
}
/**
* 比较两个写句柄的全部物理定位字段。
*
* @param other 待比较对象
* @return 字段全部相同时返回 true
*/
@Override
public boolean equals(Object other) {
if (this == other) {
return true;
}
if (!(other instanceof FileStorageWriteHandle handle)) {
return false;
}
return backend.equals(handle.backend) && platform.equals(handle.platform)
&& basePath.equals(handle.basePath) && path.equals(handle.path) && filename.equals(handle.filename);
}
/**
* 计算全部物理定位字段的哈希值。
*
* @return 句柄哈希值
*/
@Override
public int hashCode() {
return Objects.hash(backend, platform, basePath, path, filename);
}
/**
* 返回不暴露额外内容的句柄摘要。
*
* @return 后端、平台和相对对象路径摘要
*/
@Override
public String toString() {
return "FileStorageWriteHandle{" + "backend='" + backend + '\'' + ", platform='" + platform + '\''
+ ", object='" + path + filename + "'}";
}
}

View File

@@ -0,0 +1,90 @@
package tech.easyflow.common.filestorage;
import java.util.Objects;
/**
* 可恢复文件写入完成后返回的不可变结果。
*
* <p>URL 继续服务现有读取链路locator 用于数据库提交失败或进程恢复时精确定位物理对象。</p>
*/
public final class FileStorageWriteResult {
/** 已写入文件的现有读取 URL。 */
private final String url;
/** 可解码为 {@link FileStorageWriteHandle} 的恢复 locator。 */
private final String locator;
/**
* 创建文件存储写入结果。
*
* @param url 已写入文件的读取 URL
* @param locator 恢复 locator
* @throws IllegalArgumentException URL 或 locator 为空、locator 无法解码时抛出
*/
public FileStorageWriteResult(String url, String locator) {
if (url == null || url.isBlank()) {
throw new IllegalArgumentException("文件写入 URL 不能为空");
}
if (locator == null || locator.isBlank()) {
throw new IllegalArgumentException("文件写入 locator 不能为空");
}
FileStorageWriteHandle.decodeLocator(locator);
this.url = url;
this.locator = locator;
}
/**
* 获取现有读取链路使用的 URL。
*
* @return 文件 URL
*/
public String getUrl() {
return url;
}
/**
* 获取精确恢复 locator。
*
* @return 文件存储 locator
*/
public String getLocator() {
return locator;
}
/**
* 比较 URL 与 locator。
*
* @param other 待比较对象
* @return 两个字段均相同时返回 true
*/
@Override
public boolean equals(Object other) {
if (this == other) {
return true;
}
if (!(other instanceof FileStorageWriteResult result)) {
return false;
}
return url.equals(result.url) && locator.equals(result.locator);
}
/**
* 计算 URL 与 locator 的哈希值。
*
* @return 结果哈希值
*/
@Override
public int hashCode() {
return Objects.hash(url, locator);
}
/**
* 返回不展开 locator 内容的写入结果摘要。
*
* @return 写入结果摘要
*/
@Override
public String toString() {
return "FileStorageWriteResult{" + "url='" + url + '\'' + ", locatorVersion='efsw1'}";
}
}

View File

@@ -9,29 +9,56 @@ import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import tech.easyflow.common.filestorage.FileStorageService;
import tech.easyflow.common.filestorage.FileStorageWriteHandle;
import tech.easyflow.common.filestorage.FileStorageWriteResult;
import tech.easyflow.common.filestorage.utils.PathGeneratorUtil;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.channels.Channels;
import java.nio.channels.FileChannel;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
/**
* EasyFlow 本地文件存储实现。
*/
@Component("local")
public class LocalFileStorageServiceImpl implements FileStorageService {
/** 日志记录器。 */
private static final Logger LOG = LoggerFactory.getLogger(LocalFileStorageServiceImpl.class);
/** 可恢复句柄使用的后端路由名称。 */
private static final String RECOVERABLE_BACKEND = "local";
/** 本地存储根目录。 */
@Value("${easyflow.storage.local.root:}")
private String root;
/** 返回给旧读取链路的 URL 前缀。 */
@Value("${easyflow.storage.local.prefix:}")
private String prefix;
/**
* 应用启动后的本地存储初始化钩子。
*/
@EventListener(ApplicationReadyEvent.class)
public void init() {
}
/**
* 使用随机用户路径保存文件。
*
* @param file 上传文件
* @return 文件路径
*/
@Override
public String save(MultipartFile file) {
try {
@@ -47,15 +74,28 @@ public class LocalFileStorageServiceImpl implements FileStorageService {
}
}
/**
* 打开本地文件读取流。
*
* @param path 文件路径
* @return 文件输入流
* @throws IOException 文件不存在或不可读时抛出
*/
@Override
public InputStream readStream(String path) throws IOException {
File target = getLocalFile(path);
return Files.newInputStream(target.toPath());
}
/**
* 获取本地文件大小。
*
* @param path 文件路径
* @return 文件大小,不存在时返回 0
*/
@Override
public long getFileSize(String path) {
File target = null;
File target;
try {
target = getLocalFile(path);
} catch (IOException e) {
@@ -67,6 +107,11 @@ public class LocalFileStorageServiceImpl implements FileStorageService {
return 0;
}
/**
* 删除旧版路径对应的本地文件。
*
* @param path 文件路径
*/
@Override
public void delete(String path) {
try {
@@ -80,7 +125,9 @@ public class LocalFileStorageServiceImpl implements FileStorageService {
/**
* 递归删除文件或目录(支持删除非空目录)
*
* @param file 要删除的文件或目录
* @throws Exception 任一目标无法删除时抛出
*/
private void deleteRecursively(File file) throws Exception {
if (file == null || !file.exists()) {
@@ -105,7 +152,13 @@ public class LocalFileStorageServiceImpl implements FileStorageService {
}
}
/**
* 将旧版 URL 转换为本地文件。
*
* @param path 文件 URL 或路径
* @return 本地文件
* @throws IOException 路径转换失败时抛出
*/
private File getLocalFile(String path) throws IOException {
if (this.root == null || this.root.isEmpty()) {
throw new RuntimeException("请指定存储根目录");
@@ -113,6 +166,13 @@ public class LocalFileStorageServiceImpl implements FileStorageService {
return new File(this.root, path.replace(prefix, ""));
}
/**
* 使用指定前置目录与随机用户路径保存文件。
*
* @param file 上传文件
* @param prePath 前置目录
* @return 文件路径
*/
@Override
public String save(MultipartFile file, String prePath) {
try {
@@ -131,4 +191,249 @@ public class LocalFileStorageServiceImpl implements FileStorageService {
throw new RuntimeException(e.getMessage(), e);
}
}
/**
* 准备包含真实、稳定本地根目录的可恢复写句柄。
*
* @param path 根目录下的相对目录
* @param filename 固定文件名
* @return 本地可恢复写句柄
*/
@Override
public FileStorageWriteHandle prepareRecoverableWrite(String path, String filename) {
try {
Path stableRoot = prepareStableRoot();
return new FileStorageWriteHandle(
RECOVERABLE_BACKEND, "", stableRoot.toString(), path, filename);
} catch (IOException exception) {
throw new IllegalStateException("准备本地可恢复文件写入失败", exception);
}
}
/**
* 通过同目录临时文件及原子替换写入句柄指定的精确本地文件。
*
* @param file 上传文件
* @param handle 本地可恢复写句柄
* @return 本地读取 URL 与恢复 locator
* @throws RuntimeException 写入、刷盘、原子替换或结果确认失败时抛出
*/
@Override
public FileStorageWriteResult saveRecoverable(MultipartFile file, FileStorageWriteHandle handle) {
if (file == null) {
throw new IllegalArgumentException("上传文件不能为空");
}
requireLocalHandle(handle);
Path temporary = null;
try {
Path target = resolveControlledTarget(handle, true);
if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)
&& (Files.isSymbolicLink(target) || !Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS))) {
throw new IllegalStateException("本地可恢复写入目标不是普通文件: " + target);
}
temporary = recoverablePartPath(target, handle);
if (Files.exists(temporary, LinkOption.NOFOLLOW_LINKS)
&& (Files.isSymbolicLink(temporary)
|| !Files.isRegularFile(temporary, LinkOption.NOFOLLOW_LINKS))) {
throw new IllegalStateException("本地可恢复写入暂存目标不是普通文件: " + temporary);
}
try (InputStream input = file.getInputStream();
FileChannel channel = FileChannel.open(
temporary, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
StandardOpenOption.TRUNCATE_EXISTING)) {
OutputStream output = Channels.newOutputStream(channel);
input.transferTo(output);
channel.force(true);
}
try {
Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException exception) {
throw new IllegalStateException("本地文件系统不支持可恢复写入所需的原子替换", exception);
}
temporary = null;
if (!Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(target)) {
throw new IllegalStateException("本地可恢复写入后未找到普通物理文件: " + target);
}
String objectPath = handle.getPath() + handle.getFilename();
String url = StringUtils.hasText(prefix)
? (prefix.endsWith("/") ? prefix : prefix + "/") + objectPath
: objectPath;
return new FileStorageWriteResult(url, handle.encodeLocator());
} catch (IOException exception) {
throw new IllegalStateException("写入本地可恢复文件失败", exception);
} finally {
if (temporary != null) {
try {
Files.deleteIfExists(temporary);
} catch (IOException cleanupException) {
LOG.warn("清理本地可恢复写入临时文件失败: {}", temporary, cleanupException);
}
}
}
}
/**
* 精确且幂等地删除句柄对应的最终文件与确定性暂存文件,并确认两者均不存在。
*
* @param handle 本地可恢复写句柄
* @throws RuntimeException 目标不安全、删除失败或删除后仍存在时抛出
*/
@Override
public void deleteRecoverable(FileStorageWriteHandle handle) {
requireLocalHandle(handle);
try {
Path target = resolveControlledTarget(handle, false);
Path temporary = recoverablePartPath(target, handle);
deleteControlledRegularFile(target, "最终文件");
deleteControlledRegularFile(temporary, "暂存文件");
} catch (IOException exception) {
throw new IllegalStateException("删除本地可恢复文件失败", exception);
}
}
/**
* 精确检查句柄对应的本地普通文件是否存在。
*
* @param handle 本地可恢复写句柄
* @return 普通物理文件存在时返回 true
* @throws RuntimeException 路径包含符号链接或目标不是普通文件时抛出
*/
@Override
public boolean existsRecoverable(FileStorageWriteHandle handle) {
requireLocalHandle(handle);
try {
Path target = resolveControlledTarget(handle, false);
if (!Files.exists(target, LinkOption.NOFOLLOW_LINKS)) {
return false;
}
if (Files.isSymbolicLink(target) || !Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS)) {
throw new IllegalStateException("本地恢复目标不是普通文件: " + target);
}
return true;
} catch (IOException exception) {
throw new IllegalStateException("检查本地可恢复文件失败", exception);
}
}
/**
* 创建并解析配置根目录的真实路径,使句柄不依赖符号链接及后续配置切换。
*
* @return 已存在的真实根目录
* @throws IOException 无法创建或解析根目录时抛出
*/
private Path prepareStableRoot() throws IOException {
if (!StringUtils.hasText(root)) {
throw new IllegalStateException("请指定存储根目录");
}
Path configuredRoot = Path.of(root).toAbsolutePath().normalize();
Files.createDirectories(configuredRoot);
Path realRoot = configuredRoot.toRealPath();
if (!Files.isDirectory(realRoot, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(realRoot)) {
throw new IllegalStateException("本地存储根目录不是受控普通目录: " + configuredRoot);
}
return realRoot;
}
/**
* 在句柄固化根目录下解析目标,并逐级拒绝符号链接与路径逃逸。
*
* @param handle 本地可恢复写句柄
* @param createDirectories 是否创建缺失目录
* @return 受控目标文件路径
* @throws IOException 路径检查或目录创建失败时抛出
*/
private Path resolveControlledTarget(FileStorageWriteHandle handle, boolean createDirectories) throws IOException {
Path stableRoot = Path.of(handle.getBasePath());
if (!stableRoot.isAbsolute() || !stableRoot.normalize().equals(stableRoot)) {
throw new IllegalArgumentException("本地恢复句柄中的根目录不是规范绝对路径");
}
Path expectedTarget = stableRoot.resolve(handle.getPath()).resolve(handle.getFilename()).normalize();
if (!expectedTarget.startsWith(stableRoot) || expectedTarget.getParent() == null
|| !expectedTarget.getParent().startsWith(stableRoot)) {
throw new IllegalArgumentException("本地恢复目标逃逸存储根目录");
}
if (!Files.exists(stableRoot, LinkOption.NOFOLLOW_LINKS)) {
if (!createDirectories) {
return expectedTarget;
}
Files.createDirectories(stableRoot);
}
if (Files.isSymbolicLink(stableRoot) || !Files.isDirectory(stableRoot, LinkOption.NOFOLLOW_LINKS)
|| !stableRoot.toRealPath().equals(stableRoot)) {
throw new IllegalStateException("本地恢复句柄根目录不再是原受控目录: " + stableRoot);
}
Path parent = stableRoot;
if (!handle.getPath().isEmpty()) {
String relativeDirectory = handle.getPath().substring(0, handle.getPath().length() - 1);
for (String segment : relativeDirectory.split("/")) {
Path next = parent.resolve(segment);
if (Files.exists(next, LinkOption.NOFOLLOW_LINKS)) {
if (Files.isSymbolicLink(next) || !Files.isDirectory(next, LinkOption.NOFOLLOW_LINKS)) {
throw new IllegalStateException("本地恢复路径包含非普通目录: " + next);
}
} else if (createDirectories) {
Files.createDirectory(next);
} else {
return expectedTarget;
}
parent = next;
}
}
if (!parent.toRealPath().equals(parent)) {
throw new IllegalStateException("本地恢复目标父目录已逃逸受控路径: " + parent);
}
return expectedTarget;
}
/**
* 根据句柄稳定推导同目录暂存文件,确保进程在原子替换前退出时仍可精确回收。
*
* @param target 最终目标文件
* @param handle 可恢复写句柄
* @return 确定性同目录暂存文件
*/
Path recoverablePartPath(Path target, FileStorageWriteHandle handle) {
try {
byte[] digest = MessageDigest.getInstance("SHA-256")
.digest(handle.encodeLocator().getBytes(java.nio.charset.StandardCharsets.UTF_8));
return target.resolveSibling(".easyflow-part-" + HexFormat.of().formatHex(digest));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("当前 JVM 不支持 SHA-256", exception);
}
}
/**
* 删除受控普通文件并确认不存在;文件原本不存在时按幂等成功处理。
*
* @param path 待删除文件
* @param description 文件用途描述
* @throws IOException 删除失败时抛出
*/
private void deleteControlledRegularFile(Path path, String description) throws IOException {
if (!Files.exists(path, LinkOption.NOFOLLOW_LINKS)) {
return;
}
if (Files.isSymbolicLink(path) || !Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) {
throw new IllegalStateException("拒绝删除非普通的本地恢复" + description + ": " + path);
}
Files.delete(path);
if (Files.exists(path, LinkOption.NOFOLLOW_LINKS)) {
throw new IllegalStateException("删除后本地恢复" + description + "仍存在: " + path);
}
}
/**
* 校验句柄确实属于本地后端。
*
* @param handle 待校验句柄
*/
private void requireLocalHandle(FileStorageWriteHandle handle) {
if (handle == null) {
throw new IllegalArgumentException("本地文件存储写句柄不能为空");
}
if (!RECOVERABLE_BACKEND.equals(handle.getBackend()) || !handle.getPlatform().isEmpty()) {
throw new IllegalArgumentException("文件存储写句柄不属于本地后端");
}
}
}

View File

@@ -1,6 +1,8 @@
package tech.easyflow.common.filestorage.impl;
import org.dromara.x.file.storage.core.FileInfo;
import org.dromara.x.file.storage.core.platform.FileStorage;
import org.dromara.x.file.storage.core.recorder.FileRecorder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
@@ -8,24 +10,49 @@ import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import tech.easyflow.common.filestorage.FileStorageService;
import tech.easyflow.common.filestorage.FileStorageWriteHandle;
import tech.easyflow.common.filestorage.FileStorageWriteResult;
import tech.easyflow.common.filestorage.utils.PathGeneratorUtil;
import tech.easyflow.common.util.OkHttpUtil;
import java.io.*;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.Objects;
/**
* 基于 x-file-storage 的 EasyFlow 文件存储实现。
*/
@Component("xFileStorage")
public class XFIleStorageServiceImpl implements FileStorageService {
/** 日志记录器。 */
private static final Logger LOG = LoggerFactory.getLogger(XFIleStorageServiceImpl.class);
/** 可恢复句柄使用的后端路由名称。 */
private static final String RECOVERABLE_BACKEND = "xFileStorage";
/** x-file-storage 聚合服务。 */
@Autowired
private org.dromara.x.file.storage.core.FileStorageService fileStorageService;
/**
* 使用默认目录上传文件。
*
* @param file 上传文件
* @return 文件 URL
*/
@Override
public String save(MultipartFile file) {
return save(file, null);
}
/**
* 使用指定前置目录上传文件。
*
* @param file 上传文件
* @param prePath 前置目录
* @return 文件 URL
*/
@Override
public String save(MultipartFile file, String prePath) {
String uploadPath = PathGeneratorUtil.generateUserPath("");
@@ -44,14 +71,34 @@ public class XFIleStorageServiceImpl implements FileStorageService {
return fileInfo.getUrl();
}
/**
* 幂等删除指定文件;物理文件已不存在时同步清理残留记录。
*
* @param path 文件路径
* @throws RuntimeException 文件仍存在或残留记录无法清理时抛出
*/
@Override
public void delete(String path) {
boolean deleted = fileStorageService.delete(path);
if (!deleted) {
LOG.warn("删除文件失败或文件不存在path={}", path);
if (deleted) {
return;
}
if (fileStorageService.exists(path)) {
throw new RuntimeException("删除文件失败物理文件仍存在path=" + path);
}
org.dromara.x.file.storage.core.recorder.FileRecorder recorder = fileStorageService.getFileRecorder();
boolean recordDeleted = recorder != null && recorder.delete(path);
if (!recordDeleted && fileStorageService.getFileInfoByUrl(path) != null) {
throw new RuntimeException("物理文件已删除但文件记录清理失败path=" + path);
}
}
/**
* 通过文件 URL 打开远程读取流。
*
* @param fileUrl 文件 URL
* @return 远程输入流
*/
@Override
public InputStream readStream(String fileUrl) {
return OkHttpUtil.getInputStream(fileUrl);
@@ -73,7 +120,10 @@ public class XFIleStorageServiceImpl implements FileStorageService {
}
/**
* 获取文件的 Content-Type
* 获取上传文件的 Content-Type,并为文本文件补充 UTF-8 编码。
*
* @param file 上传文件
* @return 文件媒体类型
*/
public static String getFileContentType(MultipartFile file) {
String originalFilename = file.getOriginalFilename();
@@ -86,4 +136,300 @@ public class XFIleStorageServiceImpl implements FileStorageService {
}
return contentType;
}
/**
* 从当前默认 x-file-storage 平台解析平台名与公开基础路径,准备可恢复写句柄。
*
* @param path 平台基础路径下的相对目录
* @param filename 固定文件名
* @return x-file-storage 可恢复写句柄
* @throws RuntimeException 默认平台不存在或平台未公开 getBasePath 时抛出
*/
@Override
public FileStorageWriteHandle prepareRecoverableWrite(String path, String filename) {
FileStorage storage = fileStorageService.getFileStorage();
if (storage == null || !StringUtils.hasText(storage.getPlatform())) {
throw new IllegalStateException("x-file-storage 默认平台不可用");
}
String basePath = readRequiredBasePath(storage);
return new FileStorageWriteHandle(
RECOVERABLE_BACKEND, storage.getPlatform(), basePath, path, filename);
}
/**
* 使用句柄中的固定平台、路径及文件名上传文件。
*
* @param file 上传文件
* @param handle x-file-storage 可恢复写句柄
* @return 文件 URL 与恢复 locator
* @throws RuntimeException 平台配置漂移、上传失败或实际位置不一致时抛出
*/
@Override
public FileStorageWriteResult saveRecoverable(MultipartFile file, FileStorageWriteHandle handle) {
if (file == null) {
throw new IllegalArgumentException("上传文件不能为空");
}
FileStorage storage = requireStorage(handle);
requireCurrentBasePathForWrite(storage, handle);
boolean physicalWriteMayHaveStarted = false;
try {
org.dromara.x.file.storage.core.upload.UploadPretreatment upload = fileStorageService.of(file)
.setPlatform(handle.getPlatform())
.setPath(physicalPath(handle))
.setSaveFilename(handle.getFilename())
.setContentType(getFileContentType(file));
physicalWriteMayHaveStarted = true;
FileInfo fileInfo = upload.upload();
if (fileInfo == null || !StringUtils.hasText(fileInfo.getUrl())) {
throw new IllegalStateException("x-file-storage 未返回有效上传结果");
}
verifyUploadedLocation(fileInfo, handle);
return new FileStorageWriteResult(fileInfo.getUrl(), handle.encodeLocator());
} catch (RuntimeException exception) {
if (physicalWriteMayHaveStarted) {
try {
deletePhysicalAndConfirm(storage, handle);
cleanupRecorderBestEffort(storage, handle);
} catch (RuntimeException cleanupException) {
exception.addSuppressed(cleanupException);
}
}
throw exception;
}
}
/**
* 直接调用句柄指定平台的物理删除与存在检查,绕过依赖 URL 记录的聚合删除路径。
*
* @param handle x-file-storage 可恢复写句柄
* @throws RuntimeException 删除后物理对象仍存在时抛出
*/
@Override
public void deleteRecoverable(FileStorageWriteHandle handle) {
FileStorage storage = requireStorage(handle);
requirePersistedBasePathSupport(storage, handle);
deletePhysicalAndConfirm(storage, handle);
cleanupRecorderBestEffort(storage, handle);
}
/**
* 直接检查句柄指定平台上的物理对象,不依赖 Redis 或其他 FileRecorder 记录。
*
* @param handle x-file-storage 可恢复写句柄
* @return 物理对象存在时返回 true
*/
@Override
public boolean existsRecoverable(FileStorageWriteHandle handle) {
FileStorage storage = requireStorage(handle);
requirePersistedBasePathSupport(storage, handle);
return storage.exists(toFileInfo(handle));
}
/**
* 校验句柄并取得其固定平台。
*
* @param handle 待处理句柄
* @return 句柄指定的具体平台存储
*/
private FileStorage requireStorage(FileStorageWriteHandle handle) {
if (handle == null) {
throw new IllegalArgumentException("x-file-storage 写句柄不能为空");
}
if (!RECOVERABLE_BACKEND.equals(handle.getBackend()) || !StringUtils.hasText(handle.getPlatform())) {
throw new IllegalArgumentException("文件存储写句柄不属于 x-file-storage 后端");
}
FileStorage storage = fileStorageService.getFileStorage(handle.getPlatform());
if (storage == null) {
throw new IllegalStateException("x-file-storage 平台不存在: " + handle.getPlatform());
}
return storage;
}
/**
* 上传时要求平台当前基础路径仍与句柄一致,因为 x-file-storage 的 save 会覆盖 FileInfo.basePath。
*
* @param storage 具体平台存储
* @param handle 文件存储写句柄
*/
private void requireCurrentBasePathForWrite(FileStorage storage, FileStorageWriteHandle handle) {
String currentBasePath = readRequiredBasePath(storage);
if (!Objects.equals(currentBasePath, handle.getBasePath())) {
throw new IllegalStateException("x-file-storage 平台基础路径已变化,无法写入预先确定的位置");
}
}
/**
* 基础路径发生配置漂移时,确认具体平台的物理 key 仍实际使用句柄中的持久 basePath。
*
* <p>大多数对象存储使用 {@link FileStorage#getFileKey(FileInfo)} 默认实现,可安全清理历史
* basePath忽略 FileInfo.basePath 的平台会 fail-fast避免删除当前新目录下的同名对象。</p>
*
* @param storage 具体平台存储
* @param handle 文件存储写句柄
*/
private void requirePersistedBasePathSupport(FileStorage storage, FileStorageWriteHandle handle) {
String currentBasePath = readRequiredBasePath(storage);
if (Objects.equals(currentBasePath, handle.getBasePath())) {
return;
}
FileInfo fileInfo = toFileInfo(handle);
String expectedKey = handle.getBasePath() + physicalPath(handle) + handle.getFilename();
if (!Objects.equals(expectedKey, storage.getFileKey(fileInfo))) {
throw new IllegalStateException("x-file-storage 平台基础路径已变化,且当前平台无法按持久 basePath 定位");
}
}
/**
* 反射调用具体平台公开的 getBasePath 方法。
*
* @param storage 具体平台存储
* @return 基础路径,平台返回 null 时规范为空字符串
* @throws RuntimeException 平台未公开兼容方法或调用失败时抛出
*/
private String readRequiredBasePath(FileStorage storage) {
try {
Method method = storage.getClass().getMethod("getBasePath");
if (!String.class.equals(method.getReturnType())) {
throw new IllegalStateException("x-file-storage 平台 getBasePath 返回类型不是 String: "
+ storage.getClass().getName());
}
String basePath = (String) method.invoke(storage);
return basePath == null ? "" : basePath;
} catch (NoSuchMethodException exception) {
throw new IllegalStateException("x-file-storage 平台未公开 getBasePath: "
+ storage.getClass().getName(), exception);
} catch (IllegalAccessException | InvocationTargetException exception) {
throw new IllegalStateException("读取 x-file-storage 平台基础路径失败: "
+ storage.getClass().getName(), exception);
}
}
/**
* 校验 x-file-storage 实际上传位置与预先持久化句柄完全一致。
*
* @param fileInfo 实际上传结果
* @param handle 预先准备的句柄
*/
private void verifyUploadedLocation(FileInfo fileInfo, FileStorageWriteHandle handle) {
String actualBasePath = fileInfo.getBasePath() == null ? "" : fileInfo.getBasePath();
String actualPath = fileInfo.getPath() == null ? "" : fileInfo.getPath();
if (!handle.getPlatform().equals(fileInfo.getPlatform())
|| !handle.getBasePath().equals(actualBasePath)
|| !physicalPath(handle).equals(actualPath)
|| !handle.getFilename().equals(fileInfo.getFilename())) {
throw new IllegalStateException("x-file-storage 实际上传位置与恢复句柄不一致");
}
}
/**
* 构造仅包含精确物理定位字段的 FileInfo。
*
* @param handle 文件存储写句柄
* @return 供具体平台直接删除或检查的文件信息
*/
private FileInfo toFileInfo(FileStorageWriteHandle handle) {
return new FileInfo()
.setPlatform(handle.getPlatform())
.setBasePath(handle.getBasePath())
.setPath(physicalPath(handle))
.setFilename(handle.getFilename());
}
/**
* 将句柄中的安全相对目录转换为 x-file-storage 直接拼接 basePath 所需的物理目录。
*
* <p>当前配置常使用不带尾斜杠的 basePath此时必须补一个前导斜杠避免生成
* {@code attachmentskill-content/...} 一类错误对象键。</p>
*
* @param handle 文件存储写句柄
* @return 传给 x-file-storage 的精确物理目录
*/
private String physicalPath(FileStorageWriteHandle handle) {
if (handle.getBasePath().isEmpty() || handle.getBasePath().endsWith("/")) {
return handle.getPath();
}
return "/" + handle.getPath();
}
/**
* 直接删除具体平台物理对象,并以随后 exists 结果作为成功判据。
*
* @param storage 具体平台存储
* @param handle 文件存储写句柄
*/
private void deletePhysicalAndConfirm(FileStorage storage, FileStorageWriteHandle handle) {
FileInfo fileInfo = toFileInfo(handle);
boolean deleted;
try {
deleted = storage.delete(fileInfo);
} catch (RuntimeException exception) {
final boolean stillExists;
try {
stillExists = storage.exists(fileInfo);
} catch (RuntimeException existsException) {
exception.addSuppressed(existsException);
throw exception;
}
if (!stillExists) {
return;
}
throw exception;
}
if (storage.exists(fileInfo)) {
throw new IllegalStateException("x-file-storage 删除后物理对象仍存在platform="
+ handle.getPlatform() + ", path=" + handle.getPath() + handle.getFilename()
+ ", deleteResult=" + deleted);
}
}
/**
* 在物理删除已经确认成功后,尽力清理可推导 URL 对应的 recorder 记录。
*
* <p>记录不存在、平台不能公开推导 URL 或清理失败均不改变物理删除成功结果。</p>
*
* @param storage 具体平台存储
* @param handle 文件存储写句柄
*/
private void cleanupRecorderBestEffort(FileStorage storage, FileStorageWriteHandle handle) {
try {
FileRecorder recorder = fileStorageService.getFileRecorder();
if (recorder == null) {
return;
}
String url = deriveUrlBestEffort(storage, toFileInfo(handle));
if (!StringUtils.hasText(url)) {
return;
}
if (!recorder.delete(url)) {
LOG.debug("x-file-storage recorder 中没有可清理记录url={}", url);
}
} catch (RuntimeException exception) {
LOG.warn("物理文件已删除,但清理 x-file-storage recorder 记录失败platform={}",
handle.getPlatform(), exception);
}
}
/**
* 使用平台公开的 getDomain 与 getFileKey 尽力推导 recorder 使用的 URL。
*
* @param storage 具体平台存储
* @param fileInfo 精确物理文件信息
* @return 可推导 URL平台不支持时返回 null
*/
private String deriveUrlBestEffort(FileStorage storage, FileInfo fileInfo) {
try {
Method method = storage.getClass().getMethod("getDomain");
if (!String.class.equals(method.getReturnType())) {
return null;
}
String domain = (String) method.invoke(storage);
if (domain == null) {
return null;
}
return domain + storage.getFileKey(fileInfo);
} catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException | RuntimeException exception) {
LOG.debug("当前 x-file-storage 平台无法推导 recorder URL: {}", storage.getClass().getName());
return null;
}
}
}

View File

@@ -0,0 +1,115 @@
package tech.easyflow.common.filestorage;
import org.junit.Test;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertSame;
/**
* {@link FileStorageManager} 可恢复操作固定后端路由测试。
*/
public class FileStorageManagerTest {
/**
* 验证 prepare 使用当前后端,而后续操作在默认后端切换后仍按句柄后端路由。
*/
@Test
public void recoverableOperationsRouteByPreparedBackendAfterSwitch() {
RecordingStorage local = new RecordingStorage("local");
RecordingStorage xFile = new RecordingStorage("xFileStorage");
AtomicReference<String> current = new AtomicReference<>("local");
FileStorageManager manager = new FileStorageManager(
current::get, backend -> Map.of("local", local, "xFileStorage", xFile).get(backend));
FileStorageWriteHandle handle = manager.prepareRecoverableWrite("skill-content/ab", "content.bin");
current.set("xFileStorage");
FileStorageWriteResult result = manager.saveRecoverable(null, handle);
manager.deleteRecoverable(handle);
boolean exists = manager.existsRecoverable(handle);
assertEquals("local", handle.getBackend());
assertSame(local.result, result);
assertEquals(1, local.prepareCalls);
assertEquals(1, local.saveCalls);
assertEquals(1, local.deleteCalls);
assertEquals(1, local.existsCalls);
assertEquals(0, xFile.prepareCalls + xFile.saveCalls + xFile.deleteCalls + xFile.existsCalls);
assertFalse(exists);
}
/**
* 可记录可恢复调用的存储测试替身。
*/
private static final class RecordingStorage implements FileStorageService {
/** 后端名称。 */
private final String backend;
/** 固定结果。 */
private final FileStorageWriteResult result;
/** prepare 调用次数。 */
private int prepareCalls;
/** save 调用次数。 */
private int saveCalls;
/** delete 调用次数。 */
private int deleteCalls;
/** exists 调用次数。 */
private int existsCalls;
/**
* 创建指定名称的存储替身。
*
* @param backend 后端名称
*/
private RecordingStorage(String backend) {
this.backend = backend;
FileStorageWriteHandle handle = new FileStorageWriteHandle(
backend, "", "/tmp/easyflow", "skill-content", "content.bin");
this.result = new FileStorageWriteResult("/files/content.bin", handle.encodeLocator());
}
/** {@inheritDoc} */
@Override public String save(MultipartFile file) { return ""; }
/** {@inheritDoc} */
@Override public void delete(String path) { }
/** {@inheritDoc} */
@Override public InputStream readStream(String path) throws IOException { return InputStream.nullInputStream(); }
/** {@inheritDoc} */
@Override public long getFileSize(String path) { return 0; }
/** {@inheritDoc} */
@Override public String save(File file, String prePath) { return ""; }
/** {@inheritDoc} */
@Override
public FileStorageWriteHandle prepareRecoverableWrite(String path, String filename) {
prepareCalls++;
return new FileStorageWriteHandle(backend, "", "/tmp/easyflow", path, filename);
}
/** {@inheritDoc} */
@Override
public FileStorageWriteResult saveRecoverable(MultipartFile file, FileStorageWriteHandle handle) {
saveCalls++;
return result;
}
/** {@inheritDoc} */
@Override
public void deleteRecoverable(FileStorageWriteHandle handle) {
deleteCalls++;
}
/** {@inheritDoc} */
@Override
public boolean existsRecoverable(FileStorageWriteHandle handle) {
existsCalls++;
return false;
}
}
}

View File

@@ -0,0 +1,48 @@
package tech.easyflow.common.filestorage;
import org.junit.Test;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.io.InputStream;
import static org.junit.Assert.assertThrows;
/**
* {@link FileStorageService} 可恢复操作默认 fail-fast 契约测试。
*/
public class FileStorageServiceTest {
/**
* 验证尚未实现新契约的旧后端不会伪造成功结果。
*/
@Test
public void recoverableDefaultsFailFast() {
FileStorageService legacyStorage = new LegacyStorage();
FileStorageWriteHandle handle = new FileStorageWriteHandle(
"legacy", "", "/tmp/easyflow", "skill-content", "content.bin");
assertThrows(UnsupportedOperationException.class,
() -> legacyStorage.prepareRecoverableWrite("skill-content", "content.bin"));
assertThrows(UnsupportedOperationException.class,
() -> legacyStorage.saveRecoverable(null, handle));
assertThrows(UnsupportedOperationException.class,
() -> legacyStorage.deleteRecoverable(handle));
assertThrows(UnsupportedOperationException.class,
() -> legacyStorage.existsRecoverable(handle));
}
/**
* 仅实现旧版接口的存储替身。
*/
private static final class LegacyStorage implements FileStorageService {
/** {@inheritDoc} */
@Override public String save(MultipartFile file) { return ""; }
/** {@inheritDoc} */
@Override public void delete(String path) { }
/** {@inheritDoc} */
@Override public InputStream readStream(String path) throws IOException { return InputStream.nullInputStream(); }
/** {@inheritDoc} */
@Override public long getFileSize(String path) { return 0; }
}
}

View File

@@ -0,0 +1,87 @@
package tech.easyflow.common.filestorage;
import org.junit.Test;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
/**
* {@link FileStorageWriteHandle} 编解码与安全边界测试。
*/
public class FileStorageWriteHandleTest {
/**
* 验证 locator 可无损往返且相对目录会规范化为尾斜杠形式。
*/
@Test
public void locatorRoundTripPreservesPhysicalLocation() {
FileStorageWriteHandle handle = new FileStorageWriteHandle(
"xFileStorage", "minio-1", "easyflow/", "skill-content/ab", "content.bin");
String locator = handle.encodeLocator();
FileStorageWriteHandle decoded = FileStorageWriteHandle.decodeLocator(locator);
assertEquals(handle, decoded);
assertEquals("skill-content/ab/", decoded.getPath());
assertTrue(locator.startsWith("efsw1."));
assertFalse(locator.contains("="));
assertTrue(locator.length() <= 2048);
}
/**
* 验证篡改后的 locator 无法绕过完整性校验。
*/
@Test
public void tamperedLocatorIsRejected() {
FileStorageWriteHandle handle = new FileStorageWriteHandle(
"local", "", "/var/lib/easyflow", "skill-content", "content.bin");
String locator = handle.encodeLocator();
char replacement = locator.endsWith("A") ? 'B' : 'A';
String tampered = locator.substring(0, locator.length() - 1) + replacement;
assertThrows(IllegalArgumentException.class,
() -> FileStorageWriteHandle.decodeLocator(tampered));
}
/**
* 验证相对路径穿越、绝对路径与不可移植文件名都会被拒绝。
*/
@Test
public void unsafePathsAreRejected() {
assertThrows(IllegalArgumentException.class,
() -> new FileStorageWriteHandle("local", "", "/tmp/easyflow", "../outside", "file.bin"));
assertThrows(IllegalArgumentException.class,
() -> new FileStorageWriteHandle("local", "", "/tmp/easyflow", "/absolute", "file.bin"));
assertThrows(IllegalArgumentException.class,
() -> new FileStorageWriteHandle("local", "", "/tmp/easyflow", "safe", "../file.bin"));
assertThrows(IllegalArgumentException.class,
() -> new FileStorageWriteHandle("local", "", "/tmp/easyflow", "safe", "CON"));
}
/**
* 验证句柄在构造阶段就受数据库 VARCHAR(2048) locator 预算约束。
*/
@Test
public void handleExceedingPersistentLocatorBudgetIsRejected() {
String oversizedBasePath = "/" + "a".repeat(1_700);
IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
() -> new FileStorageWriteHandle(
"xFileStorage", "minio", oversizedBasePath, "skill-content", "content.bin"));
assertTrue(exception.getMessage().contains("2048"));
}
/**
* 验证解码器在 Base64 解码前拒绝超过数据库字段预算的输入。
*/
@Test
public void oversizedLocatorTextIsRejectedBeforeDecode() {
String locator = "efsw1." + "A".repeat(2048);
assertThrows(IllegalArgumentException.class,
() -> FileStorageWriteHandle.decodeLocator(locator));
}
}

View File

@@ -0,0 +1,163 @@
package tech.easyflow.common.filestorage.impl;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import org.springframework.web.multipart.MultipartFile;
import tech.easyflow.common.filestorage.FileStorageWriteHandle;
import tech.easyflow.common.filestorage.FileStorageWriteResult;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Field;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
/**
* {@link LocalFileStorageServiceImpl} 可恢复精确写删测试。
*/
public class LocalFileStorageServiceImplTest {
/** 每个测试使用的隔离临时目录。 */
@Rule
public final TemporaryFolder temporaryFolder = new TemporaryFolder();
/**
* 验证固定位置原子写入、配置切换后仍按句柄根目录定位及幂等删除。
*
* @throws Exception 测试目录或反射配置失败
*/
@Test
public void recoverableWriteUsesPersistentRootAndDeletesIdempotently() throws Exception {
File originalRoot = temporaryFolder.newFolder("original-root");
File changedRoot = temporaryFolder.newFolder("changed-root");
LocalFileStorageServiceImpl service = createService(originalRoot, "/files");
FileStorageWriteHandle handle = service.prepareRecoverableWrite("skill-content/ab", "content.bin");
setField(service, "root", changedRoot.getAbsolutePath());
byte[] bytes = "recoverable-content".getBytes(java.nio.charset.StandardCharsets.UTF_8);
FileStorageWriteResult result = service.saveRecoverable(new BytesMultipartFile(bytes), handle);
Path target = Path.of(handle.getBasePath()).resolve(handle.getPath()).resolve(handle.getFilename());
assertEquals("/files/skill-content/ab/content.bin", result.getUrl());
assertEquals(handle, FileStorageWriteHandle.decodeLocator(result.getLocator()));
assertTrue(service.existsRecoverable(handle));
assertArrayEquals(bytes, Files.readAllBytes(target));
assertFalse(Files.exists(changedRoot.toPath().resolve("skill-content/ab/content.bin")));
service.deleteRecoverable(handle);
service.deleteRecoverable(handle);
assertFalse(service.existsRecoverable(handle));
}
/**
* 验证崩溃窗口遗留的确定性 part 文件可由同一个句柄精确回收。
*
* @throws Exception 测试目录或反射配置失败
*/
@Test
public void deleteRecoverableRemovesFinalAndCrashLeftPartFile() throws Exception {
File root = temporaryFolder.newFolder("crash-root");
LocalFileStorageServiceImpl service = createService(root, "");
FileStorageWriteHandle handle = service.prepareRecoverableWrite("skill-content/cd", "content.bin");
Path target = Path.of(handle.getBasePath()).resolve(handle.getPath()).resolve(handle.getFilename());
Files.createDirectories(target.getParent());
Files.writeString(target, "final");
Path part = service.recoverablePartPath(target, handle);
Files.writeString(part, "partial");
service.deleteRecoverable(handle);
assertFalse(Files.exists(target));
assertFalse(Files.exists(part));
}
/**
* 验证句柄路径中的符号链接不会被跟随到存储根目录外。
*
* @throws Exception 测试目录、符号链接或反射配置失败
*/
@Test
public void recoverableWriteRejectsSymbolicLinkEscape() throws Exception {
File root = temporaryFolder.newFolder("symlink-root");
File outside = temporaryFolder.newFolder("outside");
Files.createSymbolicLink(root.toPath().resolve("escape"), outside.toPath());
LocalFileStorageServiceImpl service = createService(root, "");
FileStorageWriteHandle handle = service.prepareRecoverableWrite("escape", "content.bin");
assertThrows(IllegalStateException.class,
() -> service.saveRecoverable(new BytesMultipartFile(new byte[]{1}), handle));
assertFalse(Files.exists(outside.toPath().resolve("content.bin")));
}
/**
* 创建具有测试根目录与 URL 前缀的服务。
*
* @param root 本地根目录
* @param prefix URL 前缀
* @return 本地存储服务
* @throws Exception 反射设置字段失败
*/
private LocalFileStorageServiceImpl createService(File root, String prefix) throws Exception {
LocalFileStorageServiceImpl service = new LocalFileStorageServiceImpl();
setField(service, "root", root.getAbsolutePath());
setField(service, "prefix", prefix);
return service;
}
/**
* 设置服务私有配置字段。
*
* @param target 目标服务
* @param name 字段名
* @param value 字段值
* @throws Exception 字段不存在或不可写时抛出
*/
private void setField(Object target, String name, Object value) throws Exception {
Field field = target.getClass().getDeclaredField(name);
field.setAccessible(true);
field.set(target, value);
}
/**
* 基于内存字节的 MultipartFile 测试替身。
*/
private static final class BytesMultipartFile implements MultipartFile {
/** 文件内容。 */
private final byte[] bytes;
/**
* 创建测试上传文件。
*
* @param bytes 文件内容
*/
private BytesMultipartFile(byte[] bytes) {
this.bytes = bytes.clone();
}
/** {@inheritDoc} */
@Override public String getName() { return "file"; }
/** {@inheritDoc} */
@Override public String getOriginalFilename() { return "content.bin"; }
/** {@inheritDoc} */
@Override public String getContentType() { return "application/octet-stream"; }
/** {@inheritDoc} */
@Override public boolean isEmpty() { return bytes.length == 0; }
/** {@inheritDoc} */
@Override public long getSize() { return bytes.length; }
/** {@inheritDoc} */
@Override public byte[] getBytes() { return bytes.clone(); }
/** {@inheritDoc} */
@Override public InputStream getInputStream() { return new ByteArrayInputStream(bytes); }
/** {@inheritDoc} */
@Override public void transferTo(File dest) throws IOException { Files.write(dest.toPath(), bytes); }
}
}

View File

@@ -0,0 +1,571 @@
package tech.easyflow.common.filestorage.impl;
import org.junit.Test;
import org.dromara.x.file.storage.core.FileInfo;
import org.dromara.x.file.storage.core.UploadPretreatment;
import org.dromara.x.file.storage.core.platform.FileStorage;
import org.dromara.x.file.storage.core.recorder.FileRecorder;
import org.springframework.web.multipart.MultipartFile;
import tech.easyflow.common.filestorage.FileStorageWriteHandle;
import tech.easyflow.common.filestorage.FileStorageWriteResult;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Field;
import java.nio.file.Files;
import java.util.function.Consumer;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
/**
* {@link XFIleStorageServiceImpl} 删除结果传播测试。
*/
public class XFIleStorageServiceImplTest {
/**
* 验证底层明确返回 false 时抛出带有效消息的异常。
*
* @throws Exception 注入测试替身失败
*/
@Test
public void deleteFalseThrowsNonEmptyException() throws Exception {
DeleteResultStorageService delegate = new DeleteResultStorageService(false, true, false);
XFIleStorageServiceImpl service = createService(delegate);
RuntimeException exception = assertThrows(
RuntimeException.class, () -> service.delete("skill-content/retry.bin"));
assertFalse(exception.getMessage() == null || exception.getMessage().isBlank());
assertEquals("skill-content/retry.bin", delegate.getLastPath());
}
/**
* 验证底层确认删除成功时正常返回。
*
* @throws Exception 注入测试替身失败
*/
@Test
public void deleteTrueReturnsNormally() throws Exception {
DeleteResultStorageService delegate = new DeleteResultStorageService(true, false, false);
XFIleStorageServiceImpl service = createService(delegate);
service.delete("skill-content/deleted.bin");
assertEquals("skill-content/deleted.bin", delegate.getLastPath());
}
/**
* 验证物理文件已不存在时会清理残留记录并按幂等成功返回。
*
* @throws Exception 注入测试替身失败
*/
@Test
public void deleteAbsentFileCleansResidualRecord() throws Exception {
DeleteResultStorageService delegate = new DeleteResultStorageService(false, false, true);
XFIleStorageServiceImpl service = createService(delegate);
service.delete("skill-content/already-absent.bin");
assertEquals("skill-content/already-absent.bin", delegate.getLastPath());
assertFalse(delegate.hasRecord());
}
/**
* 验证 prepare 与 save 固定平台、基础路径、相对路径及文件名,并同时返回 URL 与 locator。
*
* @throws Exception 注入测试替身失败
*/
@Test
public void recoverableSaveUsesExactPreparedLocation() throws Exception {
RecoverablePlatform platform = new RecoverablePlatform("minio-main", "attachment", "https://files/");
RecoverableStorageService delegate = new RecoverableStorageService(platform);
XFIleStorageServiceImpl service = createService(delegate);
FileStorageWriteHandle handle = service.prepareRecoverableWrite("skill-content/ab", "content.bin");
FileStorageWriteResult result = service.saveRecoverable(
new BytesMultipartFile("content".getBytes(java.nio.charset.StandardCharsets.UTF_8)), handle);
assertEquals("xFileStorage", handle.getBackend());
assertEquals("minio-main", handle.getPlatform());
assertEquals("attachment", handle.getBasePath());
assertEquals("/skill-content/ab/", delegate.uploadPath);
assertEquals("content.bin", delegate.uploadFilename);
assertEquals("minio-main", delegate.uploadPlatform);
assertEquals("https://files/attachment/skill-content/ab/content.bin", result.getUrl());
assertEquals(handle, FileStorageWriteHandle.decodeLocator(result.getLocator()));
assertTrue(platform.exists);
}
/**
* 验证 recorder 完全缺失目标记录时,精确删除仍直接作用于物理平台并成功。
*
* @throws Exception 注入测试替身失败
*/
@Test
public void recoverableDeleteWithoutRecorderEntryStillDeletesPhysicalObject() throws Exception {
RecoverablePlatform platform = new RecoverablePlatform("minio-main", "easyflow/", "https://files/");
platform.exists = true;
RecoverableStorageService delegate = new RecoverableStorageService(platform);
XFIleStorageServiceImpl service = createService(delegate);
FileStorageWriteHandle handle = new FileStorageWriteHandle(
"xFileStorage", "minio-main", "easyflow/", "skill-content/ab", "content.bin");
service.deleteRecoverable(handle);
assertFalse(platform.exists);
assertEquals(1, platform.deleteCalls);
assertEquals(1, delegate.recorderDeleteCalls);
}
/**
* 验证具体平台报告删除失败且物理对象仍存在时必须抛出异常。
*
* @throws Exception 注入测试替身失败
*/
@Test
public void recoverableDeleteFailureIsNotMaskedByRecorder() throws Exception {
RecoverablePlatform platform = new RecoverablePlatform("minio-main", "easyflow/", "https://files/");
platform.exists = true;
platform.deleteSucceeds = false;
RecoverableStorageService delegate = new RecoverableStorageService(platform);
XFIleStorageServiceImpl service = createService(delegate);
FileStorageWriteHandle handle = new FileStorageWriteHandle(
"xFileStorage", "minio-main", "easyflow/", "skill-content/ab", "content.bin");
IllegalStateException exception = assertThrows(
IllegalStateException.class, () -> service.deleteRecoverable(handle));
assertTrue(exception.getMessage().contains("仍存在"));
assertTrue(platform.exists);
assertEquals(0, delegate.recorderDeleteCalls);
}
/**
* 验证物理删除确认成功后recorder 清理异常不会反向伪造物理失败。
*
* @throws Exception 注入测试替身失败
*/
@Test
public void recoverableDeleteIgnoresRecorderCleanupFailureAfterPhysicalSuccess() throws Exception {
RecoverablePlatform platform = new RecoverablePlatform("minio-main", "attachment", "https://files/");
platform.exists = true;
RecoverableStorageService delegate = new RecoverableStorageService(platform);
delegate.recorderDeleteThrows = true;
XFIleStorageServiceImpl service = createService(delegate);
FileStorageWriteHandle handle = new FileStorageWriteHandle(
"xFileStorage", "minio-main", "attachment", "skill-content/ab", "content.bin");
service.deleteRecoverable(handle);
assertFalse(platform.exists);
assertEquals(1, delegate.recorderDeleteCalls);
}
/**
* 验证平台默认 basePath 切换后,支持 FileInfo.basePath 的对象存储仍按历史句柄删除旧对象。
*
* @throws Exception 注入测试替身失败
*/
@Test
public void recoverableDeleteUsesPersistedBasePathAfterConfigurationSwitch() throws Exception {
RecoverablePlatform platform = new RecoverablePlatform("minio-main", "old-root", "https://files/");
RecoverableStorageService delegate = new RecoverableStorageService(platform);
XFIleStorageServiceImpl service = createService(delegate);
FileStorageWriteHandle handle = service.prepareRecoverableWrite("skill-content/ab", "content.bin");
platform.basePath = "new-root";
platform.exists = true;
service.deleteRecoverable(handle);
assertEquals("old-root/skill-content/ab/content.bin", platform.lastDeletedKey);
assertFalse(platform.exists);
}
/**
* 验证上传前 basePath 已切换时 fail-fast避免把预留 locator 写向新目录。
*
* @throws Exception 注入测试替身失败
*/
@Test
public void recoverableSaveRejectsBasePathSwitchBeforeUpload() throws Exception {
RecoverablePlatform platform = new RecoverablePlatform("minio-main", "old-root", "https://files/");
RecoverableStorageService delegate = new RecoverableStorageService(platform);
XFIleStorageServiceImpl service = createService(delegate);
FileStorageWriteHandle handle = service.prepareRecoverableWrite("skill-content/ab", "content.bin");
platform.basePath = "new-root";
IllegalStateException exception = assertThrows(IllegalStateException.class,
() -> service.saveRecoverable(new BytesMultipartFile(new byte[]{1}), handle));
assertTrue(exception.getMessage().contains("基础路径已变化"));
assertNull(delegate.uploadPlatform);
}
/**
* 验证未公开 getBasePath 的 x-file-storage 平台在 prepare 阶段立即失败。
*
* @throws Exception 注入测试替身失败
*/
@Test
public void recoverablePrepareFailsWhenPlatformDoesNotExposeBasePath() throws Exception {
RecoverableStorageService delegate = new RecoverableStorageService(new NoBasePathPlatform("custom"));
XFIleStorageServiceImpl service = createService(delegate);
IllegalStateException exception = assertThrows(
IllegalStateException.class,
() -> service.prepareRecoverableWrite("skill-content", "content.bin"));
assertTrue(exception.getMessage().contains("getBasePath"));
}
/**
* 创建注入指定底层存储替身的服务。
*
* @param delegate 底层存储替身
* @return 待测试服务
* @throws Exception 反射注入失败
*/
private XFIleStorageServiceImpl createService(
org.dromara.x.file.storage.core.FileStorageService delegate) throws Exception {
XFIleStorageServiceImpl service = new XFIleStorageServiceImpl();
Field field = XFIleStorageServiceImpl.class.getDeclaredField("fileStorageService");
field.setAccessible(true);
field.set(service, delegate);
return service;
}
/**
* 支持精确物理操作的 x-file-storage 平台测试替身。
*/
public static final class RecoverablePlatform implements FileStorage {
/** 平台名称。 */
private String platform;
/** 基础路径。 */
private String basePath;
/** URL 域名前缀。 */
private final String domain;
/** 物理存在状态。 */
private boolean exists;
/** 删除是否成功。 */
private boolean deleteSucceeds = true;
/** 删除调用次数。 */
private int deleteCalls;
/** 最后删除的完整对象 key。 */
private String lastDeletedKey;
/**
* 创建平台替身。
*
* @param platform 平台名
* @param basePath 基础路径
* @param domain URL 域名前缀
*/
public RecoverablePlatform(String platform, String basePath, String domain) {
this.platform = platform;
this.basePath = basePath;
this.domain = domain;
}
/**
* 获取公开基础路径。
*
* @return 基础路径
*/
public String getBasePath() { return basePath; }
/**
* 获取公开 URL 域名前缀。
*
* @return 域名前缀
*/
public String getDomain() { return domain; }
/** {@inheritDoc} */
@Override public String getPlatform() { return platform; }
/** {@inheritDoc} */
@Override public void setPlatform(String platform) { this.platform = platform; }
/** {@inheritDoc} */
@Override public boolean save(FileInfo fileInfo, UploadPretreatment pre) { exists = true; return true; }
/** {@inheritDoc} */
@Override
public boolean delete(FileInfo fileInfo) {
deleteCalls++;
lastDeletedKey = getFileKey(fileInfo);
if (deleteSucceeds) {
exists = false;
}
return deleteSucceeds;
}
/** {@inheritDoc} */
@Override public boolean exists(FileInfo fileInfo) { return exists; }
/** {@inheritDoc} */
@Override public void download(FileInfo fileInfo, Consumer<InputStream> consumer) { }
/** {@inheritDoc} */
@Override public void downloadTh(FileInfo fileInfo, Consumer<InputStream> consumer) { }
}
/**
* 不公开基础路径的平台替身。
*/
private static final class NoBasePathPlatform implements FileStorage {
/** 平台名。 */
private String platform;
/**
* 创建平台替身。
*
* @param platform 平台名
*/
private NoBasePathPlatform(String platform) { this.platform = platform; }
/** {@inheritDoc} */
@Override public String getPlatform() { return platform; }
/** {@inheritDoc} */
@Override public void setPlatform(String platform) { this.platform = platform; }
/** {@inheritDoc} */
@Override public boolean save(FileInfo fileInfo, UploadPretreatment pre) { return true; }
/** {@inheritDoc} */
@Override public boolean delete(FileInfo fileInfo) { return true; }
/** {@inheritDoc} */
@Override public boolean exists(FileInfo fileInfo) { return false; }
/** {@inheritDoc} */
@Override public void download(FileInfo fileInfo, Consumer<InputStream> consumer) { }
/** {@inheritDoc} */
@Override public void downloadTh(FileInfo fileInfo, Consumer<InputStream> consumer) { }
}
/**
* 可捕获固定上传参数并提供具体平台的聚合服务替身。
*/
private static final class RecoverableStorageService
extends org.dromara.x.file.storage.core.FileStorageService {
/** 具体平台。 */
private final FileStorage platform;
/** 上传平台。 */
private String uploadPlatform;
/** 上传路径。 */
private String uploadPath;
/** 上传文件名。 */
private String uploadFilename;
/** recorder 删除调用次数。 */
private int recorderDeleteCalls;
/** recorder 删除是否抛出异常。 */
private boolean recorderDeleteThrows;
/**
* 创建聚合服务替身。
*
* @param platform 具体平台
*/
private RecoverableStorageService(FileStorage platform) {
this.platform = platform;
setFileRecorder(new FileRecorder() {
@Override public boolean save(FileInfo fileInfo) { return true; }
@Override public void update(FileInfo fileInfo) { }
@Override public FileInfo getByUrl(String url) { return null; }
@Override public boolean delete(String url) {
recorderDeleteCalls++;
if (recorderDeleteThrows) {
throw new IllegalStateException("recorder unavailable");
}
return false;
}
@Override public void saveFilePart(org.dromara.x.file.storage.core.upload.FilePartInfo filePartInfo) { }
@Override public void deleteFilePartByUploadId(String uploadId) { }
});
}
/** {@inheritDoc} */
@SuppressWarnings("unchecked")
@Override public <T extends FileStorage> T getFileStorage() { return (T) platform; }
/** {@inheritDoc} */
@SuppressWarnings("unchecked")
@Override
public <T extends FileStorage> T getFileStorage(String name) {
return platform.getPlatform().equals(name) ? (T) platform : null;
}
/** {@inheritDoc} */
@Override
public org.dromara.x.file.storage.core.upload.UploadPretreatment of(Object file) {
return new CapturingUploadPretreatment(this);
}
}
/**
* 不访问真实网络、仅捕获上传参数的预处理器。
*/
private static final class CapturingUploadPretreatment
extends org.dromara.x.file.storage.core.upload.UploadPretreatment {
/** 所属聚合服务替身。 */
private final RecoverableStorageService delegate;
/**
* 创建捕获预处理器。
*
* @param delegate 聚合服务替身
*/
private CapturingUploadPretreatment(RecoverableStorageService delegate) {
this.delegate = delegate;
}
/**
* 测试替身不创建 FileWrapper仅保持生产链式调用兼容。
*
* @param contentType 文件媒体类型
* @return 当前预处理器
*/
@Override
public org.dromara.x.file.storage.core.upload.UploadPretreatment setContentType(String contentType) {
return this;
}
/** {@inheritDoc} */
@Override
public FileInfo upload() {
delegate.uploadPlatform = getPlatform();
delegate.uploadPath = getPath();
delegate.uploadFilename = getSaveFilename();
RecoverablePlatform platform = (RecoverablePlatform) delegate.platform;
platform.exists = true;
return new FileInfo()
.setPlatform(getPlatform())
.setBasePath(platform.getBasePath())
.setPath(getPath())
.setFilename(getSaveFilename())
.setUrl(platform.getDomain() + platform.getBasePath() + getPath() + getSaveFilename());
}
}
/**
* 基于字节数组的 MultipartFile 测试替身。
*/
private static final class BytesMultipartFile implements MultipartFile {
/** 文件内容。 */
private final byte[] bytes;
/**
* 创建上传文件替身。
*
* @param bytes 文件内容
*/
private BytesMultipartFile(byte[] bytes) { this.bytes = bytes.clone(); }
/** {@inheritDoc} */
@Override public String getName() { return "file"; }
/** {@inheritDoc} */
@Override public String getOriginalFilename() { return "content.bin"; }
/** {@inheritDoc} */
@Override public String getContentType() { return "application/octet-stream"; }
/** {@inheritDoc} */
@Override public boolean isEmpty() { return bytes.length == 0; }
/** {@inheritDoc} */
@Override public long getSize() { return bytes.length; }
/** {@inheritDoc} */
@Override public byte[] getBytes() { return bytes.clone(); }
/** {@inheritDoc} */
@Override public InputStream getInputStream() { return new ByteArrayInputStream(bytes); }
/** {@inheritDoc} */
@Override public void transferTo(File dest) throws IOException { Files.write(dest.toPath(), bytes); }
}
/**
* 可控制删除结果的 x-file-storage 测试替身。
*/
private static final class DeleteResultStorageService
extends org.dromara.x.file.storage.core.FileStorageService {
private final boolean deleteResult;
private final boolean exists;
private boolean recordExists;
private String lastPath;
/**
* 创建测试替身。
*
* @param deleteResult 删除返回值
* @param exists 物理文件是否存在
* @param recordExists 是否存在文件记录
*/
private DeleteResultStorageService(boolean deleteResult, boolean exists, boolean recordExists) {
this.deleteResult = deleteResult;
this.exists = exists;
this.recordExists = recordExists;
setFileRecorder(new FileRecorder() {
@Override public boolean save(FileInfo fileInfo) { return true; }
@Override public void update(FileInfo fileInfo) { }
@Override public FileInfo getByUrl(String url) {
return DeleteResultStorageService.this.recordExists ? new FileInfo() : null;
}
@Override public boolean delete(String url) {
boolean previous = DeleteResultStorageService.this.recordExists;
DeleteResultStorageService.this.recordExists = false;
return previous;
}
@Override public void saveFilePart(org.dromara.x.file.storage.core.upload.FilePartInfo filePartInfo) { }
@Override public void deleteFilePartByUploadId(String uploadId) { }
});
}
/**
* 返回预设删除结果并记录路径。
*
* @param path 删除路径
* @return 预设结果
*/
@Override
public boolean delete(String path) {
lastPath = path;
return deleteResult;
}
/**
* 返回预设物理存在状态。
*
* @param path 文件路径
* @return 预设存在状态
*/
@Override
public boolean exists(String path) {
return exists;
}
/**
* 返回测试文件记录。
*
* @param url 文件 URL
* @return 记录存在时返回 FileInfo
*/
@Override
public FileInfo getFileInfoByUrl(String url) {
return recordExists ? new FileInfo() : null;
}
/**
* 获取最后一次删除路径。
*
* @return 删除路径
*/
private String getLastPath() {
return lastPath;
}
/**
* 判断测试文件记录是否仍存在。
*
* @return 存在时返回 true
*/
private boolean hasRecord() {
return recordExists;
}
}
}

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());
}
}
}

View File

@@ -39,5 +39,11 @@
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>5.12.0</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -20,6 +20,9 @@ public class ApprovalInstanceBase implements Serializable {
@Id(keyType = KeyType.Generator, value = "snowFlakeId", comment = "主键")
private BigInteger id;
@Column(tenantId = true, comment = "租户ID")
private BigInteger tenantId;
@Column(comment = "流程ID")
private BigInteger flowId;
@@ -82,6 +85,14 @@ public class ApprovalInstanceBase implements Serializable {
this.id = id;
}
public BigInteger getTenantId() {
return tenantId;
}
public void setTenantId(BigInteger tenantId) {
this.tenantId = tenantId;
}
public BigInteger getFlowId() {
return flowId;
}

View File

@@ -31,6 +31,16 @@ public class ApprovalInstancePageVo {
private BigInteger applicantId;
/**
* 申请人展示名称。
*/
private String applicantName;
/**
* 申请人登录账号。
*/
private String applicantAccount;
private Date submittedAt;
private Date finishedAt;
@@ -131,6 +141,42 @@ public class ApprovalInstancePageVo {
this.applicantId = applicantId;
}
/**
* 获取申请人展示名称。
*
* @return 申请人展示名称
*/
public String getApplicantName() {
return applicantName;
}
/**
* 设置申请人展示名称。
*
* @param applicantName 申请人展示名称
*/
public void setApplicantName(String applicantName) {
this.applicantName = applicantName;
}
/**
* 获取申请人登录账号。
*
* @return 申请人登录账号
*/
public String getApplicantAccount() {
return applicantAccount;
}
/**
* 设置申请人登录账号。
*
* @param applicantAccount 申请人登录账号
*/
public void setApplicantAccount(String applicantAccount) {
this.applicantAccount = applicantAccount;
}
public Date getSubmittedAt() {
return submittedAt;
}

View File

@@ -19,6 +19,15 @@ public interface ApprovalActionFacade {
*/
ApprovalActionResult submit(ApprovalSubmitRequest request);
/**
* 判断当前登录用户是否经资源处理器授权查看审批详情。
*
* @param resourceType 资源类型
* @param identifier 资源标识
* @return 允许查看时返回 {@code true}
*/
boolean canAccessApprovalDetail(String resourceType, Object identifier);
/**
* 处理审批通过后的业务回调。
*

View File

@@ -26,6 +26,17 @@ public interface ApprovalSubjectHandler {
*/
ApprovalSubmitRequest buildSubmitRequest(BigInteger resourceId, String actionType, BigInteger operatorId);
/**
* 判断当前登录用户是否可通过资源权限查看审批详情。
*
* <p>审批申请人和任务处理人的访问由审批模块统一判断;该方法只负责补充资源自身的
* 授权口径,避免审批详情绕过资源权限系统。</p>
*
* @param identifier 资源标识
* @return 允许查看时返回 {@code true}
*/
boolean canAccessApprovalDetail(Object identifier);
/**
* 校验资源是否已发布。
*

View File

@@ -49,6 +49,15 @@ public class ApprovalActionFacadeImpl implements ApprovalActionFacade {
return ApprovalActionResult.required(instanceId);
}
/**
* {@inheritDoc}
*/
@Override
public boolean canAccessApprovalDetail(String resourceType, Object identifier) {
ApprovalSubjectHandler handler = getHandler(resourceType);
return handler.canAccessApprovalDetail(identifier);
}
/**
* {@inheritDoc}
*/

View File

@@ -5,6 +5,8 @@ import com.mybatisflex.core.query.QueryWrapper;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.approval.entity.ApprovalInstance;
import tech.easyflow.approval.entity.ApprovalFlowStep;
@@ -25,6 +27,8 @@ import tech.easyflow.approval.mapper.ApprovalLogMapper;
import tech.easyflow.approval.mapper.ApprovalTaskMapper;
import tech.easyflow.approval.service.ApprovalInstanceService;
import tech.easyflow.approval.service.ApprovalMatchService;
import tech.easyflow.system.entity.SysAccount;
import tech.easyflow.system.service.SysAccountService;
import javax.annotation.Resource;
import java.math.BigInteger;
@@ -61,6 +65,9 @@ public class ApprovalInstanceServiceImpl implements ApprovalInstanceService {
@Resource
private ApprovalAssigneeService approvalAssigneeService;
@Resource
private SysAccountService sysAccountService;
@Lazy
@Resource
private ApprovalActionFacade approvalActionFacade;
@@ -71,13 +78,24 @@ public class ApprovalInstanceServiceImpl implements ApprovalInstanceService {
@Override
@Transactional(rollbackFor = Exception.class)
public BigInteger submitApproval(ApprovalSubmitRequest request) {
ApprovalFlowDetailVo flow = approvalMatchService.matchFlow(request);
if (CollectionUtil.isEmpty(flow.getSteps())) {
throw new BusinessException("审批流程未配置步骤");
if (request == null) {
throw new BusinessException("审批请求不能为空");
}
if (request.getApplicantId() == null) {
throw new BusinessException("申请人不能为空");
}
LoginAccount loginAccount = requireCurrentLoginAccount();
if (!loginAccount.getId().equals(request.getApplicantId())) {
throw new BusinessException(403, 403, "不允许以其他账号身份提交审批");
}
SysAccount applicant = requireTenantAccount(request.getApplicantId(), "申请人");
if (!loginAccount.getTenantId().equals(applicant.getTenantId())) {
throw new BusinessException(403, 403, "申请人租户信息与当前登录态不一致");
}
ApprovalFlowDetailVo flow = approvalMatchService.matchFlow(request);
if (CollectionUtil.isEmpty(flow.getSteps())) {
throw new BusinessException("审批流程未配置步骤");
}
List<ApprovalFlowStepVo> steps = new ArrayList<>(flow.getSteps());
steps.sort(Comparator.comparing(ApprovalFlowStepVo::getStepNo));
@@ -85,6 +103,7 @@ public class ApprovalInstanceServiceImpl implements ApprovalInstanceService {
Date now = new Date();
ApprovalInstance instance = new ApprovalInstance();
instance.setTenantId(applicant.getTenantId());
instance.setFlowId(flow.getId());
instance.setFlowVersion(flow.getVersion());
instance.setResourceType(flow.getResourceType());
@@ -124,7 +143,7 @@ public class ApprovalInstanceServiceImpl implements ApprovalInstanceService {
@Override
@Transactional(rollbackFor = Exception.class)
public void approve(BigInteger instanceId, String comment, BigInteger operatorId) {
ApprovalInstance instance = requireActiveInstance(instanceId);
ApprovalInstance instance = requireActiveInstance(instanceId, operatorId);
ApprovalTask currentTask = requireCurrentTask(instanceId, instance.getCurrentStepNo());
assertTaskOperable(currentTask, operatorId);
List<ApprovalFlowStepVo> steps = resolveFrozenSteps(instance);
@@ -161,7 +180,7 @@ public class ApprovalInstanceServiceImpl implements ApprovalInstanceService {
@Override
@Transactional(rollbackFor = Exception.class)
public void reject(BigInteger instanceId, String comment, BigInteger operatorId) {
ApprovalInstance instance = requireActiveInstance(instanceId);
ApprovalInstance instance = requireActiveInstance(instanceId, operatorId);
ApprovalTask currentTask = requireCurrentTask(instanceId, instance.getCurrentStepNo());
assertTaskOperable(currentTask, operatorId);
Date now = new Date();
@@ -184,7 +203,7 @@ public class ApprovalInstanceServiceImpl implements ApprovalInstanceService {
@Override
@Transactional(rollbackFor = Exception.class)
public void revoke(BigInteger instanceId, String comment, BigInteger operatorId) {
ApprovalInstance instance = requireActiveInstance(instanceId);
ApprovalInstance instance = requireActiveInstance(instanceId, operatorId);
// 撤回属于发起人的自助操作,不能沿用审批任务处理人的授权口径。
if (!Objects.equals(instance.getApplicantId(), operatorId)) {
throw new BusinessException(403, 403, "仅审批申请人可以撤回该请求");
@@ -209,7 +228,9 @@ public class ApprovalInstanceServiceImpl implements ApprovalInstanceService {
*/
@Override
public boolean existsActiveInstance(String resourceType, BigInteger resourceId) {
BigInteger tenantId = requireCurrentTenantId();
QueryWrapper queryWrapper = QueryWrapper.create()
.eq(ApprovalInstance::getTenantId, tenantId)
.eq(ApprovalInstance::getResourceType, resourceType)
.eq(ApprovalInstance::getResourceId, resourceId)
.notIn(ApprovalInstance::getStatus,
@@ -224,7 +245,9 @@ public class ApprovalInstanceServiceImpl implements ApprovalInstanceService {
*/
@Override
public ApprovalInstance getById(BigInteger instanceId) {
return approvalInstanceMapper.selectOneById(instanceId);
return approvalInstanceMapper.selectOneByQuery(QueryWrapper.create()
.eq(ApprovalInstance::getId, instanceId)
.eq(ApprovalInstance::getTenantId, requireCurrentTenantId()));
}
private Map<String, Object> buildInstanceSnapshot(ApprovalSubmitRequest request, ApprovalFlowDetailVo flow,
@@ -344,15 +367,17 @@ public class ApprovalInstanceServiceImpl implements ApprovalInstanceService {
approvalLogMapper.insert(log);
}
private ApprovalInstance requireActiveInstance(BigInteger instanceId) {
private ApprovalInstance requireActiveInstance(BigInteger instanceId, BigInteger operatorId) {
if (instanceId == null) {
throw new BusinessException("审批实例ID不能为空");
}
ApprovalInstance instance = approvalInstanceMapper.selectOneByQuery(
QueryWrapper.create().eq(ApprovalInstance::getId, instanceId).forUpdate()
);
SysAccount operator = requireTenantAccount(operatorId, "操作人");
ApprovalInstance instance = approvalInstanceMapper.selectOneByQuery(QueryWrapper.create()
.eq(ApprovalInstance::getId, instanceId)
.eq(ApprovalInstance::getTenantId, operator.getTenantId())
.forUpdate());
if (instance == null) {
throw new BusinessException("审批实例不存在");
throw new BusinessException(404, 404, "审批实例不存在");
}
if (ApprovalInstanceStatus.from(instance.getStatus()).isFinished()) {
throw new BusinessException("审批实例已结束,无法继续处理");
@@ -360,6 +385,49 @@ public class ApprovalInstanceServiceImpl implements ApprovalInstanceService {
return instance;
}
/**
* 读取账号及其稳定租户归属。
*
* @param accountId 账号 ID
* @param accountLabel 账号角色说明
* @return 有效账号
* @throws BusinessException 账号不存在或缺少租户归属时抛出
*/
private SysAccount requireTenantAccount(BigInteger accountId, String accountLabel) {
if (accountId == null) {
throw new BusinessException(accountLabel + "不能为空");
}
SysAccount account = sysAccountService.getById(accountId);
if (account == null || account.getTenantId() == null) {
throw new BusinessException(403, 403, accountLabel + "不存在或租户信息无效");
}
return account;
}
/**
* 获取当前登录账号的租户 ID。
*
* @return 当前租户 ID
* @throws BusinessException 登录态缺少账号或租户信息时抛出
*/
private BigInteger requireCurrentTenantId() {
return requireCurrentLoginAccount().getTenantId();
}
/**
* 获取完整的当前登录账号。
*
* @return 当前登录账号
* @throws BusinessException 登录态缺少账号或租户信息时抛出
*/
private LoginAccount requireCurrentLoginAccount() {
LoginAccount account = SaTokenUtil.getLoginAccount();
if (account == null || account.getId() == null || account.getTenantId() == null) {
throw new BusinessException(401, 401, "未登录或登录态无效");
}
return account;
}
private ApprovalTask requireCurrentTask(BigInteger instanceId, Integer stepNo) {
QueryWrapper queryWrapper = QueryWrapper.create()
.eq(ApprovalTask::getInstanceId, instanceId)

View File

@@ -26,9 +26,11 @@ import tech.easyflow.approval.mapper.ApprovalFlowStepMapper;
import tech.easyflow.approval.mapper.ApprovalInstanceMapper;
import tech.easyflow.approval.mapper.ApprovalLogMapper;
import tech.easyflow.approval.mapper.ApprovalTaskMapper;
import tech.easyflow.approval.service.ApprovalActionFacade;
import tech.easyflow.approval.service.ApprovalAssigneeService;
import tech.easyflow.approval.service.ApprovalQueryService;
import tech.easyflow.system.entity.SysAccount;
import tech.easyflow.system.service.CategoryPermissionService;
import tech.easyflow.system.service.SysAccountService;
import javax.annotation.Resource;
@@ -64,6 +66,12 @@ public class ApprovalQueryServiceImpl implements ApprovalQueryService {
@Resource
private ApprovalAssigneeService approvalAssigneeService;
@Resource
private ApprovalActionFacade approvalActionFacade;
@Resource
private CategoryPermissionService categoryPermissionService;
@Resource
private SysAccountService sysAccountService;
@@ -80,6 +88,7 @@ public class ApprovalQueryServiceImpl implements ApprovalQueryService {
return new Page<>(List.of(), safePageNumber(pageNumber), safePageSize(pageSize), 0L);
}
QueryWrapper queryWrapper = buildBaseQuery(resourceType, actionType, keyword);
queryWrapper.eq(ApprovalInstance::getTenantId, account.getTenantId());
queryWrapper.in(ApprovalInstance::getId, instanceIds);
queryWrapper.in(ApprovalInstance::getStatus, List.of(
ApprovalInstanceStatus.PENDING.getCode(),
@@ -109,6 +118,7 @@ public class ApprovalQueryServiceImpl implements ApprovalQueryService {
return new Page<>(List.of(), safePageNumber(pageNumber), safePageSize(pageSize), 0L);
}
QueryWrapper queryWrapper = buildBaseQuery(resourceType, actionType, keyword);
queryWrapper.eq(ApprovalInstance::getTenantId, account.getTenantId());
queryWrapper.in(ApprovalInstance::getId, instanceIds);
queryWrapper.orderBy("finished_at desc, id desc");
return mapPage(queryWrapper, safePageNumber(pageNumber), safePageSize(pageSize), false, account, Set.of());
@@ -122,6 +132,7 @@ public class ApprovalQueryServiceImpl implements ApprovalQueryService {
Long pageNumber, Long pageSize) {
LoginAccount account = requireLoginAccount();
QueryWrapper queryWrapper = buildBaseQuery(resourceType, actionType, keyword);
queryWrapper.eq(ApprovalInstance::getTenantId, account.getTenantId());
queryWrapper.eq(ApprovalInstance::getApplicantId, account.getId());
queryWrapper.orderBy("submitted_at desc, id desc");
return mapPage(queryWrapper, safePageNumber(pageNumber), safePageSize(pageSize), false, account, Set.of());
@@ -132,10 +143,18 @@ public class ApprovalQueryServiceImpl implements ApprovalQueryService {
*/
@Override
public ApprovalInstanceDetailVo detail(BigInteger instanceId) {
ApprovalInstance instance = approvalInstanceMapper.selectOneById(instanceId);
if (instance == null) {
throw new BusinessException("审批实例不存在");
LoginAccount account = requireLoginAccount();
ApprovalInstance instance = approvalInstanceMapper.selectOneByQuery(QueryWrapper.create()
.eq(ApprovalInstance::getId, instanceId)
.eq(ApprovalInstance::getTenantId, account.getTenantId()));
if (instance == null || !Objects.equals(account.getTenantId(), instance.getTenantId())) {
throw new BusinessException(404, 404, "审批实例不存在");
}
List<ApprovalTask> tasks = approvalTaskMapper.selectListByQuery(
QueryWrapper.create().eq(ApprovalTask::getInstanceId, instanceId));
Set<BigInteger> roleIds = approvalAssigneeService.getAvailableRoleIds(account.getId());
assertDetailAccess(instance, tasks, account, roleIds);
ApprovalInstanceDetailVo detail = new ApprovalInstanceDetailVo();
detail.setId(instance.getId());
detail.setFlowId(instance.getFlowId());
@@ -152,12 +171,10 @@ public class ApprovalQueryServiceImpl implements ApprovalQueryService {
detail.setFinishedAt(instance.getFinishedAt());
detail.setSnapshotJson(instance.getSnapshotJson());
List<ApprovalTask> tasks = approvalTaskMapper.selectListByQuery(
QueryWrapper.create().eq(ApprovalTask::getInstanceId, instanceId));
List<ApprovalLog> logs = approvalLogMapper.selectListByQuery(
QueryWrapper.create().eq(ApprovalLog::getInstanceId, instanceId));
Map<Integer, ApprovalFlowStepVo> frozenStepMap = resolveFrozenStepMap(instance);
Map<BigInteger, SysAccount> accountMap = loadAccountMap(instance, tasks, logs);
Map<BigInteger, SysAccount> accountMap = loadAccountMap(instance, tasks, logs, account.getTenantId());
detail.setApplicantName(resolveAccountName(accountMap.get(instance.getApplicantId())));
detail.setApplicantAccount(resolveAccountLoginName(accountMap.get(instance.getApplicantId())));
@@ -201,8 +218,6 @@ public class ApprovalQueryServiceImpl implements ApprovalQueryService {
})
.collect(Collectors.toList()));
LoginAccount account = requireLoginAccount();
Set<BigInteger> roleIds = approvalAssigneeService.getAvailableRoleIds(account.getId());
boolean active = !ApprovalInstanceStatus.from(instance.getStatus()).isFinished();
boolean canReview = active
&& tasks.stream().anyMatch(item -> item.getStepNo().equals(instance.getCurrentStepNo())
@@ -220,10 +235,11 @@ public class ApprovalQueryServiceImpl implements ApprovalQueryService {
* @param instance 审批实例
* @param tasks 审批任务列表
* @param logs 审批日志列表
* @param tenantId 当前租户 ID
* @return 账号 ID 到账号实体的映射
*/
private Map<BigInteger, SysAccount> loadAccountMap(ApprovalInstance instance, List<ApprovalTask> tasks,
List<ApprovalLog> logs) {
List<ApprovalLog> logs, BigInteger tenantId) {
Set<BigInteger> accountIds = new HashSet<>();
if (instance.getApplicantId() != null) {
accountIds.add(instance.getApplicantId());
@@ -239,7 +255,9 @@ public class ApprovalQueryServiceImpl implements ApprovalQueryService {
if (CollectionUtil.isEmpty(accountIds)) {
return Map.of();
}
return sysAccountService.listByIds(accountIds).stream()
return sysAccountService.list(QueryWrapper.create()
.in(SysAccount::getId, accountIds)
.eq(SysAccount::getTenantId, tenantId)).stream()
.collect(Collectors.toMap(
SysAccount::getId,
account -> account,
@@ -297,6 +315,7 @@ public class ApprovalQueryServiceImpl implements ApprovalQueryService {
boolean pendingMode, LoginAccount account, Set<BigInteger> roleIds) {
Page<ApprovalInstance> page = approvalInstanceMapper.paginate(pageNumber, pageSize, queryWrapper);
List<ApprovalInstance> records = page.getRecords();
Map<BigInteger, SysAccount> applicantAccountMap = loadApplicantAccountMap(records, account.getTenantId());
Set<BigInteger> pendingTaskInstanceIds = pendingMode
? approvalAssigneeService.listPendingInstanceIds(account.getId(), roleIds,
records.stream().map(ApprovalInstance::getId).collect(Collectors.toList()))
@@ -315,6 +334,9 @@ public class ApprovalQueryServiceImpl implements ApprovalQueryService {
item.setSummary(record.getSummary());
item.setApplicationReason(record.getApplicationReason());
item.setApplicantId(record.getApplicantId());
SysAccount applicantAccount = applicantAccountMap.get(record.getApplicantId());
item.setApplicantName(resolveAccountName(applicantAccount));
item.setApplicantAccount(resolveAccountLoginName(applicantAccount));
item.setSubmittedAt(record.getSubmittedAt());
item.setFinishedAt(record.getFinishedAt());
boolean active = !ApprovalInstanceStatus.from(record.getStatus()).isFinished();
@@ -332,6 +354,31 @@ public class ApprovalQueryServiceImpl implements ApprovalQueryService {
return voPage;
}
/**
* 批量加载分页记录中的申请人账号,避免列表逐行查询。
*
* @param records 审批实例分页记录
* @param tenantId 当前租户 ID
* @return 申请人 ID 到账号实体的映射
*/
private Map<BigInteger, SysAccount> loadApplicantAccountMap(List<ApprovalInstance> records, BigInteger tenantId) {
Set<BigInteger> applicantIds = records.stream()
.map(ApprovalInstance::getApplicantId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
if (CollectionUtil.isEmpty(applicantIds)) {
return Map.of();
}
return sysAccountService.list(QueryWrapper.create()
.in(SysAccount::getId, applicantIds)
.eq(SysAccount::getTenantId, tenantId)).stream()
.collect(Collectors.toMap(
SysAccount::getId,
account -> account,
(left, right) -> left,
LinkedHashMap::new));
}
private long safePageNumber(Long pageNumber) {
return pageNumber == null || pageNumber < 1 ? 1L : pageNumber;
}
@@ -342,12 +389,39 @@ public class ApprovalQueryServiceImpl implements ApprovalQueryService {
private LoginAccount requireLoginAccount() {
LoginAccount account = SaTokenUtil.getLoginAccount();
if (account == null) {
if (account == null || account.getId() == null || account.getTenantId() == null) {
throw new BusinessException("当前未登录");
}
return account;
}
/**
* 校验审批详情的主体权限。
*
* @param instance 审批实例
* @param tasks 审批任务
* @param account 当前账号
* @param roleIds 当前账号的有效角色 ID
* @throws BusinessException 当前用户不是申请人、处理人、同租户超管或资源授权者时抛出
*/
private void assertDetailAccess(ApprovalInstance instance,
List<ApprovalTask> tasks,
LoginAccount account,
Set<BigInteger> roleIds) {
if (account.getId().equals(instance.getApplicantId())
|| categoryPermissionService.isSuperAdmin(account)) {
return;
}
boolean taskParticipant = tasks.stream().anyMatch(task -> account.getId().equals(task.getActedBy())
|| ApprovalTaskStatus.PENDING.getCode().equals(task.getStatus())
&& approvalAssigneeService.canHandleTask(task, account.getId(), roleIds));
if (taskParticipant
|| approvalActionFacade.canAccessApprovalDetail(instance.getResourceType(), instance.getResourceId())) {
return;
}
throw new BusinessException(403, 403, "无权限查看该审批实例");
}
private String resolveCurrentStepName(ApprovalInstance instance) {
Map<Integer, ApprovalFlowStepVo> stepMap = resolveFrozenStepMap(instance);
return resolveStepName(stepMap, instance.getCurrentStepNo());

View File

@@ -0,0 +1,158 @@
package tech.easyflow.approval.service.impl;
import com.mybatisflex.core.query.QueryWrapper;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.junit.MockitoJUnitRunner;
import tech.easyflow.approval.entity.ApprovalInstance;
import tech.easyflow.approval.entity.ApprovalLog;
import tech.easyflow.approval.entity.ApprovalTask;
import tech.easyflow.approval.entity.vo.ApprovalSubmitRequest;
import tech.easyflow.approval.enums.ApprovalInstanceStatus;
import tech.easyflow.approval.enums.ApprovalTaskStatus;
import tech.easyflow.approval.mapper.ApprovalInstanceMapper;
import tech.easyflow.approval.mapper.ApprovalLogMapper;
import tech.easyflow.approval.mapper.ApprovalTaskMapper;
import tech.easyflow.approval.service.ApprovalActionFacade;
import tech.easyflow.approval.service.ApprovalMatchService;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.system.entity.SysAccount;
import tech.easyflow.system.service.SysAccountService;
import java.math.BigInteger;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* {@link ApprovalInstanceServiceImpl} 审批提交身份授权测试。
*/
@RunWith(MockitoJUnitRunner.class)
public class ApprovalInstanceServiceImplAccessTest {
@Mock
private ApprovalMatchService approvalMatchService;
@Mock
private SysAccountService sysAccountService;
@Mock
private ApprovalInstanceMapper approvalInstanceMapper;
@Mock
private ApprovalTaskMapper approvalTaskMapper;
@Mock
private ApprovalLogMapper approvalLogMapper;
@Mock
private ApprovalActionFacade approvalActionFacade;
@InjectMocks
private ApprovalInstanceServiceImpl service;
/**
* 验证同租户账号也不能代替当前登录人发起审批。
*/
@Test
public void submitApprovalShouldRejectForgedApplicantBeforeMatchingFlow() {
LoginAccount loginAccount = new LoginAccount();
loginAccount.setId(BigInteger.ONE);
loginAccount.setTenantId(BigInteger.valueOf(42));
ApprovalSubmitRequest request = new ApprovalSubmitRequest();
request.setApplicantId(BigInteger.TWO);
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(loginAccount);
BusinessException exception = assertThrows(BusinessException.class,
() -> service.submitApproval(request));
assertEquals(403, exception.getHttpStatus());
}
verify(sysAccountService, never()).getById(BigInteger.TWO);
verify(approvalMatchService, never()).matchFlow(request);
}
/**
* 验证申请人可撤回进行中的审批,并同步结束当前任务与恢复资源状态。
*/
@Test
public void revokeShouldCompleteCurrentTaskForApplicant() {
BigInteger applicantId = BigInteger.valueOf(7);
BigInteger tenantId = BigInteger.valueOf(42);
BigInteger instanceId = BigInteger.valueOf(101);
SysAccount applicant = tenantAccount(applicantId, tenantId);
ApprovalInstance instance = activeInstance(instanceId, applicantId, tenantId);
ApprovalTask task = new ApprovalTask();
task.setInstanceId(instanceId);
task.setStepNo(1);
task.setStatus(ApprovalTaskStatus.PENDING.getCode());
when(sysAccountService.getById(applicantId)).thenReturn(applicant);
when(approvalInstanceMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(instance);
when(approvalTaskMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(task);
service.revoke(instanceId, "内容需要调整", applicantId);
assertEquals(ApprovalInstanceStatus.REVOKED.getCode(), instance.getStatus());
assertNotNull(instance.getFinishedAt());
assertEquals(ApprovalTaskStatus.REVOKED.getCode(), task.getStatus());
assertEquals(applicantId, task.getActedBy());
assertEquals("内容需要调整", task.getComment());
verify(approvalLogMapper).insert(any(ApprovalLog.class));
verify(approvalActionFacade).handleRevoked(instance, applicantId, "内容需要调整");
}
/**
* 验证非申请人即使属于同一租户也不能撤回审批。
*/
@Test
public void revokeShouldRejectSameTenantNonApplicant() {
BigInteger applicantId = BigInteger.valueOf(7);
BigInteger operatorId = BigInteger.valueOf(8);
BigInteger tenantId = BigInteger.valueOf(42);
BigInteger instanceId = BigInteger.valueOf(101);
when(sysAccountService.getById(operatorId)).thenReturn(tenantAccount(operatorId, tenantId));
when(approvalInstanceMapper.selectOneByQuery(any(QueryWrapper.class)))
.thenReturn(activeInstance(instanceId, applicantId, tenantId));
BusinessException exception = assertThrows(
BusinessException.class,
() -> service.revoke(instanceId, "尝试撤回", operatorId)
);
assertEquals(403, exception.getHttpStatus());
verify(approvalTaskMapper, never()).selectOneByQuery(any(QueryWrapper.class));
verify(approvalActionFacade, never())
.handleRevoked(any(ApprovalInstance.class), any(BigInteger.class), any(String.class));
}
private ApprovalInstance activeInstance(BigInteger instanceId, BigInteger applicantId, BigInteger tenantId) {
ApprovalInstance instance = new ApprovalInstance();
instance.setId(instanceId);
instance.setApplicantId(applicantId);
instance.setTenantId(tenantId);
instance.setCurrentStepNo(1);
instance.setStatus(ApprovalInstanceStatus.PENDING.getCode());
return instance;
}
private SysAccount tenantAccount(BigInteger accountId, BigInteger tenantId) {
SysAccount account = new SysAccount();
account.setId(accountId);
account.setTenantId(tenantId);
return account;
}
}

View File

@@ -0,0 +1,72 @@
package tech.easyflow.approval.service.impl;
import com.mybatisflex.core.query.QueryWrapper;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRunner;
import tech.easyflow.approval.entity.ApprovalInstance;
import tech.easyflow.approval.enums.ApprovalInstanceStatus;
import tech.easyflow.approval.mapper.ApprovalInstanceMapper;
import tech.easyflow.approval.mapper.ApprovalTaskMapper;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.system.entity.SysAccount;
import tech.easyflow.system.service.SysAccountService;
import java.math.BigInteger;
import java.util.Locale;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* {@link ApprovalInstanceServiceImpl} 审批决策并发互斥测试。
*/
@RunWith(MockitoJUnitRunner.class)
public class ApprovalInstanceServiceImplConcurrencyTest {
@Mock
private ApprovalInstanceMapper approvalInstanceMapper;
@Mock
private ApprovalTaskMapper approvalTaskMapper;
@Mock
private SysAccountService sysAccountService;
@InjectMocks
private ApprovalInstanceServiceImpl service;
/**
* 审批实例与当前任务必须在状态判断前加行锁,避免重复执行同一决策。
*/
@Test
public void approvalDecisionLocksInstanceAndCurrentTask() {
BigInteger instanceId = BigInteger.valueOf(101);
BigInteger tenantId = BigInteger.valueOf(42);
ApprovalInstance instance = new ApprovalInstance();
instance.setId(instanceId);
instance.setTenantId(tenantId);
instance.setStatus(ApprovalInstanceStatus.PENDING.getCode());
instance.setCurrentStepNo(1);
SysAccount operator = new SysAccount();
operator.setId(BigInteger.ONE);
operator.setTenantId(tenantId);
when(sysAccountService.getById(BigInteger.ONE)).thenReturn(operator);
when(approvalInstanceMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(instance);
when(approvalTaskMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(null);
assertThrows(BusinessException.class,
() -> service.approve(instanceId, "通过", BigInteger.ONE));
ArgumentCaptor<QueryWrapper> instanceQuery = ArgumentCaptor.forClass(QueryWrapper.class);
ArgumentCaptor<QueryWrapper> taskQuery = ArgumentCaptor.forClass(QueryWrapper.class);
verify(approvalInstanceMapper).selectOneByQuery(instanceQuery.capture());
verify(approvalTaskMapper).selectOneByQuery(taskQuery.capture());
assertTrue(instanceQuery.getValue().toSQL().toLowerCase(Locale.ROOT).contains("for update"));
assertTrue(instanceQuery.getValue().toSQL().toLowerCase(Locale.ROOT).contains("tenant_id"));
assertTrue(taskQuery.getValue().toSQL().toLowerCase(Locale.ROOT).contains("for update"));
}
}

View File

@@ -0,0 +1,55 @@
package tech.easyflow.approval.service.impl;
import org.junit.Test;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.Assert.assertTrue;
/**
* V32 审批实例租户迁移契约测试。
*/
public class ApprovalInstanceTenantMigrationContractTest {
/**
* 验证历史实例从申请人账号回填租户,且空租户会阻断迁移。
*
* @throws Exception 迁移文件不可读时抛出
*/
@Test
public void migrationShouldBackfillAndGuardApprovalTenant() throws Exception {
String sql = migrationSql();
assertTrue(sql.contains("ADD COLUMN `tenant_id` BIGINT UNSIGNED NULL"));
assertTrue(sql.contains("LEFT JOIN `tb_sys_account` applicant ON applicant.`id` = approval.`applicant_id`"));
assertTrue(sql.contains("applicant.`id` IS NULL OR applicant.`tenant_id` IS NULL"));
assertTrue(sql.contains("JOIN `tb_sys_account` applicant ON applicant.`id` = approval.`applicant_id`"));
assertTrue(sql.contains("SET approval.`tenant_id` = applicant.`tenant_id`"));
assertTrue(sql.contains("tmp_approval_instance_tenant_guard"));
assertTrue(sql.indexOf("tmp_approval_instance_tenant_guard") < sql.indexOf("ADD COLUMN `tenant_id`"));
assertTrue(sql.contains("MODIFY COLUMN `tenant_id` BIGINT UNSIGNED NOT NULL"));
assertTrue(sql.contains("`tenant_id`, `status`, `submitted_at`"));
}
/**
* 读取工作区中的 V32 MySQL 迁移。
*
* @return 迁移 SQL
* @throws Exception 迁移文件不存在或不可读时抛出
*/
private String migrationSql() throws Exception {
Path root = Path.of(System.getProperty("maven.multiModuleProjectDirectory",
Path.of(System.getProperty("user.dir")).toAbsolutePath().toString()));
while (root != null) {
Path migration = root.resolve("easyflow-starter/easyflow-starter-all/src/main/resources/"
+ "db/migration/mysql/V32__mysql_approval_instance_tenant.sql");
if (Files.isRegularFile(migration)) {
return Files.readString(migration, StandardCharsets.UTF_8);
}
root = root.getParent();
}
throw new IllegalStateException("找不到 V32 审批实例租户迁移");
}
}

View File

@@ -0,0 +1,283 @@
package tech.easyflow.approval.service.impl;
import com.mybatisflex.core.paginate.Page;
import com.mybatisflex.core.query.QueryWrapper;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.junit.MockitoJUnitRunner;
import tech.easyflow.approval.entity.ApprovalInstance;
import tech.easyflow.approval.entity.ApprovalLog;
import tech.easyflow.approval.entity.ApprovalTask;
import tech.easyflow.approval.entity.vo.ApprovalInstanceDetailVo;
import tech.easyflow.approval.entity.vo.ApprovalInstancePageVo;
import tech.easyflow.approval.enums.ApprovalAssigneeType;
import tech.easyflow.approval.enums.ApprovalEventType;
import tech.easyflow.approval.enums.ApprovalInstanceStatus;
import tech.easyflow.approval.enums.ApprovalTaskStatus;
import tech.easyflow.approval.mapper.ApprovalFlowStepMapper;
import tech.easyflow.approval.mapper.ApprovalInstanceMapper;
import tech.easyflow.approval.mapper.ApprovalLogMapper;
import tech.easyflow.approval.mapper.ApprovalTaskMapper;
import tech.easyflow.approval.service.ApprovalActionFacade;
import tech.easyflow.approval.service.ApprovalAssigneeService;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.system.entity.SysAccount;
import tech.easyflow.system.service.CategoryPermissionService;
import tech.easyflow.system.service.SysAccountService;
import java.math.BigInteger;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.mockStatic;
/**
* {@link ApprovalQueryServiceImpl} 审批详情租户和主体授权回归测试。
*/
@RunWith(MockitoJUnitRunner.class)
public class ApprovalQueryServiceImplAccessTest {
private static final BigInteger INSTANCE_ID = BigInteger.valueOf(101);
private static final BigInteger RESOURCE_ID = BigInteger.valueOf(501);
private static final BigInteger TENANT_ID = BigInteger.valueOf(42);
@Mock
private ApprovalInstanceMapper approvalInstanceMapper;
@Mock
private ApprovalTaskMapper approvalTaskMapper;
@Mock
private ApprovalLogMapper approvalLogMapper;
@Mock
private ApprovalFlowStepMapper approvalFlowStepMapper;
@Mock
private ApprovalAssigneeService approvalAssigneeService;
@Mock
private ApprovalActionFacade approvalActionFacade;
@Mock
private CategoryPermissionService categoryPermissionService;
@Mock
private SysAccountService sysAccountService;
@InjectMocks
private ApprovalQueryServiceImpl service;
/**
* 验证详情查询显式带租户条件,并拒绝 Mapper 异常返回的跨租户实例。
*/
@Test
public void detailShouldRejectCrossTenantInstanceBeforeReadingSnapshot() {
LoginAccount account = account(7, 42);
ApprovalInstance instance = instance(99, 99);
when(approvalInstanceMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(instance);
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
BusinessException exception = assertThrows(BusinessException.class,
() -> service.detail(INSTANCE_ID));
assertEquals(404, exception.getHttpStatus());
}
ArgumentCaptor<QueryWrapper> query = ArgumentCaptor.forClass(QueryWrapper.class);
verify(approvalInstanceMapper).selectOneByQuery(query.capture());
assertTrue(query.getValue().toSQL().toLowerCase(Locale.ROOT).contains("tenant_id"));
verify(approvalTaskMapper, never()).selectListByQuery(any(QueryWrapper.class));
verify(approvalLogMapper, never()).selectListByQuery(any(QueryWrapper.class));
}
/**
* 验证同租户普通用户不能仅凭审批查询操作权限读取完整资源快照。
*/
@Test
public void detailShouldRejectSameTenantNonParticipant() {
LoginAccount account = account(7, 42);
ApprovalInstance instance = instance(8, 42);
when(approvalInstanceMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(instance);
when(approvalTaskMapper.selectListByQuery(any(QueryWrapper.class))).thenReturn(List.of());
when(approvalAssigneeService.getAvailableRoleIds(account.getId())).thenReturn(Set.of());
when(approvalActionFacade.canAccessApprovalDetail("SKILL", RESOURCE_ID)).thenReturn(false);
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
BusinessException exception = assertThrows(BusinessException.class,
() -> service.detail(INSTANCE_ID));
assertEquals(403, exception.getHttpStatus());
}
verify(approvalLogMapper, never()).selectListByQuery(any(QueryWrapper.class));
}
/**
* 验证申请人仍可读取自己发起的审批快照。
*/
@Test
public void detailShouldAllowApplicant() {
LoginAccount account = account(7, 42);
ApprovalInstance instance = instance(7, 42);
Map<String, Object> snapshot = instance.getSnapshotJson();
stubAuthorizedDetail(instance, account, List.of());
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
ApprovalInstanceDetailVo detail = service.detail(INSTANCE_ID);
assertSame(snapshot, detail.getSnapshotJson());
assertFalse(detail.isCanApprove());
assertFalse(detail.isCanReject());
assertTrue(detail.isCanRevoke());
}
}
/**
* 验证当前待办处理人可查看审批详情。
*/
@Test
public void detailShouldAllowCurrentTaskHandler() {
LoginAccount account = account(7, 42);
ApprovalInstance instance = instance(8, 42);
ApprovalTask task = task(ApprovalTaskStatus.PENDING.getCode(), null);
task.setAssigneeType(ApprovalAssigneeType.USER.getCode());
task.setAssigneeTargetId(account.getId());
stubAuthorizedDetail(instance, account, List.of(task));
when(approvalAssigneeService.canHandleTask(task, account.getId(), Set.of())).thenReturn(true);
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
ApprovalInstanceDetailVo detail = service.detail(INSTANCE_ID);
assertEquals(INSTANCE_ID, detail.getId());
assertTrue(detail.isCanApprove());
assertTrue(detail.isCanReject());
assertFalse(detail.isCanRevoke());
}
}
/**
* 验证实际处理过历史步骤的用户仍可查看审批详情。
*/
@Test
public void detailShouldAllowHistoricalActor() {
LoginAccount account = account(7, 42);
ApprovalInstance instance = instance(8, 42);
ApprovalTask task = task(ApprovalTaskStatus.APPROVED.getCode(), account.getId());
stubAuthorizedDetail(instance, account, List.of(task));
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
assertEquals(INSTANCE_ID, service.detail(INSTANCE_ID).getId());
}
}
/**
* 验证审批说明在详情、审批任务和提交日志中完整透传。
*/
@Test
public void detailShouldExposeApplicationReasonAcrossRelatedViews() {
LoginAccount account = account(7, 42);
ApprovalInstance instance = instance(7, 42);
instance.setApplicationReason("发布新的问答流程");
ApprovalTask task = task(ApprovalTaskStatus.PENDING.getCode(), null);
ApprovalLog log = new ApprovalLog();
log.setEventType(ApprovalEventType.SUBMITTED.getCode());
stubAuthorizedDetail(instance, account, List.of(task));
when(approvalLogMapper.selectListByQuery(any(QueryWrapper.class))).thenReturn(List.of(log));
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
ApprovalInstanceDetailVo detail = service.detail(INSTANCE_ID);
assertEquals("发布新的问答流程", detail.getApplicationReason());
assertEquals("发布新的问答流程", detail.getTasks().get(0).getApplicationReason());
assertEquals("发布新的问答流程", detail.getLogs().get(0).getApplicationReason());
}
}
/**
* 验证审批分页列表返回申请人填写的审批说明和账号信息。
*/
@Test
public void initiatedPageShouldExposeApplicationReasonAndApplicant() {
LoginAccount account = account(7, 42);
ApprovalInstance instance = instance(7, 42);
instance.setApplicationReason("发布新的问答流程");
SysAccount applicant = new SysAccount();
applicant.setId(account.getId());
applicant.setNickname("陈子默");
applicant.setLoginName("czm");
Page<ApprovalInstance> page = new Page<>(List.of(instance), 1L, 10L, 1L);
when(approvalInstanceMapper.paginate(anyLong(), anyLong(), any(QueryWrapper.class))).thenReturn(page);
when(sysAccountService.list(any(QueryWrapper.class))).thenReturn(List.of(applicant));
try (MockedStatic<SaTokenUtil> saToken = mockStatic(SaTokenUtil.class)) {
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
ApprovalInstancePageVo item = service.initiatedPage(null, null, null, 1L, 10L)
.getRecords()
.get(0);
assertEquals("发布新的问答流程", item.getApplicationReason());
assertEquals("陈子默", item.getApplicantName());
assertEquals("czm", item.getApplicantAccount());
assertTrue(item.isCanRevoke());
assertFalse(item.isCanApprove());
assertFalse(item.isCanReject());
}
}
private void stubAuthorizedDetail(ApprovalInstance instance, LoginAccount account, List<ApprovalTask> tasks) {
when(approvalInstanceMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(instance);
when(approvalTaskMapper.selectListByQuery(any(QueryWrapper.class))).thenReturn(tasks);
when(approvalAssigneeService.getAvailableRoleIds(account.getId())).thenReturn(Set.of());
when(approvalLogMapper.selectListByQuery(any(QueryWrapper.class))).thenReturn(List.of());
when(sysAccountService.list(any(QueryWrapper.class))).thenReturn(List.of());
}
private ApprovalInstance instance(long applicantId, long tenantId) {
ApprovalInstance instance = new ApprovalInstance();
instance.setId(INSTANCE_ID);
instance.setTenantId(BigInteger.valueOf(tenantId));
instance.setFlowId(BigInteger.valueOf(301));
instance.setFlowVersion(1);
instance.setResourceType("SKILL");
instance.setResourceId(RESOURCE_ID);
instance.setActionType("PUBLISH");
instance.setStatus(ApprovalInstanceStatus.PENDING.getCode());
instance.setCurrentStepNo(1);
instance.setApplicantId(BigInteger.valueOf(applicantId));
instance.setSnapshotJson(Map.of(
"resourceSnapshot", Map.of("skillContent", "private prompt"),
"steps", List.of(Map.of(
"stepNo", 1,
"stepName", "审核",
"assigneeType", ApprovalAssigneeType.USER.getCode(),
"assigneeTargetId", 7))));
return instance;
}
private ApprovalTask task(String status, BigInteger actedBy) {
ApprovalTask task = new ApprovalTask();
task.setInstanceId(INSTANCE_ID);
task.setStepNo(1);
task.setStatus(status);
task.setActedBy(actedBy);
return task;
}
private LoginAccount account(long accountId, long tenantId) {
LoginAccount account = new LoginAccount();
account.setId(BigInteger.valueOf(accountId));
account.setTenantId(BigInteger.valueOf(tenantId));
return account;
}
}

View File

@@ -37,6 +37,10 @@
<groupId>tech.easyflow</groupId>
<artifactId>easyflow-common-file-storage</artifactId>
</dependency>
<dependency>
<groupId>tech.easyflow</groupId>
<artifactId>easyflow-common-cache</artifactId>
</dependency>
<dependency>
<groupId>com.mybatis-flex</groupId>
<artifactId>mybatis-flex-spring-boot3-starter</artifactId>
@@ -49,11 +53,26 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-compress</artifactId>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>5.12.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,124 @@
package tech.easyflow.skill.capability;
import com.mybatisflex.core.service.IService;
import tech.easyflow.skill.entity.SkillCapabilityBinding;
import tech.easyflow.skill.enums.SkillCapabilityType;
import tech.easyflow.skill.validation.SkillValidationResult;
import java.math.BigInteger;
import java.util.List;
import java.util.Map;
/**
* Skill 能力绑定业务服务。
*/
public interface SkillCapabilityBindingService extends IService<SkillCapabilityBinding> {
/**
* 查询当前用户可查看的 Skill 能力绑定。
*
* @param skillId Skill ID
* @return 有序绑定列表
*/
List<SkillCapabilityBinding> listBindings(BigInteger skillId);
/**
* 查询面向管理端读取接口的安全绑定,并按当前用户 MANAGE 权限隐藏内部目标 ID。
*
* @param skillId Skill ID
* @return 有序安全绑定列表
*/
List<SkillCapabilityBinding> listVisibleBindings(BigInteger skillId);
/**
* 原子替换 Skill 能力绑定。
*
* @param skillId Skill ID
* @param bindings 新绑定列表
* @return 保存后的绑定列表
*/
List<SkillCapabilityBinding> replaceBindings(BigInteger skillId, List<SkillCapabilityBinding> bindings);
/**
* 按客户端读取到的能力 hash 原子替换绑定,防止多标签页相互覆盖。
*
* @param skillId Skill ID
* @param bindings 新绑定列表
* @param expectedCapabilityHash 客户端读取到的能力 hash
* @return 保存后的绑定列表
*/
List<SkillCapabilityBinding> replaceBindings(BigInteger skillId,
List<SkillCapabilityBinding> bindings,
String expectedCapabilityHash);
/**
* 校验待保存或现有绑定。
*
* @param skillId Skill ID
* @param bindings 可选待校验绑定,为空时校验已保存绑定
* @param publishValidation 是否执行发布级实时工具解析
* @return 结构化校验结果
*/
SkillValidationResult validateBindings(BigInteger skillId,
List<SkillCapabilityBinding> bindings,
boolean publishValidation);
/**
* 校验增强包导入预览中的能力绑定。
*
* <p>该入口不读取或写入 Skill 业务数据,也不要求已有 Skill 权限。未映射目标仅保留给
* 导入映射步骤处理;已经映射的目标仍会校验当前操作者的使用权限和可用状态。</p>
*
* @param bindings 从增强包 manifest 还原的能力绑定
* @return 结构化校验结果
*/
SkillValidationResult validateImportBindings(List<SkillCapabilityBinding> bindings);
/**
* 查询可绑定能力候选项。
*
* @param capabilityType 能力类型
* @param keyword 关键词
* @return 候选列表
*/
List<SkillCapabilityCandidate> listCandidates(SkillCapabilityType capabilityType, String keyword);
/**
* 按需获取 MCP 工具清单。
*
* @param targetId MCP ID
* @return MCP 候选详情
*/
SkillCapabilityCandidate getMcpTools(BigInteger targetId);
/**
* 构建经过发布级校验的安全快照。
*
* @param skillId Skill ID
* @return 不含凭据的能力快照
*/
List<Map<String, Object>> buildPublishSnapshot(BigInteger skillId);
/**
* 计算当前能力配置 hash。
*
* @param bindings 能力绑定
* @return SHA-256 hash
*/
String calculateHash(List<SkillCapabilityBinding> bindings);
/**
* 基于数据库原始绑定计算 hash不暴露可能被展示边界脱敏的历史配置。
*
* @param skillId Skill ID
* @return SHA-256 hash
*/
String calculateStoredHash(BigInteger skillId);
/**
* 删除 Skill 的全部能力绑定。
*
* @param skillId Skill ID
*/
void removeBySkillId(BigInteger skillId);
}

View File

@@ -0,0 +1,990 @@
package tech.easyflow.skill.capability;
import com.easyagents.skill.util.SkillHashes;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mybatisflex.core.query.QueryWrapper;
import com.mybatisflex.spring.service.impl.ServiceImpl;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import tech.easyflow.ai.permission.McpAccessPermissionChecker;
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.skill.entity.SkillCapabilityBinding;
import tech.easyflow.skill.enums.SkillCapabilityExecutionMode;
import tech.easyflow.skill.enums.SkillCapabilitySelectionMode;
import tech.easyflow.skill.enums.SkillCapabilityType;
import tech.easyflow.skill.mapper.SkillCapabilityBindingMapper;
import tech.easyflow.skill.mapper.SkillMapper;
import tech.easyflow.skill.security.SkillCredentialValueGuard;
import tech.easyflow.skill.security.SkillPortableTargetSanitizer;
import tech.easyflow.skill.security.SkillSensitiveConfigSanitizer;
import tech.easyflow.skill.validation.SkillValidationIssue;
import tech.easyflow.skill.validation.SkillValidationResult;
import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.enums.ResourceAction;
import tech.easyflow.system.service.ResourceAccessService;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
/**
* Skill 能力绑定业务服务实现。
*/
@Service
public class SkillCapabilityBindingServiceImpl
extends ServiceImpl<SkillCapabilityBindingMapper, SkillCapabilityBinding>
implements SkillCapabilityBindingService {
private static final Pattern RUNTIME_NAME_PATTERN = Pattern.compile("^[A-Za-z][A-Za-z0-9_-]{0,63}$");
private static final Pattern MCP_TOOL_NAME_PATTERN = Pattern.compile("^[A-Za-z][A-Za-z0-9_.-]{0,127}$");
private static final int MAX_BINDINGS = 200;
private static final int MAX_SELECTED_TOOLS = 200;
private static final int MAX_CONFIG_BYTES = 4096;
private final SkillMapper skillMapper;
private final SkillCapabilityTargetAccessService targetAccessService;
private final McpAccessPermissionChecker mcpAccessPermissionChecker;
private final ResourceAccessService resourceAccessService;
private final ObjectMapper objectMapper;
/**
* 创建 Skill 能力绑定服务。
*
* @param skillMapper Skill Mapper
* @param targetAccessService 目标授权服务
* @param mcpAccessPermissionChecker MCP 查询与使用权限检查器
* @param resourceAccessService Skill 资源授权服务
* @param objectMapper JSON 映射器
*/
public SkillCapabilityBindingServiceImpl(SkillMapper skillMapper,
SkillCapabilityTargetAccessService targetAccessService,
McpAccessPermissionChecker mcpAccessPermissionChecker,
ResourceAccessService resourceAccessService,
ObjectMapper objectMapper) {
this.skillMapper = skillMapper;
this.targetAccessService = targetAccessService;
this.mcpAccessPermissionChecker = mcpAccessPermissionChecker;
this.resourceAccessService = resourceAccessService;
this.objectMapper = objectMapper;
}
/**
* {@inheritDoc}
*/
@Override
public List<SkillCapabilityBinding> listBindings(BigInteger skillId) {
return listBindings(skillId, false);
}
/**
* {@inheritDoc}
*/
@Override
public List<SkillCapabilityBinding> listVisibleBindings(BigInteger skillId) {
return listBindings(skillId, true);
}
/**
* 查询并填充绑定展示状态,可选按 MANAGE 权限移除内部目标标识。
*
* @param skillId Skill ID
* @param redactReadOnlyTargets 是否为只读调用方脱敏
* @return 有序绑定列表
*/
private List<SkillCapabilityBinding> listBindings(BigInteger skillId, boolean redactReadOnlyTargets) {
Skill skill = requireSkill(skillId);
resourceAccessService.assertAccess(CategoryResourceType.SKILL, skill, ResourceAction.READ, "无权限查看 Skill 能力绑定");
boolean manageable = !redactReadOnlyTargets
|| resourceAccessService.canAccess(CategoryResourceType.SKILL, skill, ResourceAction.MANAGE);
List<SkillCapabilityBinding> bindings = listRaw(skillId);
for (SkillCapabilityBinding binding : bindings) {
enrichDisplayStatus(binding);
boolean targetPermissionDenied = "NO_PERMISSION".equals(binding.getTargetStatus());
if (!manageable || targetPermissionDenied) {
binding.setTargetId(null);
if (targetPermissionDenied) {
// Skill 管理权限不能替代目标能力权限;目标不可读时只保留可删除的绑定外壳。
binding.setTargetLogicalRef(null);
binding.setTargetName(null);
binding.setSelectedToolNamesJson(List.of());
binding.setResolvedToolNames(List.of());
}
}
sanitizeBindingForExposure(binding);
}
return bindings;
}
/**
* {@inheritDoc}
*/
@Override
@Transactional(rollbackFor = Exception.class)
public List<SkillCapabilityBinding> replaceBindings(BigInteger skillId, List<SkillCapabilityBinding> bindings) {
return replaceBindingsInternal(skillId, bindings, null);
}
/**
* {@inheritDoc}
*/
@Override
@Transactional(rollbackFor = Exception.class)
public List<SkillCapabilityBinding> replaceBindings(BigInteger skillId,
List<SkillCapabilityBinding> bindings,
String expectedCapabilityHash) {
if (expectedCapabilityHash == null || !expectedCapabilityHash.matches("^[a-f0-9]{64}$")) {
throw new BusinessException(409, 4093, "缺少或无效的能力配置版本,请重新加载后再保存");
}
return replaceBindingsInternal(skillId, bindings, expectedCapabilityHash);
}
private List<SkillCapabilityBinding> replaceBindingsInternal(BigInteger skillId,
List<SkillCapabilityBinding> bindings,
String expectedCapabilityHash) {
Skill skill = requireSkill(skillId, true);
resourceAccessService.assertAccess(CategoryResourceType.SKILL, skill, ResourceAction.MANAGE, "无权限管理 Skill 能力绑定");
if (expectedCapabilityHash != null && !expectedCapabilityHash.equals(skill.getCapabilityHash())) {
throw new BusinessException(409, 4093, "能力配置已被其他操作更新,请重新加载后合并");
}
List<SkillCapabilityBinding> safeBindings = bindings == null ? new ArrayList<>() : new ArrayList<>(bindings);
if (safeBindings.size() > MAX_BINDINGS) {
throw new BusinessException("单个 Skill 最多绑定 " + MAX_BINDINGS + " 项能力");
}
SkillValidationResult validation = validateInternal(safeBindings, ValidationMode.SAVE);
assertNoErrors(validation);
QueryWrapper deleteQuery = QueryWrapper.create()
.eq(SkillCapabilityBinding::getTenantId, skill.getTenantId())
.eq(SkillCapabilityBinding::getSkillId, skillId);
long existingBindingCount = count(deleteQuery);
if (existingBindingCount > 0 && getMapper().deleteByQuery(deleteQuery) != existingBindingCount) {
throw new BusinessException(500, 500, "替换 Skill 能力绑定失败,请稍后重试");
}
LoginAccount account = requireAccount();
Date now = new Date();
for (int index = 0; index < safeBindings.size(); index++) {
SkillCapabilityBinding binding = safeBindings.get(index);
binding.setId(null);
binding.setTenantId(skill.getTenantId());
binding.setSkillId(skillId);
binding.setSortNo(index);
binding.setCreated(now);
binding.setCreatedBy(account.getId());
binding.setModified(now);
binding.setModifiedBy(account.getId());
}
if (!safeBindings.isEmpty()) {
if (!saveBatch(safeBindings)) {
throw new BusinessException(500, 500, "保存 Skill 能力绑定失败,请稍后重试");
}
}
Skill update = new Skill();
update.setId(skillId);
update.setCapabilityCount(safeBindings.size());
update.setCapabilityHash(calculateHash(safeBindings));
update.setModified(now);
update.setModifiedBy(account.getId());
QueryWrapper updateQuery = QueryWrapper.create()
.eq(Skill::getId, skillId)
.eq(Skill::getTenantId, skill.getTenantId());
if (expectedCapabilityHash != null) {
updateQuery.eq(Skill::getCapabilityHash, expectedCapabilityHash);
}
if (skillMapper.updateByQuery(update, updateQuery) != 1) {
if (expectedCapabilityHash != null) {
throw new BusinessException(409, 4093, "能力配置已被其他操作更新,请重新加载后合并");
}
throw new BusinessException(500, 500, "更新 Skill 能力摘要失败,请稍后重试");
}
return listBindings(skillId);
}
/**
* {@inheritDoc}
*/
@Override
public SkillValidationResult validateBindings(BigInteger skillId,
List<SkillCapabilityBinding> bindings,
boolean publishValidation) {
Skill skill = requireSkill(skillId);
resourceAccessService.assertAccess(CategoryResourceType.SKILL, skill,
bindings == null ? ResourceAction.READ : ResourceAction.MANAGE,
bindings == null ? "无权限校验 Skill 能力绑定" : "无权限校验待保存的 Skill 能力绑定");
return validateInternal(bindings == null ? listRaw(skillId) : bindings,
publishValidation ? ValidationMode.PUBLISH : ValidationMode.SAVE);
}
/**
* {@inheritDoc}
*/
@Override
public SkillValidationResult validateImportBindings(List<SkillCapabilityBinding> bindings) {
return validateInternal(bindings == null ? List.of() : bindings, ValidationMode.IMPORT_PREVIEW);
}
/**
* {@inheritDoc}
*/
@Override
public List<SkillCapabilityCandidate> listCandidates(SkillCapabilityType capabilityType, String keyword) {
return targetAccessService.listCandidates(capabilityType, keyword);
}
/**
* {@inheritDoc}
*/
@Override
public SkillCapabilityCandidate getMcpTools(BigInteger targetId) {
return targetAccessService.getMcpTools(targetId);
}
/**
* {@inheritDoc}
*/
@Override
public List<Map<String, Object>> buildPublishSnapshot(BigInteger skillId) {
List<SkillCapabilityBinding> bindings = listRaw(skillId);
ValidatedBindings validated = validateInternalWithTargets(bindings, ValidationMode.PUBLISH);
assertNoErrors(validated.result());
List<Map<String, Object>> snapshots = new ArrayList<>();
for (int index = 0; index < bindings.size(); index++) {
SkillCapabilityBinding binding = bindings.get(index);
SkillCapabilityType capabilityType = SkillCapabilityType.from(binding.getCapabilityType());
SkillCapabilityTarget target = Boolean.TRUE.equals(binding.getEnabled())
? validated.targetsByIndex().get(index) : null;
Map<String, Object> snapshot = new LinkedHashMap<>();
snapshot.put("capabilityType", binding.getCapabilityType());
snapshot.put("runtimeName", binding.getRuntimeName());
snapshot.put("enabled", binding.getEnabled());
snapshot.put("selectionMode", binding.getSelectionMode());
snapshot.put("selectedToolNames", binding.getSelectedToolNamesJson());
snapshot.put("resolvedToolNames", binding.getResolvedToolNames());
snapshot.put("executionMode", binding.getExecutionMode());
snapshot.put("hitlEnabled", binding.getHitlEnabled());
snapshot.put("hitlConfig", SkillSensitiveConfigSanitizer.sanitizeHitl(binding.getHitlConfigJson()));
snapshot.put("options", SkillSensitiveConfigSanitizer.sanitizeOptions(binding.getOptionsJson()));
snapshot.put("sortNo", binding.getSortNo());
if (target != null) {
String targetName = SkillPortableTargetSanitizer.safePortableMetadataOrNull(target.getName());
String targetRevision = SkillPortableTargetSanitizer.safePortableMetadataOrNull(target.getRevision());
if (targetName != null) {
snapshot.put("targetName", targetName);
}
snapshot.put("targetLogicalRef", SkillPortableTargetSanitizer.safeLogicalRefOrUnresolved(
capabilityType, target.getLogicalRef()));
if (targetRevision != null) {
snapshot.put("targetRevision", targetRevision);
}
} else {
snapshot.put("targetLogicalRef", SkillPortableTargetSanitizer.safeLogicalRefOrUnresolved(
capabilityType, binding.getTargetLogicalRef()));
}
assertCredentialFreeSnapshot(snapshot);
snapshots.add(snapshot);
}
return snapshots;
}
/**
* {@inheritDoc}
*/
@Override
public String calculateHash(List<SkillCapabilityBinding> bindings) {
List<Map<String, Object>> canonical = new ArrayList<>();
if (bindings != null) {
bindings.stream().sorted((left, right) -> Integer.compare(
left.getSortNo() == null ? 0 : left.getSortNo(),
right.getSortNo() == null ? 0 : right.getSortNo()))
.forEach(binding -> {
Map<String, Object> item = new LinkedHashMap<>();
item.put("type", binding.getCapabilityType());
item.put("targetId", binding.getTargetId() == null ? null : binding.getTargetId().toString());
item.put("targetLogicalRef", binding.getTargetLogicalRef());
item.put("runtimeName", binding.getRuntimeName());
item.put("enabled", binding.getEnabled());
item.put("selectionMode", binding.getSelectionMode());
item.put("selectedTools", binding.getSelectedToolNamesJson());
item.put("executionMode", binding.getExecutionMode());
item.put("hitlEnabled", binding.getHitlEnabled());
item.put("hitlConfig", SkillSensitiveConfigSanitizer.sanitizeHitl(binding.getHitlConfigJson()));
item.put("options", SkillSensitiveConfigSanitizer.sanitizeOptions(binding.getOptionsJson()));
canonical.add(item);
});
}
try {
return SkillHashes.sha256Hex(objectMapper.writeValueAsString(canonicalize(canonical))
.getBytes(StandardCharsets.UTF_8));
} catch (JsonProcessingException exception) {
throw new BusinessException(500, 500, "计算 Skill 能力配置 hash 失败", exception);
}
}
/**
* {@inheritDoc}
*/
@Override
public String calculateStoredHash(BigInteger skillId) {
Skill skill = requireSkill(skillId);
resourceAccessService.assertAccess(
CategoryResourceType.SKILL, skill, ResourceAction.READ, "无权限读取 Skill 能力摘要");
return calculateHash(listRaw(skillId));
}
/**
* {@inheritDoc}
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void removeBySkillId(BigInteger skillId) {
if (skillId != null) {
QueryWrapper deleteQuery = QueryWrapper.create()
.eq(SkillCapabilityBinding::getTenantId, requireAccount().getTenantId())
.eq(SkillCapabilityBinding::getSkillId, skillId);
long existingBindingCount = count(deleteQuery);
if (existingBindingCount > 0 && getMapper().deleteByQuery(deleteQuery) != existingBindingCount) {
throw new BusinessException(500, 500, "删除 Skill 能力绑定失败,请稍后重试");
}
}
}
private SkillValidationResult validateInternal(List<SkillCapabilityBinding> bindings, ValidationMode mode) {
return validateInternalWithTargets(bindings, mode).result();
}
/**
* 校验能力绑定并保留本次调用已授权的目标摘要,供发布快照复用。
*
* @param bindings 能力绑定
* @param mode 校验场景
* @return 校验结果与按绑定序号记录的目标摘要
*/
private ValidatedBindings validateInternalWithTargets(List<SkillCapabilityBinding> bindings,
ValidationMode mode) {
assertMcpAccessWhenPresent(bindings);
List<SkillValidationIssue> issues = new ArrayList<>();
if (bindings.size() > MAX_BINDINGS) {
issues.add(SkillValidationIssue.of("ERROR", "CAPABILITY_BINDING_LIMIT",
"单个 Skill 最多绑定 " + MAX_BINDINGS + " 项能力", "capabilities"));
SkillValidationResult result = new SkillValidationResult();
result.setIssues(issues);
result.setValid(false);
return new ValidatedBindings(result, Map.of());
}
Set<String> runtimeNames = new HashSet<>();
Map<TargetCacheKey, SkillCapabilityTarget> targetCache = new HashMap<>();
Map<Integer, SkillCapabilityTarget> targetsByIndex = new HashMap<>();
for (int index = 0; index < bindings.size(); index++) {
validateOne(bindings.get(index), index, mode, runtimeNames, targetCache, targetsByIndex, issues);
}
SkillValidationResult result = new SkillValidationResult();
result.setIssues(issues);
result.setValid(issues.stream().noneMatch(issue -> "ERROR".equals(issue.getSeverity())));
return new ValidatedBindings(result, targetsByIndex);
}
/**
* 当配置中出现 MCP 能力时校验当前操作者的 MCP 查询与使用权限。
*
* <p>该检查先于目标映射执行,因此禁用或尚未映射的 MCP 绑定也不能绕过授权。</p>
*
* @param bindings 待校验能力绑定
*/
private void assertMcpAccessWhenPresent(List<SkillCapabilityBinding> bindings) {
boolean containsMcp = bindings.stream()
.filter(java.util.Objects::nonNull)
.map(SkillCapabilityBinding::getCapabilityType)
.anyMatch(type -> type != null && SkillCapabilityType.MCP.name().equalsIgnoreCase(type.trim()));
if (containsMcp) {
mcpAccessPermissionChecker.assertCanUseMcp();
}
}
private void validateOne(SkillCapabilityBinding binding,
int index,
ValidationMode mode,
Set<String> runtimeNames,
Map<TargetCacheKey, SkillCapabilityTarget> targetCache,
Map<Integer, SkillCapabilityTarget> targetsByIndex,
List<SkillValidationIssue> issues) {
String path = "capabilities[" + index + "]";
if (binding == null) {
issues.add(SkillValidationIssue.of("ERROR", "CAPABILITY_EMPTY", "能力绑定不能为空", path));
return;
}
validateCredentialFields(binding, path, issues);
SkillCapabilityType type;
try {
type = SkillCapabilityType.from(binding.getCapabilityType());
binding.setCapabilityType(type.name());
} catch (BusinessException exception) {
issues.add(SkillValidationIssue.of("ERROR", "CAPABILITY_TYPE_INVALID",
"能力类型不受支持", path + ".capabilityType"));
return;
}
boolean enabled = binding.getEnabled() == null || binding.getEnabled();
binding.setEnabled(enabled);
binding.setHitlEnabled(Boolean.TRUE.equals(binding.getHitlEnabled()));
validateSafeConfigs(binding, path, issues);
if (binding.getRuntimeName() == null || !RUNTIME_NAME_PATTERN.matcher(binding.getRuntimeName()).matches()) {
issues.add(SkillValidationIssue.of("ERROR", "RUNTIME_NAME_INVALID",
"运行时名称必须以字母开头,且只包含字母、数字、下划线或连字符,最长 64 个字符",
path + ".runtimeName"));
}
validateStaticTypeConfiguration(binding, type, enabled, path, issues);
if (binding.getTargetId() == null) {
if (!SkillPortableTargetSanitizer.isSafeLogicalRef(type, binding.getTargetLogicalRef())) {
issues.add(SkillValidationIssue.of("ERROR", "TARGET_LOGICAL_REF_INVALID",
"未映射能力的目标逻辑引用格式不正确", path + ".targetLogicalRef"));
}
if (mode.allowUnresolvedTarget()) {
validateRuntimeNamesWithoutTarget(binding, type, enabled, path, runtimeNames, issues);
} else {
issues.add(SkillValidationIssue.of(enabled ? "ERROR" : "WARNING", "TARGET_UNRESOLVED",
enabled ? "启用的能力必须映射目标资源" : "能力尚未映射目标资源,保持禁用后可保存",
path + ".targetId"));
}
return;
}
if (binding.getTargetLogicalRef() != null && binding.getTargetLogicalRef().length() > 512) {
issues.add(SkillValidationIssue.of("ERROR", "TARGET_LOGICAL_REF_INVALID",
"目标逻辑引用不能超过 512 个字符", path + ".targetLogicalRef"));
}
SkillCapabilityTarget target;
try {
boolean resolveMcpTools = mode.publishValidation() && enabled && type == SkillCapabilityType.MCP;
TargetCacheKey cacheKey = new TargetCacheKey(type, binding.getTargetId(), resolveMcpTools);
target = targetCache.get(cacheKey);
if (target == null) {
target = targetAccessService.requireUsableTarget(binding, resolveMcpTools);
targetCache.put(cacheKey, target);
}
} catch (BusinessException exception) {
boolean permissionError = exception.getHttpStatus() == 403;
issues.add(SkillValidationIssue.of(permissionError || enabled ? "ERROR" : "WARNING",
permissionError ? "TARGET_NO_PERMISSION" : "TARGET_UNAVAILABLE",
permissionError ? "当前用户无权使用目标能力" : "目标能力当前不可用",
path + ".targetId"));
return;
}
targetsByIndex.put(index, target);
validateResolvedTargetCredentials(target, path, issues);
binding.setTargetName(target.getName());
binding.setTargetStatus(target.getStatus());
binding.setTargetLogicalRef(target.getLogicalRef());
if (type == SkillCapabilityType.MCP) {
validateMcp(binding, target, enabled, mode.publishValidation(), path, runtimeNames, issues);
} else {
if (enabled && binding.getRuntimeName() != null
&& !runtimeNames.add(binding.getRuntimeName().toLowerCase(Locale.ROOT))) {
issues.add(SkillValidationIssue.of("ERROR", "RUNTIME_NAME_DUPLICATE",
"最终运行时工具名重复", path + ".runtimeName"));
}
}
}
/**
* 校验能力绑定所有持久化字符串面不含认证凭据。
*
* @param binding 能力绑定
* @param path 能力绑定路径
* @param issues 问题集合
*/
private void validateCredentialFields(SkillCapabilityBinding binding,
String path,
List<SkillValidationIssue> issues) {
validateCredentialValue(binding.getCapabilityType(), path + ".capabilityType", issues);
validateCredentialValue(binding.getTargetLogicalRef(), path + ".targetLogicalRef", issues);
validateCredentialValue(binding.getRuntimeName(), path + ".runtimeName", issues);
validateCredentialValue(binding.getSelectionMode(), path + ".selectionMode", issues);
validateCredentialValue(binding.getExecutionMode(), path + ".executionMode", issues);
List<String> selectedTools = binding.getSelectedToolNamesJson() == null
? List.of() : binding.getSelectedToolNamesJson();
for (int index = 0; index < selectedTools.size(); index++) {
validateCredentialValue(selectedTools.get(index),
path + ".selectedToolNamesJson[" + index + "]", issues);
}
}
/**
* 将单个字符串中的凭据问题转换为稳定、无回显的校验结果。
*
* @param value 字符串值
* @param path 字段路径
* @param issues 问题集合
*/
private void validateCredentialValue(String value,
String path,
List<SkillValidationIssue> issues) {
if (SkillCredentialValueGuard.containsCredential(value)) {
issues.add(SkillValidationIssue.of("ERROR", "SENSITIVE_VALUE_DETECTED",
"能力配置不能包含认证凭据,请改用运行环境中的安全配置", path));
}
}
/**
* 校验目标解析结果中会进入发布快照的字符串字段。
*
* @param target 已授权目标摘要
* @param path 能力绑定路径
* @param issues 问题集合
*/
private void validateResolvedTargetCredentials(SkillCapabilityTarget target,
String path,
List<SkillValidationIssue> issues) {
// 展示元数据与逻辑引用在快照构造时采用 fail-closed 降级;工具名会直接成为运行时名称,必须阻断。
List<String> tools = target.getToolNames() == null ? List.of() : target.getToolNames();
for (int index = 0; index < tools.size(); index++) {
validateCredentialValue(tools.get(index), path + ".resolvedToolNames[" + index + "]", issues);
}
}
/**
* 对最终快照执行纵深凭据检查,防止未来新增字符串字段遗漏显式校验。
*
* @param snapshot 单项发布快照
*/
private void assertCredentialFreeSnapshot(Object snapshot) {
if (snapshot instanceof String text) {
if (SkillCredentialValueGuard.containsCredential(text)) {
throw new BusinessException("Skill 能力发布快照包含不安全配置");
}
return;
}
if (snapshot instanceof Map<?, ?> map) {
map.values().forEach(this::assertCredentialFreeSnapshot);
return;
}
if (snapshot instanceof List<?> list) {
list.forEach(this::assertCredentialFreeSnapshot);
}
}
/**
* 在目标尚未映射时校验可由 manifest 独立确定的最终运行时名称。
*
* @param binding 能力绑定
* @param type 能力类型
* @param enabled 是否启用
* @param path 问题路径
* @param runtimeNames 已占用的运行时名称
* @param issues 问题集合
*/
private void validateRuntimeNamesWithoutTarget(SkillCapabilityBinding binding,
SkillCapabilityType type,
boolean enabled,
String path,
Set<String> runtimeNames,
List<SkillValidationIssue> issues) {
if (type == SkillCapabilityType.MCP) {
validateMcp(binding, null, enabled, false, path, runtimeNames, issues);
return;
}
if (enabled && binding.getRuntimeName() != null
&& !runtimeNames.add(binding.getRuntimeName().toLowerCase(Locale.ROOT))) {
issues.add(SkillValidationIssue.of("ERROR", "RUNTIME_NAME_DUPLICATE",
"最终运行时工具名重复", path + ".runtimeName"));
}
}
private void validateMcp(SkillCapabilityBinding binding,
SkillCapabilityTarget target,
boolean enabled,
boolean publishValidation,
String path,
Set<String> runtimeNames,
List<SkillValidationIssue> issues) {
SkillCapabilitySelectionMode mode = SkillCapabilitySelectionMode.fromOrDefault(binding.getSelectionMode());
List<String> selected = binding.getSelectedToolNamesJson();
if (!enabled) {
return;
}
List<String> available = target == null ? List.of() : target.getToolNames();
List<String> resolved;
if (mode == SkillCapabilitySelectionMode.SELECTED) {
resolved = selected;
} else if (publishValidation) {
resolved = available;
} else {
return;
}
if (resolved.isEmpty()) {
if (mode == SkillCapabilitySelectionMode.SELECTED) {
// SELECTED 空清单已经由静态配置校验给出精确问题,避免重复且含混的发布错误。
return;
}
issues.add(SkillValidationIssue.of("ERROR", "MCP_TOOLS_EMPTY", "MCP 当前没有可发布的工具",
path + ".selectedToolNamesJson"));
return;
}
if (publishValidation && mode == SkillCapabilitySelectionMode.SELECTED && !available.containsAll(selected)) {
issues.add(SkillValidationIssue.of("ERROR", "MCP_TOOL_MISSING",
"部分已选择的 MCP 工具已不存在,请重新选择", path + ".selectedToolNamesJson"));
return;
}
binding.setResolvedToolNames(resolved);
for (String toolName : resolved) {
String finalName = binding.getRuntimeName() + "_" + toolName;
if (finalName.length() > 128 || !Pattern.matches("^[A-Za-z][A-Za-z0-9_.-]{0,127}$", finalName)) {
issues.add(SkillValidationIssue.of("ERROR", "MCP_RUNTIME_NAME_INVALID",
"MCP 最终工具名不符合平台命名规则", path + ".runtimeName"));
continue;
}
if (!runtimeNames.add(finalName.toLowerCase(Locale.ROOT))) {
issues.add(SkillValidationIssue.of("ERROR", "RUNTIME_NAME_DUPLICATE",
"最终运行时工具名重复", path + ".runtimeName"));
}
}
}
private void validateStaticTypeConfiguration(SkillCapabilityBinding binding,
SkillCapabilityType type,
boolean enabled,
String path,
List<SkillValidationIssue> issues) {
if (type != SkillCapabilityType.MCP) {
if (binding.getSelectionMode() != null && !binding.getSelectionMode().isBlank()) {
issues.add(SkillValidationIssue.of("ERROR", "MCP_SELECTION_MODE_NOT_ALLOWED",
"工作流或插件能力不能配置 MCP 工具选择模式", path + ".selectionMode"));
}
if (binding.getSelectedToolNamesJson() != null && !binding.getSelectedToolNamesJson().isEmpty()) {
issues.add(SkillValidationIssue.of("ERROR", "MCP_TOOL_SELECTION_NOT_ALLOWED",
"工作流或插件能力不能配置 MCP 工具清单", path + ".selectedToolNamesJson"));
}
try {
binding.setExecutionMode(SkillCapabilityExecutionMode.fromOrDefault(binding.getExecutionMode()).name());
} catch (BusinessException exception) {
issues.add(SkillValidationIssue.of("ERROR", "EXECUTION_MODE_INVALID",
"能力执行模式不受支持", path + ".executionMode"));
binding.setExecutionMode(SkillCapabilityExecutionMode.SYNC.name());
}
binding.setSelectionMode(null);
binding.setSelectedToolNamesJson(List.of());
return;
}
if (binding.getExecutionMode() != null && !binding.getExecutionMode().isBlank()) {
issues.add(SkillValidationIssue.of("ERROR", "MCP_EXECUTION_MODE_NOT_ALLOWED",
"MCP 能力不能配置工作流或插件执行模式", path + ".executionMode"));
}
binding.setExecutionMode(null);
SkillCapabilitySelectionMode mode;
try {
mode = SkillCapabilitySelectionMode.fromOrDefault(binding.getSelectionMode());
} catch (BusinessException exception) {
issues.add(SkillValidationIssue.of("ERROR", "MCP_SELECTION_MODE_INVALID",
"MCP 工具选择模式不受支持", path + ".selectionMode"));
mode = SkillCapabilitySelectionMode.ALL;
}
binding.setSelectionMode(mode.name());
List<String> requested = binding.getSelectedToolNamesJson() == null
? List.of() : binding.getSelectedToolNamesJson();
if (requested.size() > MAX_SELECTED_TOOLS) {
issues.add(SkillValidationIssue.of("ERROR", "MCP_TOOL_SELECTION_LIMIT",
"MCP 最多选择 " + MAX_SELECTED_TOOLS + " 个工具", path + ".selectedToolNamesJson"));
}
Set<String> validTools = new LinkedHashSet<>();
for (int toolIndex = 0; toolIndex < requested.size(); toolIndex++) {
String tool = requested.get(toolIndex);
if (tool == null || !MCP_TOOL_NAME_PATTERN.matcher(tool).matches()) {
issues.add(SkillValidationIssue.of("ERROR", "MCP_TOOL_NAME_INVALID",
"MCP 工具名不符合平台命名规则",
path + ".selectedToolNamesJson[" + toolIndex + "]"));
} else {
validTools.add(tool);
}
}
List<String> selected = new ArrayList<>(validTools);
selected.sort(String::compareTo);
binding.setSelectedToolNamesJson(selected);
if (mode == SkillCapabilitySelectionMode.SELECTED && selected.isEmpty()) {
issues.add(SkillValidationIssue.of(enabled ? "ERROR" : "WARNING", "MCP_TOOL_SELECTION_EMPTY",
"MCP SELECTED 模式至少选择一个工具", path + ".selectedToolNamesJson"));
}
}
private void validateSafeConfigs(SkillCapabilityBinding binding,
String path,
List<SkillValidationIssue> issues) {
Map<String, Object> originalHitl = binding.getHitlConfigJson() == null
? Map.of() : binding.getHitlConfigJson();
Map<String, Object> originalOptions = binding.getOptionsJson() == null
? Map.of() : binding.getOptionsJson();
Map<String, Object> safeHitl = SkillSensitiveConfigSanitizer.sanitizeHitl(binding.getHitlConfigJson());
Map<String, Object> safeOptions = SkillSensitiveConfigSanitizer.sanitizeOptions(binding.getOptionsJson());
if (!safeHitl.equals(originalHitl)) {
issues.add(SkillValidationIssue.of("ERROR", "HITL_CONFIG_UNSAFE",
"HITL 配置包含未允许字段或复杂值", path + ".hitlConfigJson"));
}
if (!safeOptions.equals(originalOptions)) {
issues.add(SkillValidationIssue.of("ERROR", "CAPABILITY_OPTIONS_UNSAFE",
"能力选项包含未允许字段或复杂值", path + ".optionsJson"));
}
try {
if (objectMapper.writeValueAsBytes(safeHitl).length > MAX_CONFIG_BYTES
|| objectMapper.writeValueAsBytes(safeOptions).length > MAX_CONFIG_BYTES) {
issues.add(SkillValidationIssue.of("ERROR", "CAPABILITY_CONFIG_TOO_LARGE",
"能力配置不能超过 4 KiB", path));
}
} catch (JsonProcessingException exception) {
issues.add(SkillValidationIssue.of("ERROR", "CAPABILITY_CONFIG_INVALID",
"能力配置无法序列化", path));
}
binding.setHitlConfigJson(safeHitl);
binding.setOptionsJson(safeOptions);
validateSafeConfigValues(safeHitl, safeOptions, path, issues);
}
private void validateSafeConfigValues(Map<String, Object> hitl,
Map<String, Object> options,
String path,
List<SkillValidationIssue> issues) {
for (Map.Entry<String, Object> entry : hitl.entrySet()) {
int maxLength = "confirmLabel".equals(entry.getKey()) || "cancelLabel".equals(entry.getKey())
? 128 : 2_000;
if (!(entry.getValue() instanceof String text) || text.length() > maxLength) {
issues.add(SkillValidationIssue.of("ERROR", "HITL_CONFIG_VALUE_INVALID",
"HITL 配置字段类型或长度不正确:" + entry.getKey(), path + ".hitlConfigJson." + entry.getKey()));
} else if (SkillCredentialValueGuard.containsCredential(text)) {
issues.add(SkillValidationIssue.of("ERROR", "SENSITIVE_VALUE_DETECTED",
"HITL 配置不能包含认证凭据,请改用运行环境中的安全配置",
path + ".hitlConfigJson." + entry.getKey()));
}
}
for (Map.Entry<String, Object> entry : options.entrySet()) {
if (entry.getValue() instanceof String text) {
validateCredentialValue(text, path + ".optionsJson." + entry.getKey(), issues);
}
}
validateIntegerOption(options, "timeoutMs", 100, 300_000, path, issues);
validateIntegerOption(options, "retryCount", 0, 10, path, issues);
for (String key : List.of("async", "readOnly")) {
if (options.containsKey(key) && !(options.get(key) instanceof Boolean)) {
issues.add(SkillValidationIssue.of("ERROR", "CAPABILITY_OPTION_VALUE_INVALID",
"能力选项必须为布尔值:" + key, path + ".optionsJson." + key));
}
}
}
private void validateIntegerOption(Map<String, Object> options,
String key,
int minimum,
int maximum,
String path,
List<SkillValidationIssue> issues) {
if (!options.containsKey(key)) {
return;
}
Object value = options.get(key);
boolean valid = value instanceof Number number
&& number.doubleValue() == number.longValue()
&& number.longValue() >= minimum
&& number.longValue() <= maximum;
if (!valid) {
issues.add(SkillValidationIssue.of("ERROR", "CAPABILITY_OPTION_VALUE_INVALID",
"能力选项数值超出范围:" + key, path + ".optionsJson." + key));
}
}
private void enrichDisplayStatus(SkillCapabilityBinding binding) {
if (binding.getTargetId() == null) {
binding.setTargetStatus("UNRESOLVED");
return;
}
try {
SkillCapabilityTarget target = targetAccessService.requireUsableTarget(binding, false);
binding.setTargetName(target.getName());
binding.setTargetStatus("AVAILABLE");
} catch (BusinessException exception) {
boolean permissionDenied = exception.getHttpStatus() == 403;
binding.setTargetStatus(permissionDenied ? "NO_PERMISSION" : "UNAVAILABLE");
if (permissionDenied) {
// 目标不可读时不能沿用调用前对象中可能存在的展示残留。
binding.setTargetName(null);
binding.setResolvedToolNames(List.of());
}
}
}
/**
* 对读取出的历史能力配置执行展示边界脱敏,防止遗留脏数据通过列表或详情接口回显。
*
* @param binding 待读取能力绑定
*/
private void sanitizeBindingForExposure(SkillCapabilityBinding binding) {
binding.setCapabilityType(safeNonCredentialOrNull(binding.getCapabilityType()));
binding.setTargetLogicalRef(safeNonCredentialOrNull(binding.getTargetLogicalRef()));
binding.setRuntimeName(safeNonCredentialOrNull(binding.getRuntimeName()));
binding.setSelectionMode(safeNonCredentialOrNull(binding.getSelectionMode()));
binding.setExecutionMode(safeNonCredentialOrNull(binding.getExecutionMode()));
binding.setTargetName(SkillPortableTargetSanitizer.safePortableMetadataOrNull(binding.getTargetName()));
binding.setTargetStatus(safeNonCredentialOrNull(binding.getTargetStatus()));
binding.setSelectedToolNamesJson(sanitizeToolNamesForExposure(binding.getSelectedToolNamesJson()));
binding.setResolvedToolNames(sanitizeToolNamesForExposure(binding.getResolvedToolNames()));
Map<String, Object> hitl = SkillSensitiveConfigSanitizer.sanitizeHitl(binding.getHitlConfigJson());
hitl.entrySet().removeIf(entry -> !(entry.getValue() instanceof String text)
|| SkillCredentialValueGuard.containsCredential(text));
binding.setHitlConfigJson(hitl);
Map<String, Object> options = SkillSensitiveConfigSanitizer.sanitizeOptions(binding.getOptionsJson());
options.entrySet().removeIf(entry -> !isSafeOptionForExposure(entry.getKey(), entry.getValue()));
binding.setOptionsJson(options);
}
/**
* 过滤历史工具名中的异常或凭据式值。
*
* @param values 原始工具名
* @return 可安全展示的工具名
*/
private List<String> sanitizeToolNamesForExposure(List<String> values) {
if (values == null || values.isEmpty()) {
return List.of();
}
return values.stream()
.filter(java.util.Objects::nonNull)
.filter(value -> MCP_TOOL_NAME_PATTERN.matcher(value).matches())
.filter(value -> !SkillCredentialValueGuard.containsCredential(value))
.toList();
}
/**
* 判断能力选项是否符合公开返回的严格类型和值域。
*
* @param key 选项键
* @param value 选项值
* @return 可安全展示时为 true
*/
private boolean isSafeOptionForExposure(String key, Object value) {
if (("async".equals(key) || "readOnly".equals(key))) {
return value instanceof Boolean;
}
if (!(value instanceof Number number)
|| number.doubleValue() != number.longValue()) {
return false;
}
long numeric = number.longValue();
if ("timeoutMs".equals(key)) {
return numeric >= 100 && numeric <= 300_000;
}
return "retryCount".equals(key) && numeric >= 0 && numeric <= 10;
}
/**
* 返回不含凭据的字符串;敏感或空白值统一移除。
*
* @param value 原始字符串
* @return 可安全返回的值
*/
private String safeNonCredentialOrNull(String value) {
return value == null || value.isBlank() || SkillCredentialValueGuard.containsCredential(value)
? null : value;
}
private List<SkillCapabilityBinding> listRaw(BigInteger skillId) {
return list(QueryWrapper.create()
.eq(SkillCapabilityBinding::getTenantId, requireAccount().getTenantId())
.eq(SkillCapabilityBinding::getSkillId, skillId)
.orderBy("sort_no asc, id asc"));
}
private Skill requireSkill(BigInteger skillId) {
return requireSkill(skillId, false);
}
private Skill requireSkill(BigInteger skillId, boolean forUpdate) {
if (skillId == null) {
throw new BusinessException("Skill ID 不能为空");
}
LoginAccount account = requireAccount();
QueryWrapper query = QueryWrapper.create()
.eq(Skill::getId, skillId)
.eq(Skill::getTenantId, account.getTenantId());
if (forUpdate) {
query.forUpdate();
}
Skill skill = skillMapper.selectOneByQuery(query);
if (skill == null) {
throw new BusinessException(404, 404, "Skill 不存在");
}
return skill;
}
private LoginAccount requireAccount() {
LoginAccount account = SaTokenUtil.getLoginAccount();
if (account == null || account.getId() == null || account.getTenantId() == null) {
throw new BusinessException(401, 401, "未登录或登录态无效");
}
return account;
}
private void assertNoErrors(SkillValidationResult result) {
result.getIssues().stream().filter(issue -> "ERROR".equals(issue.getSeverity())).findFirst()
.ifPresent(issue -> {
if ("TARGET_NO_PERMISSION".equals(issue.getCode())) {
throw new BusinessException(403, 403, issue.getMessage());
}
throw new BusinessException(issue.getMessage());
});
}
private Object canonicalize(Object value) {
if (value instanceof Map<?, ?> map) {
Map<String, Object> sorted = new java.util.TreeMap<>();
map.forEach((key, item) -> sorted.put(String.valueOf(key), canonicalize(item)));
return sorted;
}
if (value instanceof List<?> list) {
return list.stream().map(this::canonicalize).toList();
}
return value;
}
/**
* 能力绑定校验场景。
*
* @param publishValidation 是否执行发布级 MCP 工具解析
* @param allowUnresolvedTarget 是否允许目标留待导入映射处理
*/
private record ValidationMode(boolean publishValidation, boolean allowUnresolvedTarget) {
private static final ValidationMode SAVE = new ValidationMode(false, false);
private static final ValidationMode PUBLISH = new ValidationMode(true, false);
private static final ValidationMode IMPORT_PREVIEW = new ValidationMode(false, true);
}
/**
* 单次校验内目标查询的稳定缓存键。
*
* @param type 能力类型
* @param targetId 目标 ID
* @param resolveMcpTools 是否解析 MCP 工具清单
*/
private record TargetCacheKey(SkillCapabilityType type,
BigInteger targetId,
boolean resolveMcpTools) {
}
/**
* 校验结果及其已授权目标摘要。
*
* @param result 结构化校验结果
* @param targetsByIndex 按绑定序号记录的目标摘要
*/
private record ValidatedBindings(SkillValidationResult result,
Map<Integer, SkillCapabilityTarget> targetsByIndex) {
}
}

View File

@@ -0,0 +1,37 @@
package tech.easyflow.skill.capability;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.List;
/**
* 当前操作者可绑定的能力候选项。
*/
public class SkillCapabilityCandidate {
private String capabilityType;
private BigInteger targetId;
private String name;
private String description;
private String logicalRef;
private String revision;
private String status;
private List<String> toolNames = new ArrayList<>();
public String getCapabilityType() { return capabilityType; }
public void setCapabilityType(String capabilityType) { this.capabilityType = capabilityType; }
public BigInteger getTargetId() { return targetId; }
public void setTargetId(BigInteger targetId) { this.targetId = targetId; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public String getLogicalRef() { return logicalRef; }
public void setLogicalRef(String logicalRef) { this.logicalRef = logicalRef; }
public String getRevision() { return revision; }
public void setRevision(String revision) { this.revision = revision; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public List<String> getToolNames() { return toolNames; }
public void setToolNames(List<String> toolNames) { this.toolNames = toolNames == null ? new ArrayList<>() : new ArrayList<>(toolNames); }
}

View File

@@ -0,0 +1,30 @@
package tech.easyflow.skill.capability;
import java.util.ArrayList;
import java.util.List;
/**
* 已授权能力目标的安全解析结果。
*/
public class SkillCapabilityTarget {
private String name;
private String description;
private String logicalRef;
private String revision;
private String status;
private List<String> toolNames = new ArrayList<>();
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public String getLogicalRef() { return logicalRef; }
public void setLogicalRef(String logicalRef) { this.logicalRef = logicalRef; }
public String getRevision() { return revision; }
public void setRevision(String revision) { this.revision = revision; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public List<String> getToolNames() { return toolNames; }
public void setToolNames(List<String> toolNames) { this.toolNames = toolNames == null ? new ArrayList<>() : new ArrayList<>(toolNames); }
}

View File

@@ -0,0 +1,48 @@
package tech.easyflow.skill.capability;
import tech.easyflow.skill.entity.SkillCapabilityBinding;
import tech.easyflow.skill.enums.SkillCapabilityType;
import java.math.BigInteger;
import java.util.List;
/**
* Skill 能力目标的统一授权与安全摘要服务。
*/
public interface SkillCapabilityTargetAccessService {
/**
* 解析并校验一个能力绑定目标。
*
* @param binding 能力绑定
* @param resolveMcpTools 是否实时解析 MCP 工具
* @return 不含凭据的目标摘要
*/
SkillCapabilityTarget requireUsableTarget(SkillCapabilityBinding binding, boolean resolveMcpTools);
/**
* 查询当前操作者可绑定的目标。
*
* @param capabilityType 能力类型
* @param keyword 关键词
* @return 可绑定目标
*/
List<SkillCapabilityCandidate> listCandidates(SkillCapabilityType capabilityType, String keyword);
/**
* 按逻辑引用尝试解析当前环境目标。
*
* @param capabilityType 能力类型
* @param logicalRef 逻辑引用
* @return 当前用户有权使用的目标 ID未匹配时为空
*/
BigInteger resolveLogicalRef(SkillCapabilityType capabilityType, String logicalRef);
/**
* 按需解析一个已授权 MCP 的工具清单。
*
* @param targetId MCP ID
* @return MCP 候选详情及工具名
*/
SkillCapabilityCandidate getMcpTools(BigInteger targetId);
}

View File

@@ -0,0 +1,536 @@
package tech.easyflow.skill.capability;
import com.mybatisflex.core.query.QueryWrapper;
import io.modelcontextprotocol.spec.McpSchema;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import tech.easyflow.ai.entity.Mcp;
import tech.easyflow.ai.entity.Plugin;
import tech.easyflow.ai.entity.PluginItem;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.ai.permission.McpAccessPermissionChecker;
import tech.easyflow.ai.permission.WorkflowVisibilityQueryHelper;
import tech.easyflow.ai.service.McpService;
import tech.easyflow.ai.service.PluginItemService;
import tech.easyflow.ai.service.PluginService;
import tech.easyflow.ai.service.PluginVisibilityService;
import tech.easyflow.ai.service.WorkflowService;
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.SkillCapabilityBinding;
import tech.easyflow.skill.enums.SkillCapabilityType;
import tech.easyflow.skill.security.SkillPortableTargetSanitizer;
import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot;
import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.enums.ResourceAction;
import tech.easyflow.system.service.CategoryPermissionService;
import tech.easyflow.system.service.ResourceAccessService;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
/**
* Skill 能力目标授权服务默认实现。
*/
@Service
public class SkillCapabilityTargetAccessServiceImpl implements SkillCapabilityTargetAccessService {
private static final Logger LOG = LoggerFactory.getLogger(SkillCapabilityTargetAccessServiceImpl.class);
private static final int MAX_CANDIDATES = 100;
private static final String UNRESOLVED_PREFIX = "unresolved:";
private final WorkflowService workflowService;
private final PluginItemService pluginItemService;
private final PluginService pluginService;
private final PluginVisibilityService pluginVisibilityService;
private final McpService mcpService;
private final McpAccessPermissionChecker mcpAccessPermissionChecker;
private final ResourceAccessService resourceAccessService;
private final WorkflowVisibilityQueryHelper workflowVisibilityQueryHelper;
private final CategoryPermissionService categoryPermissionService;
/**
* 创建能力目标授权服务。
*
* @param workflowService 工作流服务
* @param pluginItemService 插件工具项服务
* @param pluginService 插件服务
* @param pluginVisibilityService 插件可见性服务
* @param mcpService MCP 服务
* @param mcpAccessPermissionChecker MCP 查询与使用权限检查器
* @param resourceAccessService 分类资源访问服务
* @param workflowVisibilityQueryHelper 工作流可见性查询助手
* @param categoryPermissionService 分类权限服务
*/
public SkillCapabilityTargetAccessServiceImpl(WorkflowService workflowService,
PluginItemService pluginItemService,
PluginService pluginService,
PluginVisibilityService pluginVisibilityService,
McpService mcpService,
McpAccessPermissionChecker mcpAccessPermissionChecker,
ResourceAccessService resourceAccessService,
WorkflowVisibilityQueryHelper workflowVisibilityQueryHelper,
CategoryPermissionService categoryPermissionService) {
this.workflowService = workflowService;
this.pluginItemService = pluginItemService;
this.pluginService = pluginService;
this.pluginVisibilityService = pluginVisibilityService;
this.mcpService = mcpService;
this.mcpAccessPermissionChecker = mcpAccessPermissionChecker;
this.resourceAccessService = resourceAccessService;
this.workflowVisibilityQueryHelper = workflowVisibilityQueryHelper;
this.categoryPermissionService = categoryPermissionService;
}
/**
* {@inheritDoc}
*/
@Override
public SkillCapabilityTarget requireUsableTarget(SkillCapabilityBinding binding, boolean resolveMcpTools) {
if (binding == null || binding.getTargetId() == null) {
throw new BusinessException("能力绑定目标不能为空");
}
SkillCapabilityType type = SkillCapabilityType.from(binding.getCapabilityType());
return switch (type) {
case WORKFLOW -> requireWorkflow(binding.getTargetId());
case PLUGIN_ITEM -> requirePluginItem(binding.getTargetId());
case MCP -> requireMcp(binding.getTargetId(), resolveMcpTools);
};
}
/**
* {@inheritDoc}
*/
@Override
public List<SkillCapabilityCandidate> listCandidates(SkillCapabilityType capabilityType, String keyword) {
String normalizedKeyword = keyword == null ? "" : keyword.trim().toLowerCase();
return switch (capabilityType) {
case WORKFLOW -> workflowCandidates(normalizedKeyword);
case PLUGIN_ITEM -> pluginCandidates(normalizedKeyword);
case MCP -> mcpCandidates(normalizedKeyword);
};
}
/**
* {@inheritDoc}
*/
@Override
public BigInteger resolveLogicalRef(SkillCapabilityType capabilityType, String logicalRef) {
if (capabilityType == SkillCapabilityType.MCP) {
// 显式未映射引用仍属于增强包 MCP 映射流程,不能绕过模块权限。
mcpAccessPermissionChecker.assertCanUseMcp();
}
if (!SkillPortableTargetSanitizer.isSafeLogicalRef(capabilityType, logicalRef)) {
return null;
}
if (logicalRef.startsWith(UNRESOLVED_PREFIX)
|| "workflow:unmapped".equals(logicalRef)
|| "plugin-item:unmapped/unmapped".equals(logicalRef)
|| "mcp:unmapped".equals(logicalRef)) {
return null;
}
try {
return switch (capabilityType) {
case WORKFLOW -> resolveWorkflowRef(logicalRef);
case PLUGIN_ITEM -> resolvePluginItemRef(logicalRef);
case MCP -> resolveMcpRef(logicalRef);
};
} catch (BusinessException exception) {
if (exception.getHttpStatus() == 401 || exception.getHttpStatus() == 403) {
throw exception;
}
return null;
}
}
/**
* {@inheritDoc}
*/
@Override
public SkillCapabilityCandidate getMcpTools(BigInteger targetId) {
SkillCapabilityTarget target = requireMcp(targetId, true);
return toCandidate(SkillCapabilityType.MCP, targetId, target);
}
private SkillCapabilityTarget requireWorkflow(BigInteger targetId) {
Workflow workflow = workflowService.getOne(QueryWrapper.create()
.eq(Workflow::getId, targetId)
.eq(Workflow::getTenantId, requireAccount().getTenantId()));
return toWorkflowTarget(workflow, targetId, true);
}
private SkillCapabilityTarget toWorkflowTarget(Workflow workflow, BigInteger targetId, boolean assertPermission) {
if (workflow == null || PublishStatus.from(workflow.getPublishStatus()) != PublishStatus.PUBLISHED
|| workflow.getPublishedSnapshotJson() == null || workflow.getPublishedSnapshotJson().isEmpty()) {
throw new BusinessException(404, 404, "绑定工作流不存在、未发布或没有有效发布快照");
}
if (assertPermission) {
resourceAccessService.assertAccess(CategoryResourceType.WORKFLOW, workflow, ResourceAction.USE,
"无权限使用绑定工作流");
}
SkillCapabilityTarget target = new SkillCapabilityTarget();
target.setName(workflow.getTitle());
target.setDescription(workflow.getDescription());
String stableRef = firstNonBlank(workflow.getAlias(), workflow.getEnglishName());
target.setLogicalRef(SkillPortableTargetSanitizer.safeLogicalRefOrUnresolved(
SkillCapabilityType.WORKFLOW, stableRef == null ? null : "workflow:" + stableRef));
target.setRevision(workflow.getPublishedAt() == null ? null : String.valueOf(workflow.getPublishedAt().getTime()));
target.setStatus("AVAILABLE");
return target;
}
private SkillCapabilityTarget requirePluginItem(BigInteger targetId) {
LoginAccount account = requireAccount();
PluginItem item = pluginItemService.getOne(QueryWrapper.create()
.eq(PluginItem::getId, targetId)
.and("plugin_id IN (SELECT id FROM tb_plugin WHERE tenant_id = ?)",
account.getTenantId().longValue()));
if (item == null || !Integer.valueOf(1).equals(item.getStatus())
|| !Integer.valueOf(1).equals(item.getServiceStatus())) {
throw new BusinessException(404, 404, "绑定插件工具项不存在或未启用");
}
Plugin plugin = pluginService.getOne(QueryWrapper.create()
.eq(Plugin::getId, item.getPluginId())
.eq(Plugin::getTenantId, account.getTenantId().longValue()));
return toPluginTarget(item, plugin, true, false);
}
private SkillCapabilityTarget toPluginTarget(PluginItem item, Plugin plugin,
boolean assertPermission, boolean alreadyPrepared) {
if (plugin == null) {
throw new BusinessException(404, 404, "绑定插件工具项所属插件不存在");
}
LoginAccount account = requireAccount();
if (!Objects.equals(plugin.getTenantId(), account.getTenantId().longValue())) {
throw new BusinessException(403, 403, "无权限使用绑定插件");
}
if (assertPermission && !pluginVisibilityService.canAccessPlugin(plugin.getCreatedBy(), plugin.getId())) {
throw new BusinessException(403, 403, "无权限使用绑定插件");
}
Plugin prepared = alreadyPrepared ? plugin : pluginService.preparePluginForCurrentUser(plugin);
if (prepared != null && Boolean.FALSE.equals(prepared.getAvailable())) {
throw new BusinessException(firstNonBlank(prepared.getReasonMessage(), "绑定插件当前不可用"));
}
SkillCapabilityTarget target = new SkillCapabilityTarget();
target.setName(plugin.getName() + " / " + item.getName());
target.setDescription(item.getDescription());
String pluginRef = firstNonBlank(plugin.getAlias());
String itemRef = firstNonBlank(item.getEnglishName());
String logicalRef = pluginRef == null || itemRef == null
? null : "plugin-item:" + pluginRef + "/" + itemRef;
target.setLogicalRef(SkillPortableTargetSanitizer.safeLogicalRefOrUnresolved(
SkillCapabilityType.PLUGIN_ITEM, logicalRef));
target.setRevision(item.getSchemaHash());
target.setStatus("AVAILABLE");
return target;
}
private SkillCapabilityTarget requireMcp(BigInteger targetId, boolean resolveTools) {
mcpAccessPermissionChecker.assertCanUseMcp();
Mcp mcp = mcpService.getOne(QueryWrapper.create()
.eq(Mcp::getId, targetId)
.eq(Mcp::getTenantId, requireAccount().getTenantId()));
return toMcpTarget(mcp, targetId, resolveTools);
}
private SkillCapabilityTarget toMcpTarget(Mcp mcp, BigInteger targetId, boolean resolveTools) {
LoginAccount account = requireAccount();
if (mcp == null || !Boolean.TRUE.equals(mcp.getStatus())) {
throw new BusinessException(404, 404, "绑定 MCP 不存在或未启用");
}
// MCP 尚未纳入 CategoryResourceType显式限制到当前租户防止使用 ID 绕过租户隔离。
if (!Objects.equals(account.getTenantId(), mcp.getTenantId())) {
throw new BusinessException(403, 403, "无权限使用绑定 MCP");
}
SkillCapabilityTarget target = new SkillCapabilityTarget();
target.setName(mcp.getTitle());
target.setDescription(mcp.getDescription());
target.setLogicalRef(SkillPortableTargetSanitizer.safeLogicalRefOrUnresolved(
SkillCapabilityType.MCP, mcp.getTitle() == null ? null : "mcp:" + mcp.getTitle()));
target.setRevision(mcp.getModified() == null ? null : String.valueOf(mcp.getModified().getTime()));
target.setStatus("AVAILABLE");
if (resolveTools) {
try {
Mcp resolved = mcpService.getMcpTools(targetId.toString());
if (resolved == null || resolved.getTools() == null) {
throw new BusinessException("MCP 当前未连接,无法解析工具清单");
}
target.setToolNames(resolved.getTools().stream().map(McpSchema.Tool::name).sorted().toList());
} catch (BusinessException exception) {
throw exception;
} catch (Exception exception) {
LOG.error("解析 Skill 绑定 MCP 工具清单失败targetId={}", targetId, exception);
throw new BusinessException(502, 5021,
"MCP 工具清单解析失败,请检查服务连接状态", exception);
}
}
return target;
}
private List<SkillCapabilityCandidate> workflowCandidates(String keyword) {
QueryWrapper query = QueryWrapper.create()
.eq(Workflow::getTenantId, requireAccount().getTenantId())
.eq(Workflow::getPublishStatus, PublishStatus.PUBLISHED.getCode())
.orderBy("modified desc");
workflowVisibilityQueryHelper.applyReadableAccess(query);
query.limit(MAX_CANDIDATES);
applyKeyword(query, keyword, "title", "description", "alias", "english_name");
List<SkillCapabilityCandidate> result = new ArrayList<>();
for (Workflow workflow : workflowService.list(query)) {
if (!resourceAccessService.canAccess(CategoryResourceType.WORKFLOW, workflow, ResourceAction.USE)) {
continue;
}
SkillCapabilityTarget target;
try {
target = toWorkflowTarget(workflow, workflow.getId(), false);
} catch (BusinessException ignored) {
continue;
}
if (!matches(keyword, target.getName(), target.getDescription(), target.getLogicalRef())) {
continue;
}
result.add(toCandidate(SkillCapabilityType.WORKFLOW, workflow.getId(), target));
if (result.size() >= MAX_CANDIDATES) {
break;
}
}
return result;
}
private List<SkillCapabilityCandidate> pluginCandidates(String keyword) {
QueryWrapper query = QueryWrapper.create()
.eq(PluginItem::getStatus, 1)
.eq(PluginItem::getServiceStatus, 1)
.orderBy("created desc");
applyPluginReadableAccess(query);
query.limit(MAX_CANDIDATES);
applyKeyword(query, keyword, "name", "description", "english_name");
List<PluginItem> items = pluginItemService.list(query);
Map<BigInteger, Plugin> plugins = loadPlugins(items);
Map<BigInteger, Plugin> preparedPlugins = new LinkedHashMap<>();
for (Plugin plugin : plugins.values()) {
preparedPlugins.put(plugin.getId(), pluginService.preparePluginForCurrentUser(plugin));
}
List<SkillCapabilityCandidate> result = new ArrayList<>();
for (PluginItem item : items) {
Plugin plugin = preparedPlugins.get(item.getPluginId());
if (plugin == null) {
continue;
}
try {
SkillCapabilityTarget target = toPluginTarget(item, plugin, false, true);
if (matches(keyword, target.getName(), target.getDescription(), target.getLogicalRef())) {
result.add(toCandidate(SkillCapabilityType.PLUGIN_ITEM, item.getId(), target));
if (result.size() >= MAX_CANDIDATES) {
break;
}
}
} catch (BusinessException ignored) {
// 候选列表只展示当前可用项,具体不可用原因在已保存绑定的校验结果中返回。
}
}
return result;
}
private List<SkillCapabilityCandidate> mcpCandidates(String keyword) {
// 候选枚举在查询数据前完成模块权限校验,避免把无权限误装成空列表。
mcpAccessPermissionChecker.assertCanUseMcp();
QueryWrapper query = QueryWrapper.create().eq(Mcp::getStatus, true)
.eq(Mcp::getTenantId, requireAccount().getTenantId())
.orderBy("modified desc").limit(MAX_CANDIDATES);
applyKeyword(query, keyword, "title", "description");
List<SkillCapabilityCandidate> result = new ArrayList<>();
for (Mcp mcp : mcpService.list(query)) {
try {
SkillCapabilityTarget target = toMcpTarget(mcp, mcp.getId(), false);
if (matches(keyword, target.getName(), target.getDescription(), target.getLogicalRef())) {
result.add(toCandidate(SkillCapabilityType.MCP, mcp.getId(), target));
if (result.size() >= MAX_CANDIDATES) {
break;
}
}
} catch (BusinessException ignored) {
// 同租户且启用的 MCP 才能成为候选。
}
}
return result;
}
private Map<BigInteger, Plugin> loadPlugins(List<PluginItem> items) {
List<BigInteger> ids = items.stream().map(PluginItem::getPluginId).filter(Objects::nonNull).distinct().toList();
if (ids.isEmpty()) {
return Map.of();
}
Map<BigInteger, Plugin> result = new LinkedHashMap<>();
for (Plugin plugin : pluginService.list(QueryWrapper.create()
.eq(Plugin::getTenantId, requireAccount().getTenantId().longValue())
.in(Plugin::getId, ids))) {
result.put(plugin.getId(), plugin);
}
return result;
}
private void applyPluginReadableAccess(QueryWrapper itemQuery) {
LoginAccount account = requireAccount();
itemQuery.and("plugin_id IN (SELECT id FROM tb_plugin WHERE tenant_id = ?)",
account.getTenantId().longValue());
RoleCategoryAccessSnapshot snapshot = categoryPermissionService.getCurrentAccess("PLUGIN");
if (snapshot.isSuperAdmin() || !snapshot.isRestricted()) {
return;
}
if (snapshot.getAccountId() == null) {
itemQuery.and("1 = 0");
return;
}
if (snapshot.getCategoryIds().isEmpty()) {
itemQuery.and("plugin_id IN (SELECT id FROM tb_plugin WHERE created_by = ?)",
snapshot.getAccountId());
return;
}
String placeholders = String.join(",", java.util.Collections.nCopies(
snapshot.getCategoryIds().size(), "?"));
List<Object> arguments = new ArrayList<>();
arguments.add(snapshot.getAccountId());
arguments.addAll(snapshot.getCategoryIds());
itemQuery.and("plugin_id IN (SELECT id FROM tb_plugin WHERE created_by = ? OR id IN "
+ "(SELECT plugin_id FROM tb_plugin_category_mapping WHERE category_id IN (" + placeholders + ")))",
arguments.toArray());
}
private BigInteger resolveWorkflowRef(String logicalRef) {
if (!logicalRef.startsWith("workflow:")) {
return null;
}
String key = logicalRef.substring("workflow:".length());
QueryWrapper query = QueryWrapper.create();
query.eq(Workflow::getTenantId, requireAccount().getTenantId());
query.and("(alias = ? OR english_name = ?)", key, key);
query.limit(2);
List<Workflow> matches = workflowService.list(query).stream()
.filter(workflow -> resourceAccessService.canAccess(
CategoryResourceType.WORKFLOW, workflow, ResourceAction.USE))
.toList();
if (matches.size() != 1) {
return null;
}
toWorkflowTarget(matches.get(0), matches.get(0).getId(), false);
return matches.get(0).getId();
}
private BigInteger resolvePluginItemRef(String logicalRef) {
if (!logicalRef.startsWith("plugin-item:") || !logicalRef.substring("plugin-item:".length()).contains("/")) {
return null;
}
String value = logicalRef.substring("plugin-item:".length());
int separator = value.indexOf('/');
String pluginKey = value.substring(0, separator);
String itemKey = value.substring(separator + 1);
QueryWrapper pluginQuery = QueryWrapper.create();
pluginQuery.eq(Plugin::getTenantId, requireAccount().getTenantId().longValue())
.eq(Plugin::getAlias, pluginKey);
pluginQuery.limit(2);
List<Plugin> plugins = pluginService.list(pluginQuery).stream()
.filter(plugin -> pluginVisibilityService.canAccessPlugin(plugin.getCreatedBy(), plugin.getId()))
.toList();
if (plugins.size() != 1) {
return null;
}
QueryWrapper itemQuery = QueryWrapper.create().eq(PluginItem::getPluginId, plugins.get(0).getId());
itemQuery.and("(english_name = ? OR name = ?)", itemKey, itemKey);
itemQuery.limit(2);
List<PluginItem> items = pluginItemService.list(itemQuery);
if (items.size() != 1) {
return null;
}
toPluginTarget(items.get(0), plugins.get(0), false, false);
return items.get(0).getId();
}
private BigInteger resolveMcpRef(String logicalRef) {
if (!logicalRef.startsWith("mcp:")) {
return null;
}
String title = logicalRef.substring("mcp:".length());
List<Mcp> matches = mcpService.list(QueryWrapper.create()
.eq(Mcp::getTenantId, requireAccount().getTenantId())
.eq(Mcp::getTitle, title).limit(2));
List<Mcp> usable = matches.stream().filter(mcp -> {
try {
toMcpTarget(mcp, mcp.getId(), false);
return true;
} catch (BusinessException exception) {
return false;
}
}).toList();
return usable.size() == 1 ? usable.get(0).getId() : null;
}
private SkillCapabilityCandidate toCandidate(SkillCapabilityType type, BigInteger id, SkillCapabilityTarget target) {
SkillCapabilityCandidate candidate = new SkillCapabilityCandidate();
candidate.setCapabilityType(type.name());
candidate.setTargetId(id);
candidate.setName(target.getName());
candidate.setDescription(target.getDescription());
candidate.setLogicalRef(target.getLogicalRef());
candidate.setRevision(target.getRevision());
candidate.setStatus(target.getStatus());
candidate.setToolNames(target.getToolNames());
return candidate;
}
private boolean matches(String keyword, String... values) {
if (keyword == null || keyword.isBlank()) {
return true;
}
for (String value : values) {
if (value != null && value.toLowerCase(Locale.ROOT).contains(keyword)) {
return true;
}
}
return false;
}
private void applyKeyword(QueryWrapper query, String keyword, String... columns) {
if (keyword == null || keyword.isBlank() || columns.length == 0) {
return;
}
String pattern = "%" + keyword.toLowerCase(Locale.ROOT) + "%";
StringBuilder condition = new StringBuilder("(");
Object[] arguments = new Object[columns.length];
for (int index = 0; index < columns.length; index++) {
if (index > 0) {
condition.append(" OR ");
}
condition.append("LOWER(").append(columns[index]).append(") LIKE ?");
arguments[index] = pattern;
}
condition.append(')');
query.and(condition.toString(), arguments);
}
private LoginAccount requireAccount() {
LoginAccount account = SaTokenUtil.getLoginAccount();
if (account == null || account.getId() == null || account.getTenantId() == null) {
throw new BusinessException(401, 401, "未登录或登录态无效");
}
return account;
}
private String firstNonBlank(String... values) {
for (String value : values) {
if (value != null && !value.isBlank()) {
return value;
}
}
return null;
}
}

View File

@@ -39,6 +39,10 @@ public class Skill extends DateEntity implements VisibilityResource, Serializabl
private String visibilityScope;
private String sourceType;
private String packageHash;
private String capabilityHash;
private String snapshotHash;
private Integer resourceCount;
private Integer capabilityCount;
private Integer referenceCount;
private Integer scriptCount;
private Integer assetCount;
@@ -67,6 +71,10 @@ public class Skill extends DateEntity implements VisibilityResource, Serializabl
private List<SkillScript> scripts;
@Column(ignore = true)
private List<SkillAsset> assets;
@Column(ignore = true)
private List<SkillResource> resources;
@Column(ignore = true)
private List<SkillCapabilityBinding> capabilityBindings;
public BigInteger getId() { return id; }
public void setId(BigInteger id) { this.id = id; }
@@ -94,6 +102,14 @@ public class Skill extends DateEntity implements VisibilityResource, Serializabl
public void setSourceType(String sourceType) { this.sourceType = sourceType; }
public String getPackageHash() { return packageHash; }
public void setPackageHash(String packageHash) { this.packageHash = packageHash; }
public String getCapabilityHash() { return capabilityHash; }
public void setCapabilityHash(String capabilityHash) { this.capabilityHash = capabilityHash; }
public String getSnapshotHash() { return snapshotHash; }
public void setSnapshotHash(String snapshotHash) { this.snapshotHash = snapshotHash; }
public Integer getResourceCount() { return resourceCount; }
public void setResourceCount(Integer resourceCount) { this.resourceCount = resourceCount; }
public Integer getCapabilityCount() { return capabilityCount; }
public void setCapabilityCount(Integer capabilityCount) { this.capabilityCount = capabilityCount; }
public Integer getReferenceCount() { return referenceCount; }
public void setReferenceCount(Integer referenceCount) { this.referenceCount = referenceCount; }
public Integer getScriptCount() { return scriptCount; }
@@ -132,4 +148,8 @@ public class Skill extends DateEntity implements VisibilityResource, Serializabl
public void setScripts(List<SkillScript> scripts) { this.scripts = scripts; }
public List<SkillAsset> getAssets() { return assets; }
public void setAssets(List<SkillAsset> assets) { this.assets = assets; }
public List<SkillResource> getResources() { return resources; }
public void setResources(List<SkillResource> resources) { this.resources = resources; }
public List<SkillCapabilityBinding> getCapabilityBindings() { return capabilityBindings; }
public void setCapabilityBindings(List<SkillCapabilityBinding> capabilityBindings) { this.capabilityBindings = capabilityBindings; }
}

View File

@@ -1,43 +0,0 @@
package tech.easyflow.skill.entity;
import com.mybatisflex.annotation.Id;
import com.mybatisflex.annotation.Table;
import java.io.Serializable;
import java.util.Date;
/**
* Skill asset 内容索引实体。
*/
@Table("tb_skill_asset_content")
public class SkillAssetContent implements Serializable {
private static final long serialVersionUID = 1L;
@Id
private String contentRef;
private String contentHash;
private String filePath;
private String mediaType;
private Long size;
private Integer refCount;
private Date created;
private Date modified;
public String getContentRef() { return contentRef; }
public void setContentRef(String contentRef) { this.contentRef = contentRef; }
public String getContentHash() { return contentHash; }
public void setContentHash(String contentHash) { this.contentHash = contentHash; }
public String getFilePath() { return filePath; }
public void setFilePath(String filePath) { this.filePath = filePath; }
public String getMediaType() { return mediaType; }
public void setMediaType(String mediaType) { this.mediaType = mediaType; }
public Long getSize() { return size; }
public void setSize(Long size) { this.size = size; }
public Integer getRefCount() { return refCount; }
public void setRefCount(Integer refCount) { this.refCount = refCount; }
public Date getCreated() { return created; }
public void setCreated(Date created) { this.created = created; }
public Date getModified() { return modified; }
public void setModified(Date modified) { this.modified = modified; }
}

View File

@@ -0,0 +1,102 @@
package tech.easyflow.skill.entity;
import com.mybatisflex.annotation.Column;
import com.mybatisflex.annotation.Id;
import com.mybatisflex.annotation.KeyType;
import com.mybatisflex.annotation.Table;
import com.mybatisflex.core.handler.FastjsonTypeHandler;
import tech.easyflow.common.entity.DateEntity;
import java.io.Serializable;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Skill 与平台能力的绑定实体。
*/
@Table("tb_skill_capability_binding")
public class SkillCapabilityBinding extends DateEntity implements Serializable {
private static final long serialVersionUID = 1L;
@Id(keyType = KeyType.Generator, value = "snowFlakeId")
private BigInteger id;
@Column(tenantId = true)
private BigInteger tenantId;
private BigInteger skillId;
private String capabilityType;
private BigInteger targetId;
private String targetLogicalRef;
private String runtimeName;
private Boolean enabled;
private String selectionMode;
@Column(typeHandler = FastjsonTypeHandler.class)
private List<String> selectedToolNamesJson = new ArrayList<>();
private String executionMode;
private Boolean hitlEnabled;
@Column(typeHandler = FastjsonTypeHandler.class)
private Map<String, Object> hitlConfigJson = new LinkedHashMap<>();
@Column(typeHandler = FastjsonTypeHandler.class)
private Map<String, Object> optionsJson = new LinkedHashMap<>();
private Integer sortNo;
private Date created;
private BigInteger createdBy;
private Date modified;
private BigInteger modifiedBy;
@Column(ignore = true)
private String targetName;
@Column(ignore = true)
private String targetStatus;
@Column(ignore = true)
private List<String> resolvedToolNames = new ArrayList<>();
public BigInteger getId() { return id; }
public void setId(BigInteger id) { this.id = id; }
public BigInteger getTenantId() { return tenantId; }
public void setTenantId(BigInteger tenantId) { this.tenantId = tenantId; }
public BigInteger getSkillId() { return skillId; }
public void setSkillId(BigInteger skillId) { this.skillId = skillId; }
public String getCapabilityType() { return capabilityType; }
public void setCapabilityType(String capabilityType) { this.capabilityType = capabilityType; }
public BigInteger getTargetId() { return targetId; }
public void setTargetId(BigInteger targetId) { this.targetId = targetId; }
public String getTargetLogicalRef() { return targetLogicalRef; }
public void setTargetLogicalRef(String targetLogicalRef) { this.targetLogicalRef = targetLogicalRef; }
public String getRuntimeName() { return runtimeName; }
public void setRuntimeName(String runtimeName) { this.runtimeName = runtimeName; }
public Boolean getEnabled() { return enabled; }
public void setEnabled(Boolean enabled) { this.enabled = enabled; }
public String getSelectionMode() { return selectionMode; }
public void setSelectionMode(String selectionMode) { this.selectionMode = selectionMode; }
public List<String> getSelectedToolNamesJson() { return selectedToolNamesJson; }
public void setSelectedToolNamesJson(List<String> selectedToolNamesJson) { this.selectedToolNamesJson = selectedToolNamesJson == null ? new ArrayList<>() : new ArrayList<>(selectedToolNamesJson); }
public String getExecutionMode() { return executionMode; }
public void setExecutionMode(String executionMode) { this.executionMode = executionMode; }
public Boolean getHitlEnabled() { return hitlEnabled; }
public void setHitlEnabled(Boolean hitlEnabled) { this.hitlEnabled = hitlEnabled; }
public Map<String, Object> getHitlConfigJson() { return hitlConfigJson; }
public void setHitlConfigJson(Map<String, Object> hitlConfigJson) { this.hitlConfigJson = hitlConfigJson == null ? new LinkedHashMap<>() : hitlConfigJson; }
public Map<String, Object> getOptionsJson() { return optionsJson; }
public void setOptionsJson(Map<String, Object> optionsJson) { this.optionsJson = optionsJson == null ? new LinkedHashMap<>() : optionsJson; }
public Integer getSortNo() { return sortNo; }
public void setSortNo(Integer sortNo) { this.sortNo = sortNo; }
@Override public Date getCreated() { return created; }
@Override public void setCreated(Date created) { this.created = created; }
public BigInteger getCreatedBy() { return createdBy; }
public void setCreatedBy(BigInteger createdBy) { this.createdBy = createdBy; }
@Override public Date getModified() { return modified; }
@Override public void setModified(Date modified) { this.modified = modified; }
public BigInteger getModifiedBy() { return modifiedBy; }
public void setModifiedBy(BigInteger modifiedBy) { this.modifiedBy = modifiedBy; }
public String getTargetName() { return targetName; }
public void setTargetName(String targetName) { this.targetName = targetName; }
public String getTargetStatus() { return targetStatus; }
public void setTargetStatus(String targetStatus) { this.targetStatus = targetStatus; }
public List<String> getResolvedToolNames() { return resolvedToolNames; }
public void setResolvedToolNames(List<String> resolvedToolNames) { this.resolvedToolNames = resolvedToolNames == null ? new ArrayList<>() : new ArrayList<>(resolvedToolNames); }
}

View File

@@ -9,6 +9,8 @@ import tech.easyflow.common.entity.DateEntity;
import java.io.Serializable;
import java.math.BigInteger;
import java.util.Date;
import java.util.ArrayList;
import java.util.List;
/**
* Skill 分类实体。
@@ -32,6 +34,8 @@ public class SkillCategory extends DateEntity implements Serializable {
private BigInteger createdBy;
private Date modified;
private BigInteger modifiedBy;
@Column(ignore = true)
private List<SkillCategory> children = new ArrayList<>();
public BigInteger getId() { return id; }
public void setId(BigInteger id) { this.id = id; }
@@ -57,4 +61,6 @@ public class SkillCategory extends DateEntity implements Serializable {
@Override public void setModified(Date modified) { this.modified = modified; }
public BigInteger getModifiedBy() { return modifiedBy; }
public void setModifiedBy(BigInteger modifiedBy) { this.modifiedBy = modifiedBy; }
public List<SkillCategory> getChildren() { return children; }
public void setChildren(List<SkillCategory> children) { this.children = children == null ? new ArrayList<>() : children; }
}

View File

@@ -0,0 +1,198 @@
package tech.easyflow.skill.entity;
import com.mybatisflex.annotation.Id;
import com.mybatisflex.annotation.Table;
import java.io.Serializable;
import java.util.Date;
/**
* Skill 二进制内容索引实体。
*/
@Table("tb_skill_content")
public class SkillContent implements Serializable {
private static final long serialVersionUID = 1L;
/** 内容引用。 */
@Id
private String contentRef;
/** 内容哈希。 */
private String contentHash;
/** 文件存储返回的读取路径。 */
private String filePath;
/** 可在写入前确定的稳定存储定位符。 */
private String storageLocator;
/** 媒体类型。 */
private String mediaType;
/** 内容字节数。 */
private Long size;
/** 当前引用数。 */
private Integer refCount;
/** 创建时间。 */
private Date created;
/** 修改时间。 */
private Date modified;
/**
* 获取内容引用。
*
* @return 内容引用
*/
public String getContentRef() {
return contentRef;
}
/**
* 设置内容引用。
*
* @param contentRef 内容引用
*/
public void setContentRef(String contentRef) {
this.contentRef = contentRef;
}
/**
* 获取内容哈希。
*
* @return 内容哈希
*/
public String getContentHash() {
return contentHash;
}
/**
* 设置内容哈希。
*
* @param contentHash 内容哈希
*/
public void setContentHash(String contentHash) {
this.contentHash = contentHash;
}
/**
* 获取文件读取路径。
*
* @return 文件读取路径
*/
public String getFilePath() {
return filePath;
}
/**
* 设置文件读取路径。
*
* @param filePath 文件读取路径
*/
public void setFilePath(String filePath) {
this.filePath = filePath;
}
/**
* 获取稳定存储定位符。
*
* @return 稳定存储定位符
*/
public String getStorageLocator() {
return storageLocator;
}
/**
* 设置稳定存储定位符。
*
* @param storageLocator 稳定存储定位符
*/
public void setStorageLocator(String storageLocator) {
this.storageLocator = storageLocator;
}
/**
* 获取媒体类型。
*
* @return 媒体类型
*/
public String getMediaType() {
return mediaType;
}
/**
* 设置媒体类型。
*
* @param mediaType 媒体类型
*/
public void setMediaType(String mediaType) {
this.mediaType = mediaType;
}
/**
* 获取内容字节数。
*
* @return 内容字节数
*/
public Long getSize() {
return size;
}
/**
* 设置内容字节数。
*
* @param size 内容字节数
*/
public void setSize(Long size) {
this.size = size;
}
/**
* 获取当前引用数。
*
* @return 当前引用数
*/
public Integer getRefCount() {
return refCount;
}
/**
* 设置当前引用数。
*
* @param refCount 当前引用数
*/
public void setRefCount(Integer refCount) {
this.refCount = refCount;
}
/**
* 获取创建时间。
*
* @return 创建时间
*/
public Date getCreated() {
return created;
}
/**
* 设置创建时间。
*
* @param created 创建时间
*/
public void setCreated(Date created) {
this.created = created;
}
/**
* 获取修改时间。
*
* @return 修改时间
*/
public Date getModified() {
return modified;
}
/**
* 设置修改时间。
*
* @param modified 修改时间
*/
public void setModified(Date modified) {
this.modified = modified;
}
}

View File

@@ -0,0 +1,200 @@
package tech.easyflow.skill.entity;
import com.mybatisflex.annotation.Id;
import com.mybatisflex.annotation.Table;
import java.io.Serializable;
import java.util.Date;
/**
* Skill 二进制内容写入意图实体。
*
* <p>写入意图独立于正式内容索引提交,用于在进程异常退出后定位尚未激活的物理对象。</p>
*/
@Table("tb_skill_content_write_intent")
public class SkillContentWriteIntent implements Serializable {
private static final long serialVersionUID = 1L;
/** 内容引用。 */
@Id
private String contentRef;
/** 写入预留令牌。 */
private String reservationToken;
/** 内容哈希。 */
private String contentHash;
/** 可在写入前确定的稳定存储定位符。 */
private String storageLocator;
/** 媒体类型。 */
private String mediaType;
/** 内容字节数。 */
private Long size;
/** PENDING、WRITING 或 CLEANING 状态。 */
private String state;
/** 创建时间。 */
private Date created;
/** 修改时间。 */
private Date modified;
/**
* 获取内容引用。
*
* @return 内容引用
*/
public String getContentRef() {
return contentRef;
}
/**
* 设置内容引用。
*
* @param contentRef 内容引用
*/
public void setContentRef(String contentRef) {
this.contentRef = contentRef;
}
/**
* 获取写入预留令牌。
*
* @return 写入预留令牌
*/
public String getReservationToken() {
return reservationToken;
}
/**
* 设置写入预留令牌。
*
* @param reservationToken 写入预留令牌
*/
public void setReservationToken(String reservationToken) {
this.reservationToken = reservationToken;
}
/**
* 获取内容哈希。
*
* @return 内容哈希
*/
public String getContentHash() {
return contentHash;
}
/**
* 设置内容哈希。
*
* @param contentHash 内容哈希
*/
public void setContentHash(String contentHash) {
this.contentHash = contentHash;
}
/**
* 获取稳定存储定位符。
*
* @return 稳定存储定位符
*/
public String getStorageLocator() {
return storageLocator;
}
/**
* 设置稳定存储定位符。
*
* @param storageLocator 稳定存储定位符
*/
public void setStorageLocator(String storageLocator) {
this.storageLocator = storageLocator;
}
/**
* 获取媒体类型。
*
* @return 媒体类型
*/
public String getMediaType() {
return mediaType;
}
/**
* 设置媒体类型。
*
* @param mediaType 媒体类型
*/
public void setMediaType(String mediaType) {
this.mediaType = mediaType;
}
/**
* 获取内容字节数。
*
* @return 内容字节数
*/
public Long getSize() {
return size;
}
/**
* 设置内容字节数。
*
* @param size 内容字节数
*/
public void setSize(Long size) {
this.size = size;
}
/**
* 获取写入状态。
*
* @return 写入状态
*/
public String getState() {
return state;
}
/**
* 设置写入状态。
*
* @param state 写入状态
*/
public void setState(String state) {
this.state = state;
}
/**
* 获取创建时间。
*
* @return 创建时间
*/
public Date getCreated() {
return created;
}
/**
* 设置创建时间。
*
* @param created 创建时间
*/
public void setCreated(Date created) {
this.created = created;
}
/**
* 获取修改时间。
*
* @return 修改时间
*/
public Date getModified() {
return modified;
}
/**
* 设置修改时间。
*
* @param modified 修改时间
*/
public void setModified(Date modified) {
this.modified = modified;
}
}

View File

@@ -0,0 +1,49 @@
package tech.easyflow.skill.entity;
import com.mybatisflex.annotation.Column;
import com.mybatisflex.annotation.Id;
import com.mybatisflex.annotation.Table;
import java.io.Serializable;
import java.math.BigInteger;
import java.util.Date;
/**
* Skill 导入临时包索引。
*/
@Table("tb_skill_import_stage")
public class SkillImportStage implements Serializable {
private static final long serialVersionUID = 1L;
@Id
private String importToken;
@Column(tenantId = true)
private BigInteger tenantId;
private BigInteger accountId;
private String filePath;
private String originalName;
private String format;
private String status;
private Date expiresAt;
private Date created;
public String getImportToken() { return importToken; }
public void setImportToken(String importToken) { this.importToken = importToken; }
public BigInteger getTenantId() { return tenantId; }
public void setTenantId(BigInteger tenantId) { this.tenantId = tenantId; }
public BigInteger getAccountId() { return accountId; }
public void setAccountId(BigInteger accountId) { this.accountId = accountId; }
public String getFilePath() { return filePath; }
public void setFilePath(String filePath) { this.filePath = filePath; }
public String getOriginalName() { return originalName; }
public void setOriginalName(String originalName) { this.originalName = originalName; }
public String getFormat() { return format; }
public void setFormat(String format) { this.format = format; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public Date getExpiresAt() { return expiresAt; }
public void setExpiresAt(Date expiresAt) { this.expiresAt = expiresAt; }
public Date getCreated() { return created; }
public void setCreated(Date created) { this.created = created; }
}

View File

@@ -0,0 +1,85 @@
package tech.easyflow.skill.entity;
import com.mybatisflex.annotation.Column;
import com.mybatisflex.annotation.Id;
import com.mybatisflex.annotation.KeyType;
import com.mybatisflex.annotation.Table;
import com.mybatisflex.core.handler.FastjsonTypeHandler;
import tech.easyflow.common.entity.DateEntity;
import java.io.Serializable;
import java.math.BigInteger;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Skill 通用资源实体,统一承载文本与二进制包内文件。
*/
@Table("tb_skill_resource")
public class SkillResource extends DateEntity implements Serializable {
private static final long serialVersionUID = 1L;
@Id(keyType = KeyType.Generator, value = "snowFlakeId")
private BigInteger id;
@Column(tenantId = true)
private BigInteger tenantId;
private BigInteger skillId;
private String path;
private String normalizedPath;
private String kind;
private String language;
private String mediaType;
private Boolean isText;
private String textContent;
private String contentRef;
private String contentHash;
private Long size;
@Column(typeHandler = FastjsonTypeHandler.class)
private Map<String, Object> metadataJson = new LinkedHashMap<>();
private Integer sortNo;
private Date created;
private BigInteger createdBy;
private Date modified;
private BigInteger modifiedBy;
public BigInteger getId() { return id; }
public void setId(BigInteger id) { this.id = id; }
public BigInteger getTenantId() { return tenantId; }
public void setTenantId(BigInteger tenantId) { this.tenantId = tenantId; }
public BigInteger getSkillId() { return skillId; }
public void setSkillId(BigInteger skillId) { this.skillId = skillId; }
public String getPath() { return path; }
public void setPath(String path) { this.path = path; }
public String getNormalizedPath() { return normalizedPath; }
public void setNormalizedPath(String normalizedPath) { this.normalizedPath = normalizedPath; }
public String getKind() { return kind; }
public void setKind(String kind) { this.kind = kind; }
public String getLanguage() { return language; }
public void setLanguage(String language) { this.language = language; }
public String getMediaType() { return mediaType; }
public void setMediaType(String mediaType) { this.mediaType = mediaType; }
public Boolean getIsText() { return isText; }
public void setIsText(Boolean text) { isText = text; }
public String getTextContent() { return textContent; }
public void setTextContent(String textContent) { this.textContent = textContent; }
public String getContentRef() { return contentRef; }
public void setContentRef(String contentRef) { this.contentRef = contentRef; }
public String getContentHash() { return contentHash; }
public void setContentHash(String contentHash) { this.contentHash = contentHash; }
public Long getSize() { return size; }
public void setSize(Long size) { this.size = size; }
public Map<String, Object> getMetadataJson() { return metadataJson; }
public void setMetadataJson(Map<String, Object> metadataJson) { this.metadataJson = metadataJson == null ? new LinkedHashMap<>() : metadataJson; }
public Integer getSortNo() { return sortNo; }
public void setSortNo(Integer sortNo) { this.sortNo = sortNo; }
@Override public Date getCreated() { return created; }
@Override public void setCreated(Date created) { this.created = created; }
public BigInteger getCreatedBy() { return createdBy; }
public void setCreatedBy(BigInteger createdBy) { this.createdBy = createdBy; }
@Override public Date getModified() { return modified; }
@Override public void setModified(Date modified) { this.modified = modified; }
public BigInteger getModifiedBy() { return modifiedBy; }
public void setModifiedBy(BigInteger modifiedBy) { this.modifiedBy = modifiedBy; }
}

View File

@@ -0,0 +1,30 @@
package tech.easyflow.skill.enums;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.util.Locale;
/**
* Skill 能力执行模式配置。
*/
public enum SkillCapabilityExecutionMode {
SYNC,
ASYNC;
/**
* 解析执行模式,空值默认同步。
*
* @param value 模式编码
* @return 执行模式
*/
public static SkillCapabilityExecutionMode fromOrDefault(String value) {
if (value == null || value.isBlank()) {
return SYNC;
}
try {
return valueOf(value.trim().toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException exception) {
throw new BusinessException("不支持的能力执行模式");
}
}
}

View File

@@ -0,0 +1,30 @@
package tech.easyflow.skill.enums;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.util.Locale;
/**
* MCP 工具选择模式。
*/
public enum SkillCapabilitySelectionMode {
ALL,
SELECTED;
/**
* 解析选择模式,空值默认全部。
*
* @param value 模式编码
* @return 选择模式
*/
public static SkillCapabilitySelectionMode fromOrDefault(String value) {
if (value == null || value.isBlank()) {
return ALL;
}
try {
return valueOf(value.trim().toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException exception) {
throw new BusinessException("不支持的 MCP 工具选择模式");
}
}
}

View File

@@ -0,0 +1,31 @@
package tech.easyflow.skill.enums;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.util.Locale;
/**
* Skill 可绑定的平台能力类型。
*/
public enum SkillCapabilityType {
WORKFLOW,
PLUGIN_ITEM,
MCP;
/**
* 解析能力类型。
*
* @param value 类型编码
* @return 能力类型
*/
public static SkillCapabilityType from(String value) {
if (value == null || value.isBlank()) {
throw new BusinessException("能力类型不能为空");
}
try {
return valueOf(value.trim().toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException exception) {
throw new BusinessException("不支持的能力类型");
}
}
}

View File

@@ -10,8 +10,9 @@ public class SkillFileContent {
private String content;
private String language;
private String mediaType;
private Boolean isText;
private Long size;
private String downloadUrl;
private String contentHash;
public String getPath() { return path; }
public void setPath(String path) { this.path = path; }
@@ -23,9 +24,10 @@ public class SkillFileContent {
public void setLanguage(String language) { this.language = language; }
public String getMediaType() { return mediaType; }
public void setMediaType(String mediaType) { this.mediaType = mediaType; }
public Boolean getIsText() { return isText; }
public void setIsText(Boolean text) { isText = text; }
public Long getSize() { return size; }
public void setSize(Long size) { this.size = size; }
public String getDownloadUrl() { return downloadUrl; }
public void setDownloadUrl(String downloadUrl) { this.downloadUrl = downloadUrl; }
public String getContentHash() { return contentHash; }
public void setContentHash(String contentHash) { this.contentHash = contentHash; }
}

View File

@@ -14,7 +14,9 @@ public class SkillFileNode {
private String type;
private String language;
private String mediaType;
private Boolean isText;
private Long size;
private String contentHash;
private List<SkillFileNode> children = new ArrayList<>();
public String getKey() { return key; }
@@ -29,9 +31,12 @@ public class SkillFileNode {
public void setLanguage(String language) { this.language = language; }
public String getMediaType() { return mediaType; }
public void setMediaType(String mediaType) { this.mediaType = mediaType; }
public Boolean getIsText() { return isText; }
public void setIsText(Boolean text) { isText = text; }
public Long getSize() { return size; }
public void setSize(Long size) { this.size = size; }
public String getContentHash() { return contentHash; }
public void setContentHash(String contentHash) { this.contentHash = contentHash; }
public List<SkillFileNode> getChildren() { return children; }
public void setChildren(List<SkillFileNode> children) { this.children = children == null ? new ArrayList<>() : children; }
}

View File

@@ -0,0 +1,23 @@
package tech.easyflow.skill.file;
import java.math.BigInteger;
/**
* Skill 资源重命名请求。
*/
public class SkillFileRenameRequest {
private BigInteger skillId;
private String path;
private String newPath;
private String expectedContentHash;
public BigInteger getSkillId() { return skillId; }
public void setSkillId(BigInteger skillId) { this.skillId = skillId; }
public String getPath() { return path; }
public void setPath(String path) { this.path = path; }
public String getNewPath() { return newPath; }
public void setNewPath(String newPath) { this.newPath = newPath; }
public String getExpectedContentHash() { return expectedContentHash; }
public void setExpectedContentHash(String expectedContentHash) { this.expectedContentHash = expectedContentHash; }
}

View File

@@ -10,6 +10,7 @@ public class SkillFileSaveRequest {
private BigInteger skillId;
private String path;
private String content;
private String expectedContentHash;
public BigInteger getSkillId() { return skillId; }
public void setSkillId(BigInteger skillId) { this.skillId = skillId; }
@@ -17,5 +18,6 @@ public class SkillFileSaveRequest {
public void setPath(String path) { this.path = path; }
public String getContent() { return content; }
public void setContent(String content) { this.content = content; }
public String getExpectedContentHash() { return expectedContentHash; }
public void setExpectedContentHash(String expectedContentHash) { this.expectedContentHash = expectedContentHash; }
}

View File

@@ -36,6 +36,22 @@ public interface SkillFileService {
*/
SkillFileContent saveContent(SkillFileSaveRequest request);
/**
* 创建 Skill 文本资源。
*
* @param request 创建请求
* @return 创建后的文件内容
*/
SkillFileContent createTextFile(SkillFileSaveRequest request);
/**
* 重命名 Skill 资源。
*
* @param request 重命名请求
* @return 重命名后的文件内容
*/
SkillFileContent renameFile(SkillFileRenameRequest request);
/**
* 删除逻辑文件。
*
@@ -44,6 +60,15 @@ public interface SkillFileService {
*/
void deleteFile(BigInteger skillId, String path);
/**
* 按客户端读取到的内容 hash 删除逻辑文件。
*
* @param skillId Skill ID
* @param path 逻辑路径
* @param expectedContentHash 客户端读取到的内容 hash
*/
void deleteFile(BigInteger skillId, String path, String expectedContentHash);
/**
* 上传 asset 文件。
*
@@ -54,6 +79,30 @@ public interface SkillFileService {
*/
SkillFileContent uploadAsset(BigInteger skillId, String path, MultipartFile file);
/**
* 上传任意安全的二进制资源。
*
* @param skillId Skill ID
* @param path 目标逻辑路径
* @param file 上传文件
* @return 保存后的资源内容
*/
SkillFileContent uploadResource(BigInteger skillId, String path, MultipartFile file);
/**
* 上传或按内容 hash 原子替换二进制资源。
*
* @param skillId Skill ID
* @param path 目标逻辑路径
* @param file 上传文件
* @param expectedContentHash 已有路径的客户端内容 hash新路径为空
* @return 保存后的资源内容
*/
SkillFileContent uploadResource(BigInteger skillId,
String path,
MultipartFile file,
String expectedContentHash);
/**
* 打开 asset 输入流。
*
@@ -62,5 +111,13 @@ public interface SkillFileService {
* @return asset 输入流
*/
InputStream openAsset(BigInteger skillId, String path);
}
/**
* 打开二进制资源输入流。
*
* @param skillId Skill ID
* @param path 逻辑路径
* @return 输入流,调用方负责关闭
*/
InputStream openResource(BigInteger skillId, String path);
}

View File

@@ -0,0 +1,492 @@
package tech.easyflow.skill.imports;
import com.easyagents.skill.model.SkillPackageLimits;
import com.easyagents.skill.exception.SkillException;
import com.easyagents.skill.exception.SkillPackageException;
import com.easyagents.skill.util.SkillPaths;
import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
import org.apache.commons.compress.archivers.zip.ZipFile;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.ByteBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.Enumeration;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.zip.ZipEntry;
import java.util.zip.ZipException;
import java.util.zip.ZipOutputStream;
import java.util.zip.CRC32;
/**
* 将 EasyFlow Bundle 安全转换为标准 Skill ZIP并提取平台 manifest。
*/
@Component
public class EasyFlowBundleReader {
private static final Logger LOG = LoggerFactory.getLogger(EasyFlowBundleReader.class);
private final EasyFlowSkillManifestCodec manifestCodec;
/**
* 创建 EasyFlow Bundle 读取器。
*
* @param manifestCodec manifest 编解码器
*/
public EasyFlowBundleReader(EasyFlowSkillManifestCodec manifestCodec) {
this.manifestCodec = manifestCodec;
}
/**
* 判断 ZIP 是否包含 EasyFlow manifest。
*
* @param inputStream ZIP 输入流
* @return 包含时为 true
*/
public boolean containsManifest(InputStream inputStream) {
SkillPackageLimits limits = SkillPackageLimits.defaults();
Path packageFile = null;
try {
packageFile = copyCompressedPackage(inputStream, limits.getMaxCompressedPackageBytes());
try (ZipFile zip = new ZipFile(packageFile)) {
Enumeration<ZipArchiveEntry> entries = zip.getEntries();
int count = 0;
long declaredTotalBytes = 0;
long actualTotalBytes = 0;
boolean containsManifest = false;
while (entries.hasMoreElements()) {
ZipArchiveEntry entry = entries.nextElement();
if (++count > limits.getMaxEntryCount() + 1) {
throw new BusinessException("EasyFlow Skill 包文件数量超过限制");
}
String fullPath = validateCentralEntry(zip, entry, limits);
if (entry.isDirectory()) {
continue;
}
declaredTotalBytes = safeAdd(
declaredTotalBytes, entry.getSize(), limits.getMaxTotalUncompressedBytes());
long singleLimit = EasyFlowSkillManifestCodec.MANIFEST_PATH.equals(fullPath)
? EasyFlowSkillManifestCodec.MAX_MANIFEST_BYTES : limits.getMaxBinaryFileBytes();
long actualSize = readAndVerifyEntry(zip, entry, fullPath, singleLimit);
actualTotalBytes = safeAdd(
actualTotalBytes, actualSize, limits.getMaxTotalUncompressedBytes());
containsManifest |= EasyFlowSkillManifestCodec.MANIFEST_PATH.equals(fullPath);
}
return containsManifest;
}
} catch (ZipException exception) {
throw invalidZip(exception);
} catch (IOException exception) {
ZipException zipException = findZipException(exception);
if (zipException != null) {
throw invalidZip(zipException);
}
throw new BusinessException(500, 500, "读取 Skill 包格式失败", exception);
} finally {
deleteQuietly(packageFile);
}
}
/**
* 提取 manifest并将 skills/ 前缀下的标准包内容流式写到临时 ZIP。
*
* @param inputStream EasyFlow Bundle 输入流
* @return 可自动清理的准备结果
*/
public PreparedBundle prepare(InputStream inputStream) {
SkillPackageLimits limits = SkillPackageLimits.defaults();
Path packageFile = null;
Path standardZip = null;
try {
packageFile = copyCompressedPackage(inputStream, limits.getMaxCompressedPackageBytes());
standardZip = Files.createTempFile("easyflow-bundle-standard-", ".zip");
byte[] manifestBytes = null;
long declaredTotalBytes = 0;
long actualTotalBytes = 0;
int entryCount = 0;
Set<String> paths = new HashSet<>();
Set<String> collisionKeys = new HashSet<>();
try (ZipFile input = new ZipFile(packageFile);
ZipOutputStream output = new ZipOutputStream(
Files.newOutputStream(standardZip, StandardOpenOption.TRUNCATE_EXISTING),
StandardCharsets.UTF_8)) {
Enumeration<ZipArchiveEntry> entries = input.getEntries();
byte[] buffer = new byte[8192];
while (entries.hasMoreElements()) {
ZipArchiveEntry entry = entries.nextElement();
if (++entryCount > limits.getMaxEntryCount() + 1) {
throw new BusinessException("EasyFlow Skill 包文件数量超过限制");
}
String fullPath = validateCentralEntry(input, entry, limits);
if (entry.isDirectory()) {
continue;
}
declaredTotalBytes = safeAdd(
declaredTotalBytes, entry.getSize(), limits.getMaxTotalUncompressedBytes());
if (EasyFlowSkillManifestCodec.MANIFEST_PATH.equals(fullPath)) {
if (manifestBytes != null) {
throw new BusinessException("EasyFlow Skill 包包含重复 manifest");
}
try (InputStream entryInput = input.getInputStream(entry)) {
manifestBytes = readLimited(entryInput, EasyFlowSkillManifestCodec.MAX_MANIFEST_BYTES);
}
verifyCrc(entry, crc32(manifestBytes), fullPath);
actualTotalBytes = safeAdd(actualTotalBytes, manifestBytes.length,
limits.getMaxTotalUncompressedBytes());
if (manifestBytes.length != entry.getSize()) {
throw new BusinessException("EasyFlow Skill manifest 实际大小与目录信息不一致");
}
continue;
}
if (!fullPath.startsWith("skills/")) {
throw new BusinessException("EasyFlow Skill 包根目录只能包含 manifest 和 skills/");
}
String relativePath = normalizePackagePath(fullPath.substring("skills/".length()));
if (!paths.add(relativePath) || !collisionKeys.add(SkillPaths.collisionKey(relativePath))) {
throw new BusinessException("EasyFlow Skill 包存在重复或大小写冲突路径:" + relativePath);
}
ZipEntry outputEntry = new ZipEntry(relativePath);
outputEntry.setTime(0L);
output.putNextEntry(outputEntry);
long entryBytes = 0;
CRC32 crc = new CRC32();
try (InputStream entryInput = input.getInputStream(entry)) {
int length;
while ((length = entryInput.read(buffer)) >= 0) {
entryBytes += length;
if (entryBytes > limits.getMaxBinaryFileBytes()) {
throw new BusinessException("EasyFlow Skill 包单文件解压大小超过限制");
}
actualTotalBytes = safeAdd(actualTotalBytes, length,
limits.getMaxTotalUncompressedBytes());
crc.update(buffer, 0, length);
output.write(buffer, 0, length);
}
}
if (entryBytes != entry.getSize()) {
throw new BusinessException("EasyFlow Skill 包条目实际大小与目录信息不一致");
}
verifyCrc(entry, crc.getValue(), fullPath);
output.closeEntry();
}
output.finish();
}
if (manifestBytes == null) {
throw new BusinessException("EasyFlow Skill 包缺少 easyflow-manifest.json");
}
return new PreparedBundle(standardZip, manifestCodec.decode(manifestBytes));
} catch (RuntimeException | IOException exception) {
deleteQuietly(standardZip);
if (exception instanceof BusinessException businessException) {
throw businessException;
}
if (exception instanceof SkillPackageException skillPackageException) {
throw skillPackageException;
}
ZipException zipException = findZipException(exception);
if (zipException != null) {
throw invalidZip(zipException);
}
LOG.error("解析 EasyFlow Skill Bundle 失败", exception);
throw new BusinessException(500, 500, "解析 EasyFlow Skill Bundle 失败", exception);
}
finally {
deleteQuietly(packageFile);
}
}
private Path copyCompressedPackage(InputStream input, long limit) throws IOException {
if (input == null) {
throw new BusinessException("Skill 包输入流不能为空");
}
Path target = Files.createTempFile("easyflow-bundle-compressed-", ".zip");
try (OutputStream output = Files.newOutputStream(target, StandardOpenOption.TRUNCATE_EXISTING)) {
byte[] buffer = new byte[8192];
long total = 0;
int length;
while ((length = input.read(buffer)) >= 0) {
total += length;
if (total > limit) {
throw new BusinessException(413, 4131,
"Skill 包压缩文件超过 " + limit + " 字节限制");
}
output.write(buffer, 0, length);
}
return target;
} catch (RuntimeException | IOException exception) {
deleteQuietly(target);
throw exception;
}
}
/**
* 校验外层 ZIP 中央目录元数据并返回规范化路径。
*
* @param zip ZIP 文件
* @param entry ZIP 条目
* @param limits 包安全限制
* @return 规范化包内路径
* @throws BusinessException 条目类型、路径、大小或压缩比不安全
* @throws SkillPackageException 原始文件名或 CRC 元数据不合法
*/
private String validateCentralEntry(ZipFile zip, ZipArchiveEntry entry, SkillPackageLimits limits) {
if (!zip.canReadEntryData(entry)) {
throw new BusinessException("Skill 包包含加密或不支持的压缩条目");
}
if (entry.isUnixSymlink()) {
throw new BusinessException("Skill 包不允许包含符号链接");
}
String path = strictUtf8EntryName(entry);
if (entry.isDirectory()) {
while (path.endsWith("/")) {
path = path.substring(0, path.length() - 1);
}
if (path.isBlank()) {
throw new BusinessException("Skill 包包含非法空目录路径");
}
}
String normalized = normalizePackagePath(path);
if (normalized.length() > limits.getMaxPathLength()
|| normalized.split("/").length > limits.getMaxPathDepth()) {
throw new BusinessException("Skill 包路径长度或层级超过限制");
}
long size = entry.getSize();
long compressedSize = entry.getCompressedSize();
if (size < 0 || compressedSize < 0) {
throw new BusinessException("Skill 包条目缺少可靠大小信息");
}
if (!entry.isDirectory() && entry.getCrc() < 0) {
throw packageError("UNKNOWN_ENTRY_CRC", normalized,
"EasyFlow Skill 包条目缺少中央目录 CRC");
}
double ratio = size == 0 ? 0D : (double) size / Math.max(1L, compressedSize);
if (ratio > limits.getMaxCompressionRatio()) {
throw new BusinessException("Skill 包条目压缩比超过安全限制");
}
return normalized;
}
/**
* 严格按 UTF-8 解码 ZIP 中央目录的原始文件名字节,并拒绝 Unicode extra field 造成的歧义。
*
* @param entry ZIP 条目
* @return 唯一的 UTF-8 文件名
* @throws SkillPackageException 原始文件名字节非法或与解析结果不一致
*/
private String strictUtf8EntryName(ZipArchiveEntry entry) {
byte[] rawName = entry.getRawName();
if (rawName == null) {
throw packageError("INVALID_UTF8_ENTRY_NAME", entry.getName(),
"EasyFlow Skill 包条目缺少原始文件名字节");
}
try {
String decodedName = StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.decode(ByteBuffer.wrap(rawName))
.toString();
if (!decodedName.equals(entry.getName())) {
throw packageError("INVALID_UTF8_ENTRY_NAME", entry.getName(),
"EasyFlow Skill 包条目文件名必须具有唯一 UTF-8 表示");
}
return decodedName;
} catch (CharacterCodingException exception) {
throw new SkillPackageException("INVALID_UTF8_ENTRY_NAME", entry.getName(),
"EasyFlow Skill 包条目文件名不是合法 UTF-8", exception);
}
}
/**
* 流式读取单个外层 ZIP 条目,并同时校验实际大小与中央目录 CRC。
*
* @param zip ZIP 文件
* @param entry ZIP 条目
* @param path 已校验路径
* @param sizeLimit 单文件解压上限
* @return 实际解压字节数
* @throws IOException 条目读取失败
* @throws SkillPackageException CRC 不匹配
*/
private long readAndVerifyEntry(ZipFile zip,
ZipArchiveEntry entry,
String path,
long sizeLimit) throws IOException {
CRC32 crc = new CRC32();
byte[] buffer = new byte[8192];
long actualSize = 0;
try (InputStream input = zip.getInputStream(entry)) {
int length;
while ((length = input.read(buffer)) >= 0) {
actualSize += length;
if (actualSize > sizeLimit) {
throw new BusinessException(413, 4131, "EasyFlow Skill 包单文件解压大小超过限制");
}
crc.update(buffer, 0, length);
}
}
if (actualSize != entry.getSize()) {
throw packageError("ENTRY_SIZE_MISMATCH", path,
"EasyFlow Skill 包条目实际大小与中央目录不一致");
}
verifyCrc(entry, crc.getValue(), path);
return actualSize;
}
/**
* 校验 ZIP 条目 CRC-32。
*
* @param entry ZIP 条目
* @param actualCrc 实际内容 CRC-32
* @param path 包内路径
* @throws SkillPackageException CRC 与中央目录不一致
*/
private void verifyCrc(ZipArchiveEntry entry, long actualCrc, String path) {
if (entry.getCrc() != actualCrc) {
throw packageError("CRC_MISMATCH", path,
"EasyFlow Skill 包条目 CRC 与实际内容不一致");
}
}
/**
* 计算字节内容的 CRC-32。
*
* @param bytes 内容字节
* @return CRC-32
*/
private long crc32(byte[] bytes) {
CRC32 crc = new CRC32();
crc.update(bytes);
return crc.getValue();
}
/**
* 创建带稳定错误码和包内路径的 Skill 包异常。
*
* @param code 稳定错误码
* @param path 包内路径
* @param message 错误信息
* @return Skill 包异常
*/
private SkillPackageException packageError(String code, String path, String message) {
return new SkillPackageException(code, path, message);
}
private long safeAdd(long current, long value, long limit) {
if (value < 0 || current > limit - value) {
throw new BusinessException("EasyFlow Skill 包解压总大小超过限制");
}
return current + value;
}
private String normalizePackagePath(String path) {
try {
return SkillPaths.normalize(path);
} catch (SkillException exception) {
throw new BusinessException("Skill 包路径不合法:" + exception.getMessage());
}
}
private byte[] readLimited(InputStream input, long limit) throws IOException {
ByteArrayOutputStream output = new ByteArrayOutputStream();
byte[] buffer = new byte[8192];
long total = 0;
int length;
while ((length = input.read(buffer)) >= 0) {
total += length;
if (total > limit) {
throw new BusinessException(413, 4131, "EasyFlow Skill manifest 超过 1 MiB 限制");
}
output.write(buffer, 0, length);
}
return output.toByteArray();
}
private BusinessException invalidZip(ZipException exception) {
return new BusinessException(400, 4001, "Skill 包不是有效的 ZIP 文件", exception);
}
/**
* 沿异常链查找被 Commons Compress 包装的 ZIP 格式异常。
*
* @param exception 外层读取异常
* @return ZIP 格式异常;不存在时返回 {@code null}
*/
private ZipException findZipException(Throwable exception) {
Throwable current = exception;
for (int depth = 0; current != null && depth < 32; depth++) {
if (current instanceof ZipException zipException) {
return zipException;
}
if (current == current.getCause()) {
break;
}
current = current.getCause();
}
return null;
}
private void deleteQuietly(Path path) {
if (path == null) {
return;
}
try {
Files.deleteIfExists(path);
} catch (IOException exception) {
LOG.warn("清理 EasyFlow Bundle 临时标准包失败path={}", path, exception);
}
}
/**
* EasyFlow Bundle 准备结果。
*/
public final class PreparedBundle implements AutoCloseable {
private final Path standardZip;
private final Map<String, Object> manifest;
private PreparedBundle(Path standardZip, Map<String, Object> manifest) {
this.standardZip = standardZip;
this.manifest = manifest;
}
/**
* 打开转换后的标准 ZIP。
*
* @return 输入流
* @throws IOException 临时文件无法读取
*/
public InputStream openStandardZip() throws IOException {
return Files.newInputStream(standardZip);
}
/**
* 获取已做基础版本校验的 manifest。
*
* @return manifest
*/
public Map<String, Object> getManifest() {
return manifest;
}
/**
* 删除转换临时文件。
*/
@Override
public void close() {
deleteQuietly(standardZip);
}
}
}

View File

@@ -0,0 +1,573 @@
package tech.easyflow.skill.imports;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Component;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.skill.capability.SkillCapabilityTarget;
import tech.easyflow.skill.capability.SkillCapabilityTargetAccessService;
import tech.easyflow.skill.entity.Skill;
import tech.easyflow.skill.entity.SkillCapabilityBinding;
import tech.easyflow.skill.enums.SkillCapabilityExecutionMode;
import tech.easyflow.skill.enums.SkillCapabilitySelectionMode;
import tech.easyflow.skill.enums.SkillCapabilityType;
import tech.easyflow.skill.security.SkillCredentialValueGuard;
import tech.easyflow.skill.security.SkillPortableTargetSanitizer;
import tech.easyflow.skill.security.SkillSensitiveConfigSanitizer;
import java.nio.charset.StandardCharsets;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
/**
* EasyFlow Skill Bundle manifest 的版本化白名单编解码器。
*/
@Component
public class EasyFlowSkillManifestCodec {
private static final Set<String> ROOT_FIELDS = Set.of("schemaVersion", "skills");
private static final Set<String> SKILL_FIELDS = Set.of("packageRoot", "packageHash", "capabilities");
private static final Set<String> BINDING_FIELDS = Set.of(
"bindingKey", "capabilityType", "runtimeName", "enabled", "selectionMode",
"selectedToolNames", "executionMode", "hitlEnabled", "hitlConfig", "options", "sortNo",
"targetLogicalRef", "targetStatus", "targetName", "targetRevision");
private static final Pattern RUNTIME_NAME_PATTERN = Pattern.compile("^[A-Za-z][A-Za-z0-9_-]{0,63}$");
private static final Pattern MCP_TOOL_NAME_PATTERN = Pattern.compile("^[A-Za-z][A-Za-z0-9_.-]{0,127}$");
/** EasyFlow Bundle manifest 固定路径。 */
public static final String MANIFEST_PATH = "easyflow-manifest.json";
/** manifest 最大字节数。 */
public static final long MAX_MANIFEST_BYTES = 1024L * 1024;
/** 单个增强包最大 Skill 数。 */
public static final int MAX_SKILLS = 100;
/** packageRoot 最大字符数。 */
public static final int MAX_PACKAGE_ROOT_LENGTH = 128;
/** 单个 Skill 最大能力绑定数。 */
public static final int MAX_BINDINGS_PER_SKILL = 200;
/** 单个增强包最大能力绑定总数。 */
public static final int MAX_TOTAL_BINDINGS = 1_000;
private final ObjectMapper objectMapper;
private final SkillCapabilityTargetAccessService targetAccessService;
/**
* 创建 manifest 编解码器。
*
* @param objectMapper JSON 映射器
* @param targetAccessService 能力目标授权服务
*/
public EasyFlowSkillManifestCodec(ObjectMapper objectMapper,
SkillCapabilityTargetAccessService targetAccessService) {
this.objectMapper = objectMapper;
this.targetAccessService = targetAccessService;
}
/**
* 将 Skill 列表编码为不含凭据的 manifest。
*
* @param skills Skill 详情
* @return UTF-8 JSON
*/
public byte[] encode(List<Skill> skills) {
if (skills == null || skills.size() > MAX_SKILLS) {
throw new BusinessException("单个 EasyFlow Skill Bundle 最多包含 " + MAX_SKILLS + " 个 Skill");
}
Map<String, Object> manifest = new LinkedHashMap<>();
manifest.put("schemaVersion", "1.0");
List<Map<String, Object>> skillItems = new ArrayList<>();
int totalBindings = 0;
for (int skillIndex = 0; skillIndex < skills.size(); skillIndex++) {
Skill skill = skills.get(skillIndex);
String skillPath = "skills[" + skillIndex + "]";
if (skill == null) {
throw new SkillManifestValidationException("SKILL_EMPTY", skillPath,
"EasyFlow Skill manifest 的 Skill 不能为空");
}
String packageRoot = boundedRequiredString(
skill.getName(), skillPath + ".packageRoot", MAX_PACKAGE_ROOT_LENGTH);
String packageHash = boundedRequiredString(
skill.getPackageHash(), skillPath + ".packageHash", 128);
validatePortableMetadata(packageRoot, skillPath + ".packageRoot");
validatePortableMetadata(packageHash, skillPath + ".packageHash");
Map<String, Object> item = new LinkedHashMap<>();
item.put("packageRoot", packageRoot);
item.put("packageHash", packageHash);
List<Map<String, Object>> bindings = new ArrayList<>();
List<SkillCapabilityBinding> sourceBindings = skill.getCapabilityBindings() == null
? List.of() : skill.getCapabilityBindings();
if (sourceBindings.size() > MAX_BINDINGS_PER_SKILL
|| (totalBindings += sourceBindings.size()) > MAX_TOTAL_BINDINGS) {
throw new BusinessException("EasyFlow Skill Bundle 能力绑定数量超过限制");
}
for (int index = 0; index < sourceBindings.size(); index++) {
bindings.add(bindingManifest(skillIndex, packageRoot, index, sourceBindings.get(index)));
}
item.put("capabilities", bindings);
skillItems.add(item);
}
manifest.put("skills", skillItems);
validateCredentialFreeTree(manifest, "");
validateSkills(skillItems);
try {
byte[] bytes = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsBytes(manifest);
if (bytes.length > MAX_MANIFEST_BYTES) {
throw new BusinessException("EasyFlow Skill manifest 超过 1 MiB 限制");
}
return bytes;
} catch (JsonProcessingException exception) {
throw new BusinessException(500, 500, "生成 EasyFlow Skill manifest 失败", exception);
}
}
/**
* 解码并校验 manifest 基础版本结构。
*
* @param bytes manifest 字节
* @return manifest 对象
*/
public Map<String, Object> decode(byte[] bytes) {
if (bytes == null || bytes.length == 0 || bytes.length > MAX_MANIFEST_BYTES) {
if (bytes != null && bytes.length > MAX_MANIFEST_BYTES) {
throw new BusinessException(413, 4131, "EasyFlow Skill manifest 超过 1 MiB 限制");
}
throw new BusinessException("EasyFlow Skill manifest 不能为空");
}
try {
Map<String, Object> manifest = objectMapper.readerFor(new TypeReference<Map<String, Object>>() { })
.with(JsonParser.Feature.STRICT_DUPLICATE_DETECTION)
.readValue(bytes);
if (manifest == null) {
throw new BusinessException("EasyFlow Skill manifest 根对象不能为空");
}
// 在枚举解析和错误消息构造前先覆盖整个平台 manifest 字符串面,避免敏感值回显。
validateCredentialFreeTree(manifest, "");
assertOnlyFields(manifest, ROOT_FIELDS, "根对象");
if (!"1.0".equals(String.valueOf(manifest.get("schemaVersion")))) {
throw new BusinessException("不支持的 EasyFlow Skill manifest 版本");
}
if (!(manifest.get("skills") instanceof List<?> skills)) {
throw new BusinessException("EasyFlow Skill manifest 缺少 skills 列表");
}
validateSkills(skills);
return manifest;
} catch (java.io.IOException exception) {
throw new BusinessException("EasyFlow Skill manifest JSON 格式不正确");
}
}
private void validateSkills(List<?> skills) {
if (skills.size() > MAX_SKILLS) {
throw new BusinessException("EasyFlow Skill manifest 最多包含 " + MAX_SKILLS + " 个 Skill");
}
java.util.Set<String> roots = new java.util.HashSet<>();
java.util.Set<String> bindingKeys = new java.util.HashSet<>();
int totalBindings = 0;
for (int skillIndex = 0; skillIndex < skills.size(); skillIndex++) {
String skillPath = "skills[" + skillIndex + "]";
Object source = skills.get(skillIndex);
if (!(source instanceof Map<?, ?> skill)) {
throw new SkillManifestValidationException("SKILL_INVALID", skillPath,
"EasyFlow Skill manifest 的 Skill 项格式不正确");
}
assertOnlyFields(skill, SKILL_FIELDS, skillPath);
String packageRoot = boundedRequiredString(
skill.get("packageRoot"), skillPath + ".packageRoot", MAX_PACKAGE_ROOT_LENGTH);
validatePortableMetadata(packageRoot, skillPath + ".packageRoot");
validatePortableMetadata(boundedRequiredString(
skill.get("packageHash"), skillPath + ".packageHash", 128),
skillPath + ".packageHash");
if (!roots.add(packageRoot)) {
throw new SkillManifestValidationException("PACKAGE_ROOT_DUPLICATE",
skillPath + ".packageRoot", "EasyFlow Skill manifest 存在重复 packageRoot");
}
Object capabilitiesValue = skill.get("capabilities");
if (!(capabilitiesValue instanceof List<?> capabilities)) {
throw new SkillManifestValidationException("CAPABILITIES_REQUIRED",
skillPath + ".capabilities", "EasyFlow Skill manifest 缺少 capabilities 列表");
}
if (capabilities.size() > MAX_BINDINGS_PER_SKILL) {
throw new BusinessException("单个 Skill 的能力绑定不能超过 " + MAX_BINDINGS_PER_SKILL + "");
}
totalBindings += capabilities.size();
if (totalBindings > MAX_TOTAL_BINDINGS) {
throw new BusinessException("EasyFlow Skill manifest 能力绑定总数不能超过 "
+ MAX_TOTAL_BINDINGS + "");
}
for (int bindingIndex = 0; bindingIndex < capabilities.size(); bindingIndex++) {
Object bindingValue = capabilities.get(bindingIndex);
String bindingPath = skillPath + ".capabilities[" + bindingIndex + "]";
if (!(bindingValue instanceof Map<?, ?> binding)) {
throw new SkillManifestValidationException("CAPABILITY_INVALID", bindingPath,
"EasyFlow Skill manifest 的能力绑定格式不正确");
}
assertOnlyFields(binding, BINDING_FIELDS, bindingPath);
String bindingKey = boundedRequiredString(
binding.get("bindingKey"), bindingPath + ".bindingKey", 256);
validatePortableMetadata(bindingKey, bindingPath + ".bindingKey");
if (!bindingKeys.add(bindingKey)) {
throw new SkillManifestValidationException("BINDING_KEY_DUPLICATE",
bindingPath + ".bindingKey", "EasyFlow Skill manifest 存在重复 bindingKey");
}
SkillCapabilityType type = parseCapabilityType(binding.get("capabilityType"), bindingPath);
String runtimeName = boundedRequiredString(
binding.get("runtimeName"), bindingPath + ".runtimeName", 64);
if (!RUNTIME_NAME_PATTERN.matcher(runtimeName).matches()) {
throw new SkillManifestValidationException("RUNTIME_NAME_INVALID",
bindingPath + ".runtimeName", "EasyFlow Skill manifest 的运行时名称格式不正确");
}
String logicalRef = boundedRequiredString(
binding.get("targetLogicalRef"), bindingPath + ".targetLogicalRef", 512);
validateLogicalRef(type, logicalRef, bindingPath + ".targetLogicalRef");
validateSelectionMode(boundedOptionalString(
binding.get("selectionMode"), bindingPath + ".selectionMode", 16), bindingPath);
validateExecutionMode(boundedOptionalString(
binding.get("executionMode"), bindingPath + ".executionMode", 16), bindingPath);
validatePortableMetadata(boundedOptionalString(
binding.get("targetStatus"), bindingPath + ".targetStatus", 32),
bindingPath + ".targetStatus");
validatePortableMetadata(boundedOptionalString(
binding.get("targetName"), bindingPath + ".targetName", 256),
bindingPath + ".targetName");
validatePortableMetadata(boundedOptionalString(
binding.get("targetRevision"), bindingPath + ".targetRevision", 256),
bindingPath + ".targetRevision");
validateBoolean(binding.get("enabled"), bindingPath + ".enabled");
validateBoolean(binding.get("hitlEnabled"), bindingPath + ".hitlEnabled");
validateInteger(binding.get("sortNo"), bindingPath + ".sortNo");
validateStringList(binding.get("selectedToolNames"), bindingPath + ".selectedToolNames");
validateSafeConfig(binding.get("hitlConfig"), true, bindingPath + ".hitlConfig");
validateSafeConfig(binding.get("options"), false, bindingPath + ".options");
}
}
}
private void assertOnlyFields(Map<?, ?> source, Set<String> allowed, String path) {
for (Object key : source.keySet()) {
if (!(key instanceof String field) || !allowed.contains(field)) {
throw new SkillManifestValidationException("FIELD_NOT_ALLOWED", path,
"EasyFlow Skill manifest 包含未允许字段");
}
}
}
private void validateBoolean(Object value, String field) {
if (value != null && !(value instanceof Boolean)) {
throw new BusinessException("EasyFlow Skill manifest 字段 " + field + " 类型不正确");
}
}
private void validateInteger(Object value, String field) {
if (value != null && (!(value instanceof Number number)
|| number.doubleValue() != number.longValue()
|| number.longValue() < Integer.MIN_VALUE || number.longValue() > Integer.MAX_VALUE)) {
throw new BusinessException("EasyFlow Skill manifest 字段 " + field + " 类型不正确");
}
}
private void validateStringList(Object value, String field) {
if (value == null) {
return;
}
if (!(value instanceof List<?> values) || values.size() > 200) {
throw new BusinessException("EasyFlow Skill manifest 字段 " + field + " 类型不正确或超过限制");
}
for (int index = 0; index < values.size(); index++) {
Object item = values.get(index);
if (!(item instanceof String text) || !MCP_TOOL_NAME_PATTERN.matcher(text).matches()) {
throw new SkillManifestValidationException("MCP_TOOL_NAME_INVALID",
field + "[" + index + "]", "EasyFlow Skill manifest 包含非法工具名");
}
}
}
private void validateLogicalRef(SkillCapabilityType type, String logicalRef, String path) {
if (!SkillPortableTargetSanitizer.isSafeLogicalRef(type, logicalRef)) {
throw new SkillManifestValidationException("TARGET_LOGICAL_REF_INVALID", path,
"EasyFlow Skill manifest 的目标逻辑引用格式不正确");
}
}
/**
* 校验 manifest 可移植元数据不含本机路径或认证材料。
*
* @param value 元数据值
* @param field 字段名
*/
private void validatePortableMetadata(String value, String field) {
if (SkillCredentialValueGuard.containsCredential(value)) {
throw new SkillManifestValidationException("SENSITIVE_VALUE_DETECTED", field,
"EasyFlow Skill manifest 不能包含认证凭据");
}
if (!SkillPortableTargetSanitizer.isSafePortableMetadata(value)) {
throw new BusinessException("EasyFlow Skill manifest 字段 " + field + " 包含不安全内容");
}
}
private void validateSafeConfig(Object value, boolean hitl, String field) {
if (value == null) {
return;
}
if (!(value instanceof Map<?, ?> raw)) {
throw new BusinessException("EasyFlow Skill manifest 字段 " + field + " 类型不正确");
}
Map<String, Object> source = new LinkedHashMap<>();
for (Map.Entry<?, ?> entry : raw.entrySet()) {
if (!(entry.getKey() instanceof String key)) {
throw new BusinessException("EasyFlow Skill manifest 字段 " + field + " 包含非法键");
}
source.put(key, entry.getValue());
}
Map<String, Object> safe = hitl
? SkillSensitiveConfigSanitizer.sanitizeHitl(source)
: SkillSensitiveConfigSanitizer.sanitizeOptions(source);
if (!safe.equals(source)) {
throw new BusinessException("EasyFlow Skill manifest 字段 " + field + " 包含未允许或敏感配置");
}
if (hitl) {
for (Map.Entry<String, Object> entry : safe.entrySet()) {
int maxLength = "confirmLabel".equals(entry.getKey()) || "cancelLabel".equals(entry.getKey())
? 128 : 2_000;
if (!(entry.getValue() instanceof String text) || text.length() > maxLength) {
throw new SkillManifestValidationException("HITL_CONFIG_VALUE_INVALID",
field + "." + entry.getKey(),
"EasyFlow Skill manifest 的 HITL 配置字段类型或长度不正确");
}
if (SkillCredentialValueGuard.containsCredential(text)) {
throw new SkillManifestValidationException("SENSITIVE_VALUE_DETECTED",
field + "." + entry.getKey(),
"EasyFlow Skill manifest 的 HITL 配置不能包含认证凭据");
}
}
return;
}
validateOptionValue(safe, "timeoutMs", field);
validateOptionValue(safe, "retryCount", field);
for (String key : List.of("async", "readOnly")) {
if (safe.containsKey(key) && !(safe.get(key) instanceof Boolean)) {
throw new SkillManifestValidationException("CAPABILITY_OPTION_VALUE_INVALID",
field + "." + key, "EasyFlow Skill manifest 的能力选项类型不正确");
}
}
}
/**
* 校验数值型能力选项。
*
* @param options 能力选项
* @param key 选项键
* @param path options 字段路径
*/
private void validateOptionValue(Map<String, Object> options,
String key,
String path) {
if (!options.containsKey(key)) {
return;
}
Object value = options.get(key);
// manifest 解码只负责结构、类型和安全边界;运行时值域由能力预览校验统一返回结构化问题。
boolean valid = value instanceof Number number
&& number.doubleValue() == number.longValue()
&& number.longValue() >= Integer.MIN_VALUE
&& number.longValue() <= Integer.MAX_VALUE;
if (!valid) {
throw new SkillManifestValidationException("CAPABILITY_OPTION_VALUE_INVALID",
path + "." + key, "EasyFlow Skill manifest 的能力选项数值不正确");
}
}
/**
* 解析能力类型并将不可信输入转换为稳定错误。
*
* @param value 原始类型值
* @param bindingPath 能力绑定路径
* @return 能力类型
*/
private SkillCapabilityType parseCapabilityType(Object value, String bindingPath) {
String path = bindingPath + ".capabilityType";
String type = boundedRequiredString(value, path, 32);
try {
return SkillCapabilityType.from(type);
} catch (BusinessException exception) {
throw new SkillManifestValidationException("CAPABILITY_TYPE_INVALID", path,
"EasyFlow Skill manifest 的能力类型不受支持");
}
}
/**
* 校验 MCP 工具选择模式且不回显原始值。
*
* @param value 模式值
* @param bindingPath 能力绑定路径
*/
private void validateSelectionMode(String value, String bindingPath) {
if (value == null || value.isBlank()) {
return;
}
try {
SkillCapabilitySelectionMode.fromOrDefault(value);
} catch (BusinessException exception) {
throw new SkillManifestValidationException("MCP_SELECTION_MODE_INVALID",
bindingPath + ".selectionMode", "EasyFlow Skill manifest 的 MCP 工具选择模式不受支持");
}
}
/**
* 校验能力执行模式且不回显原始值。
*
* @param value 模式值
* @param bindingPath 能力绑定路径
*/
private void validateExecutionMode(String value, String bindingPath) {
if (value == null || value.isBlank()) {
return;
}
try {
SkillCapabilityExecutionMode.fromOrDefault(value);
} catch (BusinessException exception) {
throw new SkillManifestValidationException("EXECUTION_MODE_INVALID",
bindingPath + ".executionMode", "EasyFlow Skill manifest 的执行模式不受支持");
}
}
/**
* 递归校验 manifest 中实际会携带的全部字符串值。
*
* @param value 当前值
* @param path 当前字段路径
*/
private void validateCredentialFreeTree(Object value, String path) {
Deque<ManifestNode> pending = new ArrayDeque<>();
pending.push(new ManifestNode(value, path));
while (!pending.isEmpty()) {
ManifestNode node = pending.pop();
if (node.value() instanceof String text) {
if (SkillCredentialValueGuard.containsCredential(text)) {
throw new SkillManifestValidationException("SENSITIVE_VALUE_DETECTED",
node.path().isBlank() ? "manifest" : node.path(),
"EasyFlow Skill manifest 不能包含认证凭据");
}
continue;
}
if (node.value() instanceof Map<?, ?> map) {
for (Map.Entry<?, ?> entry : map.entrySet()) {
if (entry.getKey() instanceof String key) {
String childPath = node.path().isBlank() ? key : node.path() + "." + key;
pending.push(new ManifestNode(entry.getValue(), childPath));
}
}
continue;
}
if (node.value() instanceof List<?> list) {
for (int index = list.size() - 1; index >= 0; index--) {
pending.push(new ManifestNode(list.get(index), node.path() + "[" + index + "]"));
}
}
}
}
/**
* manifest 迭代扫描节点。
*
* @param value 当前值
* @param path 当前路径
*/
private record ManifestNode(Object value, String path) {
}
private String boundedRequiredString(Object value, String field, int maxLength) {
String result = boundedOptionalString(value, field, maxLength);
if (result == null || result.isBlank()) {
throw new BusinessException("EasyFlow Skill manifest 缺少 " + field);
}
return result;
}
private String boundedOptionalString(Object value, String field, int maxLength) {
if (value == null) {
return null;
}
if (!(value instanceof String result) || result.length() > maxLength) {
throw new BusinessException("EasyFlow Skill manifest 字段 " + field + " 超过限制或类型不正确");
}
return result;
}
private Map<String, Object> bindingManifest(int skillIndex,
String packageRoot,
int index,
SkillCapabilityBinding binding) {
String bindingPath = "skills[" + skillIndex + "].capabilities[" + index + "]";
if (binding == null) {
throw new SkillManifestValidationException("CAPABILITY_EMPTY", bindingPath,
"EasyFlow Skill manifest 的能力绑定不能为空");
}
Map<String, Object> credentialSurface = new LinkedHashMap<>();
credentialSurface.put("capabilityType", binding.getCapabilityType());
credentialSurface.put("runtimeName", binding.getRuntimeName());
credentialSurface.put("selectionMode", binding.getSelectionMode());
credentialSurface.put("selectedToolNames", binding.getSelectedToolNamesJson());
credentialSurface.put("executionMode", binding.getExecutionMode());
validateCredentialFreeTree(credentialSurface, bindingPath);
SkillCapabilityType type = parseCapabilityType(binding.getCapabilityType(), bindingPath);
Map<String, Object> item = new LinkedHashMap<>();
item.put("bindingKey", packageRoot + ":" + index);
item.put("capabilityType", type.name());
item.put("runtimeName", binding.getRuntimeName());
item.put("enabled", binding.getEnabled());
item.put("selectionMode", binding.getSelectionMode());
item.put("selectedToolNames", binding.getSelectedToolNamesJson());
item.put("executionMode", binding.getExecutionMode());
item.put("hitlEnabled", binding.getHitlEnabled());
Map<String, Object> safeHitl = SkillSensitiveConfigSanitizer.sanitizeHitl(binding.getHitlConfigJson());
Map<String, Object> safeOptions = SkillSensitiveConfigSanitizer.sanitizeOptions(binding.getOptionsJson());
validateSafeConfig(safeHitl, true, bindingPath + ".hitlConfig");
validateSafeConfig(safeOptions, false, bindingPath + ".options");
item.put("hitlConfig", safeHitl);
item.put("options", safeOptions);
item.put("sortNo", binding.getSortNo());
String fallbackRef = SkillPortableTargetSanitizer.safeLogicalRefOrUnresolved(
type, binding.getTargetLogicalRef());
if (binding.getTargetId() == null || !Boolean.TRUE.equals(binding.getEnabled())) {
item.put("targetLogicalRef", fallbackRef);
item.put("targetStatus", binding.getTargetId() == null ? "UNRESOLVED" : "DISABLED");
return item;
}
try {
SkillCapabilityTarget target = targetAccessService.requireUsableTarget(binding, false);
item.put("targetLogicalRef", SkillPortableTargetSanitizer.safeLogicalRefOrUnresolved(
type, target.getLogicalRef()));
putSafeMetadata(item, "targetName", target.getName());
putSafeMetadata(item, "targetRevision", target.getRevision());
item.put("targetStatus", "AVAILABLE");
} catch (BusinessException exception) {
// 备份导出必须可用;目标会在导入映射或再次发布时重新校验。
item.put("targetLogicalRef", fallbackRef);
putSafeMetadata(item, "targetName", binding.getTargetName());
item.put("targetStatus", exception.getHttpStatus() == 403 ? "NO_PERMISSION" : "UNAVAILABLE");
}
return item;
}
/**
* 仅在目标元数据安全且非空时写入 manifest。
*
* @param target 目标字段映射
* @param field 字段名
* @param value 原始元数据
*/
private void putSafeMetadata(Map<String, Object> target, String field, String value) {
String safeValue = SkillPortableTargetSanitizer.safePortableMetadataOrNull(value);
if (safeValue != null) {
target.put(field, safeValue);
}
}
}

View File

@@ -0,0 +1,80 @@
package tech.easyflow.skill.imports;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
/**
* 已完整构建并校验的 Skill 导出临时产物。
*/
public final class SkillExportArtifact implements AutoCloseable {
private static final Logger LOG = LoggerFactory.getLogger(SkillExportArtifact.class);
private final Path path;
private final String fileName;
private final String mediaType;
/**
* 创建导出产物。
*
* @param path 临时文件
* @param fileName 下载文件名
* @param mediaType 媒体类型
*/
public SkillExportArtifact(Path path, String fileName, String mediaType) {
this.path = path;
this.fileName = fileName;
this.mediaType = mediaType;
}
/**
* 获取下载文件名。
*
* @return 文件名
*/
public String getFileName() {
return fileName;
}
/**
* 获取媒体类型。
*
* @return 媒体类型
*/
public String getMediaType() {
return mediaType;
}
/**
* 将已完成产物传输到响应流。
*
* @param outputStream 输出流
*/
public void transferTo(OutputStream outputStream) {
try (java.io.InputStream input = Files.newInputStream(path)) {
input.transferTo(outputStream);
outputStream.flush();
} catch (IOException exception) {
LOG.error("输出 Skill 导出文件失败path={}", path, exception);
throw new BusinessException(500, 500, "输出 Skill 导出文件失败", exception);
}
}
/**
* 清理临时产物。
*/
@Override
public void close() {
try {
Files.deleteIfExists(path);
} catch (IOException exception) {
LOG.warn("清理 Skill 导出临时产物失败path={}", path, exception);
}
}
}

View File

@@ -0,0 +1,19 @@
package tech.easyflow.skill.imports;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.List;
/**
* Skill 导出请求。
*/
public class SkillExportRequest {
private List<BigInteger> ids = new ArrayList<>();
private String format;
public List<BigInteger> getIds() { return ids; }
public void setIds(List<BigInteger> ids) { this.ids = ids == null ? new ArrayList<>() : new ArrayList<>(ids); }
public String getFormat() { return format; }
public void setFormat(String format) { this.format = format; }
}

View File

@@ -1,6 +1,5 @@
package tech.easyflow.skill.imports;
import java.io.OutputStream;
import java.math.BigInteger;
import java.util.Collection;
@@ -10,11 +9,11 @@ import java.util.Collection;
public interface SkillExportService {
/**
* 导出一个或多个 Skill 为标准 zip 包
* 在写入 HTTP 响应前完整构建导出临时产物
*
* @param skillIds Skill ID 集合
* @param outputStream zip 输出流
* @param format 导出格式
* @return 可自动清理的导出产物
*/
void exportZip(Collection<BigInteger> skillIds, OutputStream outputStream);
SkillExportArtifact prepare(Collection<BigInteger> skillIds, SkillImportFormat format);
}

View File

@@ -1,106 +1,349 @@
package tech.easyflow.skill.imports;
import com.easyagents.skill.util.SkillPaths;
import com.easyagents.skill.codec.SkillPackageWriteOptions;
import com.easyagents.skill.codec.ZipSkillPackageCodec;
import com.easyagents.skill.exception.SkillPackageException;
import com.easyagents.skill.model.SkillPackage;
import com.easyagents.skill.model.SkillPackageLayout;
import com.easyagents.skill.model.SkillPackageLimits;
import com.easyagents.skill.validation.SkillValidationIssue;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.skill.entity.Skill;
import tech.easyflow.skill.entity.SkillAsset;
import tech.easyflow.skill.entity.SkillReference;
import tech.easyflow.skill.entity.SkillScript;
import tech.easyflow.skill.service.SkillService;
import tech.easyflow.skill.store.DBSkillContentStore;
import tech.easyflow.skill.support.SkillModelConverter;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import java.util.zip.ZipOutputStream;
import java.util.zip.Deflater;
/**
* Skill zip 导出服务实现
* 标准 Skill ZIP 与 EasyFlow Bundle 安全导出服务。
*/
@Service
public class SkillExportServiceImpl implements SkillExportService {
private static final Logger LOG = LoggerFactory.getLogger(SkillExportServiceImpl.class);
private final SkillService skillService;
private final DBSkillContentStore contentStore;
private final EasyFlowSkillManifestCodec manifestCodec;
/**
* 创建 Skill 导出服务。
*
* @param skillService Skill 服务
* @param contentStore Skill asset 内容存储
* @param contentStore 二进制内容仓库
* @param manifestCodec EasyFlow manifest 编解码器
*/
public SkillExportServiceImpl(SkillService skillService, DBSkillContentStore contentStore) {
public SkillExportServiceImpl(SkillService skillService,
DBSkillContentStore contentStore,
EasyFlowSkillManifestCodec manifestCodec) {
this.skillService = skillService;
this.contentStore = contentStore;
this.manifestCodec = manifestCodec;
}
/**
* {@inheritDoc}
*/
@Override
public void exportZip(Collection<BigInteger> skillIds, OutputStream outputStream) {
public SkillExportArtifact prepare(Collection<BigInteger> skillIds, SkillImportFormat format) {
if (skillIds == null || skillIds.isEmpty()) {
throw new BusinessException("请选择要导出的 Skill");
}
try (ZipOutputStream zipOutputStream = new ZipOutputStream(outputStream, StandardCharsets.UTF_8)) {
Set<String> folderNames = new LinkedHashSet<>();
for (BigInteger skillId : skillIds) {
Skill skill = skillService.getDetail(skillId);
writeSkill(zipOutputStream, folderNames, skill);
SkillImportFormat effectiveFormat = format == null ? SkillImportFormat.STANDARD : format;
List<Skill> skills = loadAuthorizedSkills(
skillIds, effectiveFormat == SkillImportFormat.EASYFLOW);
Path standardPackage = null;
Path finalPackage = null;
try {
standardPackage = Files.createTempFile("easyflow-skill-standard-", ".zip");
encodeStandard(skills, standardPackage);
finalPackage = effectiveFormat == SkillImportFormat.STANDARD
? standardPackage : buildEasyFlowBundle(skills, standardPackage);
if (!finalPackage.equals(standardPackage)) {
deleteQuietly(standardPackage);
}
} catch (IOException e) {
throw new BusinessException("导出 Skill 失败");
String fileStem = skills.size() == 1 ? safeFileStem(skills.get(0).getName()) : "skills";
return new SkillExportArtifact(finalPackage,
fileStem + (effectiveFormat == SkillImportFormat.EASYFLOW ? ".efskill" : ".zip"),
effectiveFormat == SkillImportFormat.EASYFLOW
? "application/vnd.easyflow.skill+zip" : "application/zip");
} catch (BusinessException exception) {
deleteQuietly(finalPackage != null && !finalPackage.equals(standardPackage) ? finalPackage : null);
deleteQuietly(standardPackage);
throw exception;
} catch (SkillPackageException exception) {
deleteQuietly(finalPackage != null && !finalPackage.equals(standardPackage) ? finalPackage : null);
deleteQuietly(standardPackage);
throw mapPackageException(exception, effectiveFormat, skillIds);
} catch (Exception exception) {
deleteQuietly(finalPackage != null && !finalPackage.equals(standardPackage) ? finalPackage : null);
deleteQuietly(standardPackage);
LOG.error("导出 Skill 包失败format={}, skillIds={}", effectiveFormat, skillIds, exception);
throw new BusinessException(500, 500, "导出 Skill 包失败,请稍后重试", exception);
}
}
private void writeSkill(ZipOutputStream zipOutputStream, Set<String> folderNames, Skill skill) throws IOException {
String folder = uniqueFolderName(folderNames, skill.getName());
writeText(zipOutputStream, folder + "/" + SkillPaths.SKILL_FILE, skill.getSkillContent());
if (skill.getReferences() != null) {
for (SkillReference reference : skill.getReferences()) {
writeText(zipOutputStream, folder + "/" + reference.getPath(), reference.getContent());
private List<Skill> loadAuthorizedSkills(Collection<BigInteger> skillIds, boolean includeCapabilities) {
List<Skill> skills = new ArrayList<>();
Set<BigInteger> uniqueIds = new HashSet<>();
for (BigInteger skillId : skillIds) {
if (skillId != null && uniqueIds.add(skillId)) {
// 标准 ZIP 不读取平台能力目标;两个入口都在服务端逐项执行 Skill READ 权限校验。
skills.add(includeCapabilities
? skillService.getDetail(skillId)
: skillService.getPackageDetail(skillId));
}
}
if (skill.getScripts() != null) {
for (SkillScript script : skill.getScripts()) {
writeText(zipOutputStream, folder + "/" + script.getPath(), script.getContent());
}
if (skills.isEmpty()) {
throw new BusinessException("请选择有效的 Skill");
}
if (skill.getAssets() != null) {
for (SkillAsset asset : skill.getAssets()) {
zipOutputStream.putNextEntry(new ZipEntry(folder + "/" + asset.getPath()));
zipOutputStream.write(contentStore.readAllBytes(asset.getContentRef()));
zipOutputStream.closeEntry();
return skills;
}
private void encodeStandard(List<Skill> skills, Path target) throws IOException {
List<com.easyagents.skill.model.Skill> agentSkills = skills.stream()
.map(SkillModelConverter::toAgentSkill)
.toList();
SkillPackage skillPackage = new SkillPackage(
agentSkills.size() == 1 ? SkillPackageLayout.SINGLE_DIRECTORY : SkillPackageLayout.MULTI_DIRECTORY,
agentSkills);
try (OutputStream output = Files.newOutputStream(target, StandardOpenOption.TRUNCATE_EXISTING)) {
new ZipSkillPackageCodec(contentStore).encode(skillPackage, output, SkillPackageWriteOptions.defaults());
}
ensureStandardDirectoryEntries(target);
}
/**
* 为标准包中的每个 Skill 根目录补齐可移植的标准空目录项。
*
* @param packagePath 已由标准编解码器生成的 ZIP 路径
* @throws IOException 读取、重写或替换 ZIP 失败时抛出
*/
private void ensureStandardDirectoryEntries(Path packagePath) throws IOException {
Path rewritten = Files.createTempFile("easyflow-skill-directories-", ".zip");
Set<String> entryNames = new HashSet<>();
List<String> skillRoots = new ArrayList<>();
try {
try (ZipInputStream input = new ZipInputStream(
Files.newInputStream(packagePath), StandardCharsets.UTF_8);
ZipOutputStream output = new ZipOutputStream(
Files.newOutputStream(rewritten, StandardOpenOption.TRUNCATE_EXISTING),
StandardCharsets.UTF_8)) {
ZipEntry entry;
byte[] buffer = new byte[8192];
while ((entry = input.getNextEntry()) != null) {
String entryName = entry.getName();
entryNames.add(entryName);
if (entryName.endsWith("/SKILL.md")) {
skillRoots.add(entryName.substring(0, entryName.length() - "SKILL.md".length()));
} else if ("SKILL.md".equals(entryName)) {
skillRoots.add("");
}
ZipEntry copied = new ZipEntry(entryName);
copied.setTime(0L);
output.putNextEntry(copied);
if (!entry.isDirectory()) {
int length;
while ((length = input.read(buffer)) >= 0) {
if (length > 0) {
output.write(buffer, 0, length);
}
}
}
output.closeEntry();
}
for (String root : skillRoots) {
for (String directory : List.of("references/", "scripts/", "assets/")) {
String directoryPath = root + directory;
if (entryNames.add(directoryPath)) {
writeDirectoryEntry(output, directoryPath);
}
}
}
output.finish();
}
Files.move(rewritten, packagePath, StandardCopyOption.REPLACE_EXISTING);
} finally {
deleteQuietly(rewritten);
}
}
private void writeText(ZipOutputStream zipOutputStream, String path, String content) throws IOException {
zipOutputStream.putNextEntry(new ZipEntry(path));
zipOutputStream.write((content == null ? "" : content).getBytes(StandardCharsets.UTF_8));
zipOutputStream.closeEntry();
}
private String uniqueFolderName(Set<String> folderNames, String name) {
String base = sanitizeFolderName(name);
String candidate = base;
int index = 2;
while (!folderNames.add(candidate)) {
candidate = base + "-" + index++;
private Path buildEasyFlowBundle(List<Skill> skills, Path standardPackage) throws IOException {
SkillPackageLimits limits = SkillPackageLimits.defaults();
Path bundle = Files.createTempFile("easyflow-skill-bundle-", ".efskill");
try {
byte[] manifestBytes = manifestCodec.encode(skills);
long totalBytes = addExportBytes(0, manifestBytes.length, limits.getMaxTotalUncompressedBytes());
int entryCount = 1;
try (ZipOutputStream output = new ZipOutputStream(
Files.newOutputStream(bundle, StandardOpenOption.TRUNCATE_EXISTING), StandardCharsets.UTF_8)) {
// 禁用二次高比率压缩,保证成功导出的外层 Bundle 能通过同一导入压缩比门禁。
output.setLevel(Deflater.NO_COMPRESSION);
writeEntry(output, EasyFlowSkillManifestCodec.MANIFEST_PATH, manifestBytes);
try (ZipInputStream input = new ZipInputStream(
Files.newInputStream(standardPackage), StandardCharsets.UTF_8)) {
ZipEntry entry;
byte[] buffer = new byte[8192];
while ((entry = input.getNextEntry()) != null) {
if (++entryCount > limits.getMaxEntryCount() + 1) {
throw exportLimit("EasyFlow Skill 包文件数量超过限制");
}
String targetPath = "skills/" + entry.getName();
if (targetPath.length() > limits.getMaxPathLength()
|| targetPath.split("/").length > limits.getMaxPathDepth()) {
throw exportLimit("EasyFlow Skill 包路径长度或层级超过限制");
}
ZipEntry targetEntry = new ZipEntry(targetPath);
targetEntry.setTime(0L);
output.putNextEntry(targetEntry);
if (!entry.isDirectory()) {
int length;
while ((length = input.read(buffer)) >= 0) {
if (length == 0) {
continue;
}
totalBytes = addExportBytes(
totalBytes, length, limits.getMaxTotalUncompressedBytes());
output.write(buffer, 0, length);
}
}
output.closeEntry();
}
}
output.finish();
}
if (Files.size(bundle) > limits.getMaxCompressedPackageBytes()) {
throw exportLimit("EasyFlow Skill 包压缩文件超过限制");
}
return bundle;
} catch (RuntimeException | IOException exception) {
deleteQuietly(bundle);
throw exception;
}
return candidate;
}
private String sanitizeFolderName(String value) {
String sanitized = value == null ? "skill" : value.trim().replaceAll("[\\\\/:*?\"<>|\\s]+", "-");
sanitized = sanitized.replaceAll("^-+", "").replaceAll("-+$", "");
return sanitized.isBlank() ? "skill" : sanitized;
private long addExportBytes(long current, long increment, long limit) {
if (increment < 0 || current > limit - increment) {
throw exportLimit("EasyFlow Skill 包解压总大小超过限制");
}
return current + increment;
}
private BusinessException exportLimit(String message) {
return new BusinessException(413, 4131, message);
}
private BusinessException mapPackageException(SkillPackageException exception,
SkillImportFormat format,
Collection<BigInteger> skillIds) {
List<String> codes = new ArrayList<>();
codes.add(exception.getCode() == null ? "SKILL_PACKAGE_FAILED" : exception.getCode());
if (exception.getReport() != null) {
exception.getReport().getIssues().stream()
.map(SkillValidationIssue::getCode)
.forEach(codes::add);
}
if (codes.stream().anyMatch(code -> Set.of(
"ZIP_IO_ERROR", "CONTENT_STORE_ERROR", "CONTENT_NOT_FOUND", "SKILL_CONTENT_STORE_ERROR",
"SKILL_CONTENT_ROLLBACK_ERROR", "CONTENT_REF_MISMATCH",
"RESOURCE_SIZE_MISMATCH", "RESOURCE_HASH_MISMATCH", "CRC_MISMATCH")
.contains(code))) {
LOG.error("导出 Skill 包内部失败format={}, skillIds={}, code={}, path={}",
format, skillIds, exception.getCode(), exception.getPath(), exception);
return new BusinessException(500, 500, "导出 Skill 包失败,请稍后重试", exception);
}
if (codes.stream().anyMatch(code -> code != null && (code.endsWith("_LIMIT")
|| code.contains("SIZE_LIMIT")))) {
return new BusinessException(413, 4131,
"Skill 包超过导出限制:" + firstPackageMessage(exception), exception);
}
return new BusinessException(400, 4001,
"Skill 包不符合导出规范:" + firstPackageMessage(exception), exception);
}
/**
* 读取结构化报告中的首个可执行错误消息。
*
* @param exception M18 包异常
* @return 错误消息
*/
private String firstPackageMessage(SkillPackageException exception) {
if (exception.getReport() != null) {
return exception.getReport().getIssues().stream()
.map(SkillValidationIssue::getMessage)
.filter(message -> message != null && !message.isBlank())
.findFirst()
.orElse("Skill 包校验失败");
}
return exception.getMessage() == null || exception.getMessage().isBlank()
? "Skill 包校验失败" : exception.getMessage();
}
private void writeEntry(ZipOutputStream output, String path, byte[] bytes) throws IOException {
ZipEntry entry = new ZipEntry(path);
entry.setTime(0L);
output.putNextEntry(entry);
output.write(bytes);
output.closeEntry();
}
/**
* 写入确定时间戳的 ZIP 目录项。
*
* @param output ZIP 输出流
* @param path 以斜杠结尾的目录路径
* @throws IOException 写入目录项失败时抛出
*/
private void writeDirectoryEntry(ZipOutputStream output, String path) throws IOException {
ZipEntry entry = new ZipEntry(path.endsWith("/") ? path : path + "/");
entry.setTime(0L);
output.putNextEntry(entry);
output.closeEntry();
}
private String safeFileStem(String value) {
if (value == null || value.isBlank()) {
return "skill";
}
String normalized = java.text.Normalizer.normalize(value, java.text.Normalizer.Form.NFKC)
.toLowerCase(java.util.Locale.ROOT)
.replaceAll("[^a-z0-9_-]+", "-")
.replaceAll("^-+|-+$", "");
if (normalized.isBlank()) {
return "skill";
}
return normalized.substring(0, Math.min(normalized.length(), 80));
}
private void deleteQuietly(Path path) {
if (path == null) {
return;
}
try {
Files.deleteIfExists(path);
} catch (IOException exception) {
LOG.warn("清理 Skill 导出临时文件失败path={}", path, exception);
}
}
}

View File

@@ -0,0 +1,35 @@
package tech.easyflow.skill.imports;
import java.math.BigInteger;
/**
* EasyFlow Bundle 能力目标映射项。
*/
public class SkillImportCapabilityMapping {
private String bindingKey;
private String packageRoot;
private String capabilityType;
private String targetLogicalRef;
private String targetName;
private String status;
private BigInteger targetId;
private boolean disabled;
public String getBindingKey() { return bindingKey; }
public void setBindingKey(String bindingKey) { this.bindingKey = bindingKey; }
public String getPackageRoot() { return packageRoot; }
public void setPackageRoot(String packageRoot) { this.packageRoot = packageRoot; }
public String getCapabilityType() { return capabilityType; }
public void setCapabilityType(String capabilityType) { this.capabilityType = capabilityType; }
public String getTargetLogicalRef() { return targetLogicalRef; }
public void setTargetLogicalRef(String targetLogicalRef) { this.targetLogicalRef = targetLogicalRef; }
public String getTargetName() { return targetName; }
public void setTargetName(String targetName) { this.targetName = targetName; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public BigInteger getTargetId() { return targetId; }
public void setTargetId(BigInteger targetId) { this.targetId = targetId; }
public boolean isDisabled() { return disabled; }
public void setDisabled(boolean disabled) { this.disabled = disabled; }
}

View File

@@ -0,0 +1,15 @@
package tech.easyflow.skill.imports;
import java.math.BigInteger;
/**
* 导入确认阶段的窄能力映射请求。
*
* @param bindingKey manifest 中的能力绑定键
* @param targetId 当前环境目标 ID禁用时为空
* @param disabled 是否保持未映射并禁用
*/
public record SkillImportCapabilityOverride(String bindingKey,
BigInteger targetId,
boolean disabled) {
}

View File

@@ -0,0 +1,30 @@
package tech.easyflow.skill.imports;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Skill 导入确认请求。
*/
public class SkillImportConfirmRequest {
private String importToken;
private BigInteger categoryId;
private String conflictStrategy;
private Map<String, String> renames = new LinkedHashMap<>();
private List<SkillImportCapabilityOverride> capabilityMappings = new ArrayList<>();
public String getImportToken() { return importToken; }
public void setImportToken(String importToken) { this.importToken = importToken; }
public BigInteger getCategoryId() { return categoryId; }
public void setCategoryId(BigInteger categoryId) { this.categoryId = categoryId; }
public String getConflictStrategy() { return conflictStrategy; }
public void setConflictStrategy(String conflictStrategy) { this.conflictStrategy = conflictStrategy; }
public Map<String, String> getRenames() { return renames; }
public void setRenames(Map<String, String> renames) { this.renames = renames == null ? new LinkedHashMap<>() : new LinkedHashMap<>(renames); }
public List<SkillImportCapabilityOverride> getCapabilityMappings() { return capabilityMappings; }
public void setCapabilityMappings(List<SkillImportCapabilityOverride> capabilityMappings) { this.capabilityMappings = capabilityMappings == null ? new ArrayList<>() : new ArrayList<>(capabilityMappings); }
}

View File

@@ -0,0 +1,31 @@
package tech.easyflow.skill.imports;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.util.Locale;
/**
* Skill 导入同名冲突策略。
*/
public enum SkillImportConflictStrategy {
REJECT,
RENAME,
OVERWRITE;
/**
* 解析冲突策略,空值默认拒绝。
*
* @param value 策略编码
* @return 冲突策略
*/
public static SkillImportConflictStrategy fromOrDefault(String value) {
if (value == null || value.isBlank()) {
return REJECT;
}
try {
return valueOf(value.trim().toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException exception) {
throw new BusinessException("不支持的 Skill 导入冲突策略:" + value);
}
}
}

View File

@@ -0,0 +1,30 @@
package tech.easyflow.skill.imports;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.util.Locale;
/**
* Skill 导入导出格式。
*/
public enum SkillImportFormat {
STANDARD,
EASYFLOW;
/**
* 解析格式编码。
*
* @param value 格式编码
* @return 格式
*/
public static SkillImportFormat from(String value) {
if (value == null || value.isBlank()) {
return STANDARD;
}
try {
return valueOf(value.trim().toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException exception) {
throw new BusinessException("不支持的 Skill 包格式:" + value);
}
}
}

View File

@@ -2,6 +2,8 @@ package tech.easyflow.skill.imports;
import java.util.ArrayList;
import java.util.List;
import java.util.Date;
import tech.easyflow.skill.validation.SkillValidationIssue;
/**
* Skill 导入预览结果。
@@ -9,6 +11,11 @@ import java.util.List;
public class SkillImportPreview {
private List<SkillImportPreviewItem> skills = new ArrayList<>();
private String importToken;
private String format;
private Date expiresAt;
private List<SkillImportCapabilityMapping> capabilityMappings = new ArrayList<>();
private List<SkillValidationIssue> issues = new ArrayList<>();
/**
* 获取导入 Skill 预览项。
@@ -27,5 +34,15 @@ public class SkillImportPreview {
public void setSkills(List<SkillImportPreviewItem> skills) {
this.skills = skills == null ? new ArrayList<>() : skills;
}
}
public String getImportToken() { return importToken; }
public void setImportToken(String importToken) { this.importToken = importToken; }
public String getFormat() { return format; }
public void setFormat(String format) { this.format = format; }
public Date getExpiresAt() { return expiresAt; }
public void setExpiresAt(Date expiresAt) { this.expiresAt = expiresAt; }
public List<SkillImportCapabilityMapping> getCapabilityMappings() { return capabilityMappings; }
public void setCapabilityMappings(List<SkillImportCapabilityMapping> capabilityMappings) { this.capabilityMappings = capabilityMappings == null ? new ArrayList<>() : new ArrayList<>(capabilityMappings); }
public List<SkillValidationIssue> getIssues() { return issues; }
public void setIssues(List<SkillValidationIssue> issues) { this.issues = issues == null ? new ArrayList<>() : new ArrayList<>(issues); }
}

View File

@@ -0,0 +1,103 @@
package tech.easyflow.skill.imports;
/**
* Skill 导入预览中的逻辑文件摘要,不包含文件正文、存储引用或物理路径。
*/
public class SkillImportPreviewFile {
private String path;
private String kind;
private String mediaType;
private boolean text;
private long size;
/**
* 获取 Skill 根目录内的规范相对路径。
*
* @return 逻辑相对路径
*/
public String getPath() {
return path;
}
/**
* 设置 Skill 根目录内的规范相对路径。
*
* @param path 逻辑相对路径
*/
public void setPath(String path) {
this.path = path;
}
/**
* 获取文件语义类型。
*
* @return 文件语义类型
*/
public String getKind() {
return kind;
}
/**
* 设置文件语义类型。
*
* @param kind 文件语义类型
*/
public void setKind(String kind) {
this.kind = kind;
}
/**
* 获取媒体类型。
*
* @return 媒体类型
*/
public String getMediaType() {
return mediaType;
}
/**
* 设置媒体类型。
*
* @param mediaType 媒体类型
*/
public void setMediaType(String mediaType) {
this.mediaType = mediaType;
}
/**
* 判断文件是否为严格 UTF-8 文本。
*
* @return 文本文件时为 true
*/
public boolean isText() {
return text;
}
/**
* 设置文本标记。
*
* @param text 是否为严格 UTF-8 文本
*/
public void setText(boolean text) {
this.text = text;
}
/**
* 获取文件字节数。
*
* @return 文件字节数
*/
public long getSize() {
return size;
}
/**
* 设置文件字节数。
*
* @param size 文件字节数
*/
public void setSize(long size) {
this.size = size;
}
}

View File

@@ -1,5 +1,8 @@
package tech.easyflow.skill.imports;
import java.util.ArrayList;
import java.util.List;
/**
* Skill 导入预览项。
*/
@@ -12,6 +15,12 @@ public class SkillImportPreviewItem {
private int scriptCount;
private int assetCount;
private boolean conflict;
private Boolean overwriteAllowed;
private String conflictReason;
private String packageRoot;
private int resourceCount;
private String packageHash;
private List<SkillImportPreviewFile> files = new ArrayList<>();
/**
* 获取包内 Skill ID。
@@ -43,5 +52,56 @@ public class SkillImportPreviewItem {
public void setAssetCount(int assetCount) { this.assetCount = assetCount; }
public boolean isConflict() { return conflict; }
public void setConflict(boolean conflict) { this.conflict = conflict; }
}
/**
* 获取当前用户是否允许覆盖同名 Skill。
*
* @return 存在冲突时的覆盖许可;无冲突时为空
*/
public Boolean getOverwriteAllowed() { return overwriteAllowed; }
/**
* 设置当前用户是否允许覆盖同名 Skill。
*
* @param overwriteAllowed 覆盖许可
*/
public void setOverwriteAllowed(Boolean overwriteAllowed) { this.overwriteAllowed = overwriteAllowed; }
/**
* 获取禁止覆盖的原因编码。
*
* @return 原因编码;允许覆盖或无冲突时为空
*/
public String getConflictReason() { return conflictReason; }
/**
* 设置禁止覆盖的原因编码。
*
* @param conflictReason 原因编码
*/
public void setConflictReason(String conflictReason) { this.conflictReason = conflictReason; }
public String getPackageRoot() { return packageRoot; }
public void setPackageRoot(String packageRoot) { this.packageRoot = packageRoot; }
public int getResourceCount() { return resourceCount; }
public void setResourceCount(int resourceCount) { this.resourceCount = resourceCount; }
public String getPackageHash() { return packageHash; }
public void setPackageHash(String packageHash) { this.packageHash = packageHash; }
/**
* 获取包内逻辑文件摘要。
*
* @return 按规范路径排序的文件摘要
*/
public List<SkillImportPreviewFile> getFiles() {
return files;
}
/**
* 设置包内逻辑文件摘要。
*
* @param files 文件摘要
*/
public void setFiles(List<SkillImportPreviewFile> files) {
this.files = files == null ? new ArrayList<>() : new ArrayList<>(files);
}
}

View File

@@ -2,9 +2,8 @@ package tech.easyflow.skill.imports;
import tech.easyflow.skill.entity.Skill;
import java.io.InputStream;
import java.math.BigInteger;
import java.util.List;
import org.springframework.web.multipart.MultipartFile;
/**
* Skill zip 导入服务。
@@ -12,21 +11,26 @@ import java.util.List;
public interface SkillImportService {
/**
* 预览 zip 中的 Skill 包
* 上传并创建可单次确认的导入预览
*
* @param inputStream zip 输入流
* @return 导入预览
* @param file 标准 ZIP 或 .efskill
* @return 导入预览与 importToken
*/
SkillImportPreview preview(InputStream inputStream);
SkillImportPreview preview(MultipartFile file);
/**
* 确认导入 zip 中的 Skill 包
* 使用单次 importToken 确认导入。
*
* @param inputStream zip 输入流
* @param categoryId 目标分类 ID可为空
* @param overwriteDraft 是否覆盖同名草稿
* @return 已保存 Skill 列表
* @param request 导入确认请求
* @return 已保存 Skill
*/
List<Skill> importZip(InputStream inputStream, BigInteger categoryId, boolean overwriteDraft);
}
List<Skill> confirm(SkillImportConfirmRequest request);
/**
* 取消导入预览并清理临时包。
*
* @param importToken 导入令牌
*/
void cancel(String importToken);
}

View File

@@ -0,0 +1,263 @@
package tech.easyflow.skill.imports;
import com.alicp.jetcache.AutoReleaseLock;
import com.alicp.jetcache.Cache;
import com.mybatisflex.core.query.QueryWrapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.annotation.Propagation;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.filestorage.FileStorageService;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.skill.entity.SkillImportStage;
import tech.easyflow.skill.mapper.SkillImportStageMapper;
import java.math.BigInteger;
import java.time.Duration;
import java.util.Date;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
/**
* 基于数据库临时索引、JetCache 单次锁与文件存储的 Skill 导入会话仓库。
*/
@Service
public class SkillImportStageStore {
private static final Logger LOG = LoggerFactory.getLogger(SkillImportStageStore.class);
private static final Duration SESSION_TTL = Duration.ofMinutes(30);
private static final Duration PROCESSING_TTL = Duration.ofHours(2);
private static final String CACHE_PREFIX = "skill:import:";
private final Cache<String, Object> defaultCache;
private final SkillImportStageMapper stageMapper;
private final FileStorageService fileStorageService;
/**
* 创建 Skill 导入会话仓库。
*
* @param defaultCache 平台默认缓存
* @param stageMapper 临时包 Mapper
* @param fileStorageService 文件存储
*/
public SkillImportStageStore(@Qualifier("defaultCache") Cache<String, Object> defaultCache,
SkillImportStageMapper stageMapper,
@Qualifier("default") FileStorageService fileStorageService) {
this.defaultCache = defaultCache;
this.stageMapper = stageMapper;
this.fileStorageService = fileStorageService;
}
/**
* 登记临时包并返回单次令牌。
*
* @param filePath 临时包存储路径
* @param originalName 原始文件名
* @param format 包格式
* @return 临时包索引
*/
@Transactional(rollbackFor = Exception.class)
public SkillImportStage create(String filePath, String originalName, SkillImportFormat format) {
LoginAccount account = requireAccount();
Date now = new Date();
SkillImportStage stage = new SkillImportStage();
stage.setImportToken(UUID.randomUUID().toString().replace("-", ""));
stage.setTenantId(account.getTenantId());
stage.setAccountId(account.getId());
stage.setFilePath(filePath);
stage.setOriginalName(originalName);
stage.setFormat(format.name());
stage.setStatus("PENDING");
stage.setCreated(now);
stage.setExpiresAt(new Date(now.getTime() + SESSION_TTL.toMillis()));
if (stageMapper.insert(stage) != 1) {
throw new BusinessException(500, 500, "创建 Skill 导入会话失败,请稍后重试");
}
// 缓存只保存小型索引;完整包始终留在受控文件存储中。
defaultCache.put(cacheKey(stage.getImportToken()), stage, SESSION_TTL.toMinutes(), TimeUnit.MINUTES);
return stage;
}
/**
* 原子消费导入令牌。令牌一旦消费,即使业务导入失败也不能重复执行。
*
* @param token 导入令牌
* @return 被消费的临时包索引
*/
@Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)
public SkillImportStage consume(String token) {
validateToken(token);
LoginAccount account = requireAccount();
try (AutoReleaseLock lock = defaultCache.tryLock(lockKey(token), 60, TimeUnit.SECONDS)) {
if (lock == null) {
throw new BusinessException("Skill 导入正在处理中,请勿重复提交");
}
SkillImportStage stage = findOwnedStage(token, account);
assertOwner(stage, account);
Date now = new Date();
Date processingExpiresAt = new Date(now.getTime() + PROCESSING_TTL.toMillis());
if (stageMapper.consume(token, account.getTenantId(), account.getId(), now, processingExpiresAt) != 1) {
throw new BusinessException("Skill 导入令牌已过期或已被使用,请重新预览");
}
stage.setStatus("PROCESSING");
stage.setExpiresAt(processingExpiresAt);
defaultCache.remove(cacheKey(token));
return stage;
}
}
/**
* 取消尚未消费的导入会话并释放临时包。
*
* @param token 导入令牌
*/
public void cancel(String token) {
validateToken(token);
LoginAccount account = requireAccount();
try (AutoReleaseLock lock = defaultCache.tryLock(lockKey(token), 30, TimeUnit.SECONDS)) {
if (lock == null) {
throw new BusinessException("Skill 导入正在处理中,暂时无法取消");
}
SkillImportStage stage = findOwnedStage(token, account);
assertOwner(stage, account);
if (!"PENDING".equals(stage.getStatus())) {
throw new BusinessException("Skill 导入正在处理中,不能取消");
}
Date now = new Date();
if (stageMapper.beginCancel(token, account.getTenantId(), account.getId(), now) != 1) {
throw new BusinessException("Skill 导入状态已变化,请刷新后重试");
}
stage.setStatus("PROCESSING");
stage.setExpiresAt(now);
defaultCache.remove(cacheKey(token));
deleteFile(stage);
if (stageMapper.finishCancel(token, account.getTenantId(), account.getId()) != 1) {
throw new BusinessException("Skill 导入状态已变化,请刷新后重试");
}
}
}
/**
* 完成导入后释放临时包和索引。
*
* @param stage 临时包索引
*/
public void complete(SkillImportStage stage) {
if (stage != null) {
cleanup(stage);
}
}
/**
* 定时清理过期或已消费但未完成清理的临时包。
*/
@Scheduled(fixedDelayString = "${easyflow.skill.import-cleanup-delay-ms:300000}")
public void cleanupExpired() {
List<SkillImportStage> expired = stageMapper.selectListByQuery(QueryWrapper.create()
.le(SkillImportStage::getExpiresAt, new Date())
.orderBy("expires_at asc")
.limit(100));
for (SkillImportStage stage : expired) {
try (AutoReleaseLock lock = defaultCache.tryLock(lockKey(stage.getImportToken()), 30, TimeUnit.SECONDS)) {
if (lock == null) {
continue;
}
SkillImportStage current = stageMapper.selectOneById(stage.getImportToken());
if (current != null && current.getExpiresAt() != null && !current.getExpiresAt().after(new Date())) {
cleanup(current);
}
} catch (RuntimeException exception) {
LOG.error("清理过期 Skill 导入临时包失败token={}", stage.getImportToken(), exception);
}
}
}
private void cleanup(SkillImportStage stage) {
deleteFile(stage);
stageMapper.deleteById(stage.getImportToken());
defaultCache.remove(cacheKey(stage.getImportToken()));
}
private void deleteFile(SkillImportStage stage) {
try {
fileStorageService.delete(stage.getFilePath());
} catch (RuntimeException exception) {
if (isFileAlreadyAbsent(exception)) {
return;
}
LOG.error("删除 Skill 导入临时包失败token={}, path={}",
stage.getImportToken(), stage.getFilePath(), exception);
throw new BusinessException(500, 500, "清理 Skill 导入临时包失败,请稍后重试", exception);
}
}
/**
* 判断存储异常是否表示目标文件已经不存在。
*
* @param exception 存储删除异常
* @return 文件已不存在时为 true
*/
private boolean isFileAlreadyAbsent(RuntimeException exception) {
Throwable current = exception;
while (current != null) {
if (current instanceof java.io.FileNotFoundException
|| current instanceof java.nio.file.NoSuchFileException) {
return true;
}
current = current.getCause();
}
return false;
}
private void assertOwner(SkillImportStage stage, LoginAccount account) {
if (stage == null) {
throw new BusinessException(404, 404, "Skill 导入令牌不存在或已过期");
}
if (!account.getId().equals(stage.getAccountId()) || !account.getTenantId().equals(stage.getTenantId())) {
throw new BusinessException(403, 403, "无权限使用该 Skill 导入令牌");
}
if (stage.getExpiresAt() == null || !stage.getExpiresAt().after(new Date())) {
throw new BusinessException("Skill 导入令牌已过期,请重新预览");
}
}
private LoginAccount requireAccount() {
LoginAccount account = SaTokenUtil.getLoginAccount();
if (account == null || account.getId() == null || account.getTenantId() == null) {
throw new BusinessException(401, 401, "未登录或登录态无效");
}
return account;
}
private void validateToken(String token) {
if (token == null || !token.matches("^[a-fA-F0-9]{32}$")) {
throw new BusinessException("Skill 导入令牌格式不正确");
}
}
private SkillImportStage findOwnedStage(String token, LoginAccount account) {
SkillImportStage stage = stageMapper.selectOneByQuery(QueryWrapper.create()
.eq(SkillImportStage::getImportToken, token)
.eq(SkillImportStage::getTenantId, account.getTenantId())
.eq(SkillImportStage::getAccountId, account.getId()));
if (stage == null && stageMapper.selectCountByQuery(QueryWrapper.create()
.eq(SkillImportStage::getImportToken, token)) > 0) {
throw new BusinessException(403, 403, "无权限使用该 Skill 导入令牌");
}
return stage;
}
private String cacheKey(String token) {
return CACHE_PREFIX + token;
}
private String lockKey(String token) {
return CACHE_PREFIX + "lock:" + token;
}
}

View File

@@ -0,0 +1,45 @@
package tech.easyflow.skill.imports;
import tech.easyflow.common.web.exceptions.BusinessException;
/**
* 携带结构化问题码和字段路径的 EasyFlow Skill manifest 校验异常。
*/
public class SkillManifestValidationException extends BusinessException {
private static final long serialVersionUID = 1L;
private final String validationCode;
private final String path;
/**
* 创建 manifest 校验异常。
*
* @param validationCode 稳定问题码
* @param path manifest 字段路径
* @param message 不包含原始敏感值的安全消息
*/
public SkillManifestValidationException(String validationCode, String path, String message) {
super(400, 4001, message);
this.validationCode = validationCode;
this.path = path;
}
/**
* 获取稳定问题码。
*
* @return 问题码
*/
public String getValidationCode() {
return validationCode;
}
/**
* 获取 manifest 字段路径。
*
* @return 字段路径
*/
public String getPath() {
return path;
}
}

View File

@@ -1,10 +0,0 @@
package tech.easyflow.skill.mapper;
import com.mybatisflex.core.BaseMapper;
import tech.easyflow.skill.entity.SkillAssetContent;
/**
* Skill asset 内容索引 Mapper。
*/
public interface SkillAssetContentMapper extends BaseMapper<SkillAssetContent> {
}

View File

@@ -1,10 +0,0 @@
package tech.easyflow.skill.mapper;
import com.mybatisflex.core.BaseMapper;
import tech.easyflow.skill.entity.SkillAsset;
/**
* Skill asset Mapper。
*/
public interface SkillAssetMapper extends BaseMapper<SkillAsset> {
}

View File

@@ -0,0 +1,10 @@
package tech.easyflow.skill.mapper;
import com.mybatisflex.core.BaseMapper;
import tech.easyflow.skill.entity.SkillCapabilityBinding;
/**
* Skill 能力绑定 Mapper。
*/
public interface SkillCapabilityBindingMapper extends BaseMapper<SkillCapabilityBinding> {
}

View File

@@ -1,10 +1,27 @@
package tech.easyflow.skill.mapper;
import com.mybatisflex.core.BaseMapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import tech.easyflow.skill.entity.SkillCategory;
import java.math.BigInteger;
import java.util.List;
/**
* Skill 分类 Mapper。
*/
public interface SkillCategoryMapper extends BaseMapper<SkillCategory> {
/**
* 按稳定顺序锁定租户内完整分类树,串行化分类结构变更。
*
* @param tenantId 租户 ID
* @return 已锁定的分类列表
*/
@Select("SELECT id,tenant_id AS tenantId,parent_id AS parentId,category_name AS categoryName," +
"level_no AS levelNo,ancestors,sort_no AS sortNo,status,created,created_by AS createdBy," +
"modified,modified_by AS modifiedBy FROM tb_skill_category " +
"WHERE tenant_id=#{tenantId} ORDER BY id FOR UPDATE")
List<SkillCategory> selectTenantTreeForUpdate(@Param("tenantId") BigInteger tenantId);
}

View File

@@ -0,0 +1,238 @@
package tech.easyflow.skill.mapper;
import com.mybatisflex.core.BaseMapper;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import tech.easyflow.skill.entity.SkillContent;
import java.util.Date;
import java.util.List;
/**
* Skill 二进制内容 Mapper。
*/
public interface SkillContentMapper extends BaseMapper<SkillContent> {
/**
* 原子增加与内容引用、大小及哈希均匹配的正式内容引用计数。
*
* <p>storage_locator 允许为 null 仅用于兼容迁移前正式内容;空定位符与旧 PENDING 路径
* 均不会被视为活动内容。</p>
*
* @param contentRef 内容引用
* @param size 内容字节数
* @return 更新行数
*/
@Update("UPDATE tb_skill_content SET ref_count=ref_count+1,modified=CURRENT_TIMESTAMP "
+ "WHERE content_ref=#{contentRef} AND size=#{size} "
+ "AND CONCAT('sha256:',content_hash)=#{contentRef} AND ref_count>0 "
+ "AND file_path IS NOT NULL AND file_path<>'' AND file_path NOT LIKE '__PENDING__:%' "
+ "AND (storage_locator IS NULL OR storage_locator<>'')")
int retainMatching(@Param("contentRef") String contentRef, @Param("size") long size);
/**
* 以当前读方式锁定并返回指定内容索引。
*
* <p>该查询用于引用计数状态转换,避免 MySQL REPEATABLE READ 下普通一致性读反复返回
* 调用方事务早先建立的旧快照。</p>
*
* @param contentRef 内容引用
* @return 当前内容索引;不存在时为 null
*/
@Select("SELECT content_ref AS contentRef,content_hash AS contentHash,file_path AS filePath," +
"storage_locator AS storageLocator,media_type AS mediaType,size,ref_count AS refCount,created,modified "
+ "FROM tb_skill_content WHERE content_ref=#{contentRef} FOR UPDATE")
SkillContent selectForUpdate(@Param("contentRef") String contentRef);
/**
* 将已经完成物理校验的旧版零引用内容恢复为一份活动引用。
*
* <p>仅允许恢复缺少稳定定位符的迁移前内容;读取路径、哈希、大小与零引用状态均须保持
* 锁定读取时的值,避免复活正在由新流程清理的可恢复对象。</p>
*
* @param contentRef 内容引用
* @param contentHash 内容哈希
* @param filePath 已校验的旧版读取路径
* @param size 内容字节数
* @return 成功恢复为 1状态已变化为 0
*/
@Update("UPDATE tb_skill_content SET ref_count=1,modified=CURRENT_TIMESTAMP "
+ "WHERE content_ref=#{contentRef} AND content_hash=#{contentHash} "
+ "AND file_path=#{filePath} AND size=#{size} AND ref_count=0 "
+ "AND storage_locator IS NULL AND file_path IS NOT NULL AND file_path<>'' "
+ "AND file_path NOT LIKE '__PENDING__:%'")
int resurrectVerifiedLegacy(@Param("contentRef") String contentRef,
@Param("contentHash") String contentHash,
@Param("filePath") String filePath,
@Param("size") long size);
/**
* 插入首个引用已经激活的正式内容索引。
*
* <p>新流程必须同时提供非空读取路径和稳定存储定位符,并保证内容引用与哈希一致。</p>
*
* @param contentRef 内容引用
* @param contentHash 内容哈希
* @param filePath 文件读取路径
* @param storageLocator 稳定存储定位符
* @param mediaType 媒体类型
* @param size 内容字节数
* @return 成功插入为 1参数不满足活动内容约束为 0
* @throws org.springframework.dao.DuplicateKeyException 内容引用已经存在
*/
@Insert("INSERT INTO tb_skill_content("
+ "content_ref,content_hash,file_path,storage_locator,media_type,size,ref_count,created,modified) "
+ "SELECT #{contentRef},#{contentHash},#{filePath},#{storageLocator},#{mediaType},#{size},"
+ "1,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP "
+ "WHERE #{filePath} IS NOT NULL AND #{filePath}<>'' "
+ "AND #{filePath} NOT LIKE '__PENDING__:%' "
+ "AND #{storageLocator} IS NOT NULL AND #{storageLocator}<>'' "
+ "AND #{size}>=0 AND CONCAT('sha256:',#{contentHash})=#{contentRef}")
int insertActive(@Param("contentRef") String contentRef,
@Param("contentHash") String contentHash,
@Param("filePath") String filePath,
@Param("storageLocator") String storageLocator,
@Param("mediaType") String mediaType,
@Param("size") long size);
/**
* 原子增加内容引用计数。
*
* @param contentRef 内容引用
* @return 更新行数
*/
@Update("UPDATE tb_skill_content SET ref_count = ref_count + 1, modified = CURRENT_TIMESTAMP "
+ "WHERE content_ref = #{contentRef} AND ref_count > 0 "
+ "AND CONCAT('sha256:',content_hash)=#{contentRef} "
+ "AND file_path IS NOT NULL AND file_path<>'' AND file_path NOT LIKE '__PENDING__:%' "
+ "AND (storage_locator IS NULL OR storage_locator<>'')")
int retain(String contentRef);
/**
* 原子减少仍有多个持有者的内容引用计数。
*
* @param contentRef 内容引用
* @return 更新行数
*/
@Update("UPDATE tb_skill_content SET ref_count = ref_count - 1, modified = CURRENT_TIMESTAMP "
+ "WHERE content_ref = #{contentRef} AND ref_count > 1 "
+ "AND file_path IS NOT NULL AND file_path<>'' AND file_path NOT LIKE '__PENDING__:%' "
+ "AND (storage_locator IS NULL OR storage_locator<>'')")
int releaseShared(String contentRef);
/**
* 通过 INSERT IGNORE 原子抢占新内容 hash避免跨实例 get-then-insert 竞态。
*
* @param contentRef 内容引用
* @param contentHash 内容 hash
* @param pendingPath 临时占位路径
* @param mediaType 媒体类型
* @param size 字节数
* @return 抢占成功为 1已有内容为 0
*/
@Insert("INSERT IGNORE INTO tb_skill_content(content_ref,content_hash,file_path,media_type,size,ref_count,created,modified) "
+ "VALUES(#{contentRef},#{contentHash},#{pendingPath},#{mediaType},#{size},0,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)")
int reserve(@Param("contentRef") String contentRef,
@Param("contentHash") String contentHash,
@Param("pendingPath") String pendingPath,
@Param("mediaType") String mediaType,
@Param("size") long size);
/**
* 完成内容物理路径写入。
*
* @param contentRef 内容引用
* @param filePath 物理路径
* @return 更新行数
*/
@Update("UPDATE tb_skill_content SET file_path=#{filePath}, ref_count=1, modified=CURRENT_TIMESTAMP "
+ "WHERE content_ref=#{contentRef} AND ref_count=0 AND file_path LIKE '__PENDING__:%'")
int finishReservation(@Param("contentRef") String contentRef, @Param("filePath") String filePath);
/**
* 按读取路径和稳定定位符精确标记最后一份正式内容引用为待清理。
*
* @param contentRef 内容引用
* @param filePath 当前文件读取路径
* @param storageLocator 当前稳定存储定位符
* @return 更新行数
*/
@Update("UPDATE tb_skill_content SET ref_count=0,modified=CURRENT_TIMESTAMP "
+ "WHERE content_ref=#{contentRef} AND file_path=#{filePath} "
+ "AND storage_locator<=>#{storageLocator} AND ref_count=1 "
+ "AND file_path IS NOT NULL AND file_path<>'' AND file_path NOT LIKE '__PENDING__:%' "
+ "AND (storage_locator IS NULL OR storage_locator<>'')")
int markReleased(@Param("contentRef") String contentRef,
@Param("filePath") String filePath,
@Param("storageLocator") String storageLocator);
/**
* 统计当前可读取的正式内容。
*
* @param contentRef 内容引用
* @return 可见内容数量
*/
@Select("SELECT COUNT(1) FROM tb_skill_content WHERE content_ref=#{contentRef} "
+ "AND ref_count>0 AND file_path IS NOT NULL AND file_path<>'' "
+ "AND file_path NOT LIKE '__PENDING__:%' "
+ "AND (storage_locator IS NULL OR storage_locator<>'')")
int countVisible(String contentRef);
/**
* 查询超过保留期限的未完成占位记录。
*
* @param cutoff 截止时间
* @param limit 最大返回数量
* @return 待清理占位记录
*/
@Select("SELECT content_ref AS contentRef,content_hash AS contentHash,file_path AS filePath," +
"storage_locator AS storageLocator,media_type AS mediaType,size,ref_count AS refCount,created,modified "
+ "FROM tb_skill_content WHERE ref_count=0 AND file_path LIKE '__PENDING__:%' "
+ "AND modified < #{cutoff} ORDER BY modified ASC LIMIT #{limit}")
List<SkillContent> findStalePending(@Param("cutoff") Date cutoff, @Param("limit") int limit);
/**
* 条件删除仍处于原占位状态的过期记录。
*
* @param contentRef 内容引用
* @param pendingPath 原占位路径
* @param cutoff 截止时间
* @return 删除行数
*/
@Delete("DELETE FROM tb_skill_content WHERE content_ref=#{contentRef} AND file_path=#{pendingPath} "
+ "AND ref_count=0 AND file_path LIKE '__PENDING__:%' AND modified < #{cutoff}")
int deleteStalePending(@Param("contentRef") String contentRef,
@Param("pendingPath") String pendingPath,
@Param("cutoff") Date cutoff);
/**
* 查询需要重试物理删除的零引用内容。
*
* @param cutoff 截止时间
* @param limit 最大返回数量
* @return 待清理内容
*/
@Select("SELECT content_ref AS contentRef,content_hash AS contentHash,file_path AS filePath," +
"storage_locator AS storageLocator,media_type AS mediaType,size,ref_count AS refCount,created,modified "
+ "FROM tb_skill_content WHERE ref_count=0 AND file_path NOT LIKE '__PENDING__:%' "
+ "AND storage_locator IS NOT NULL AND storage_locator<>'' "
+ "AND modified < #{cutoff} ORDER BY modified ASC LIMIT #{limit}")
List<SkillContent> findReleasedBefore(@Param("cutoff") Date cutoff, @Param("limit") int limit);
/**
* 按读取路径和稳定定位符精确删除已完成物理清理的零引用索引。
*
* @param contentRef 内容引用
* @param filePath 原文件读取路径
* @param storageLocator 原稳定存储定位符
* @return 删除行数
*/
@Delete("DELETE FROM tb_skill_content WHERE content_ref=#{contentRef} "
+ "AND file_path=#{filePath} AND storage_locator<=>#{storageLocator} AND ref_count=0")
int deleteReleased(@Param("contentRef") String contentRef,
@Param("filePath") String filePath,
@Param("storageLocator") String storageLocator);
}

View File

@@ -0,0 +1,147 @@
package tech.easyflow.skill.mapper;
import com.mybatisflex.core.BaseMapper;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import tech.easyflow.skill.entity.SkillContentWriteIntent;
import java.util.Date;
import java.util.List;
/**
* Skill 二进制内容写入意图 Mapper。
*/
public interface SkillContentWriteIntentMapper extends BaseMapper<SkillContentWriteIntent> {
/**
* 原子预留指定内容引用的写入意图。
*
* @param contentRef 内容引用
* @param reservationToken 写入预留令牌
* @param contentHash 内容哈希
* @param storageLocator 稳定存储定位符
* @param mediaType 媒体类型
* @param size 内容字节数
* @return 成功插入为 1参数不满足约束为 0
* @throws org.springframework.dao.DuplicateKeyException 内容引用已被其他写入意图预留
*/
@Insert("INSERT INTO tb_skill_content_write_intent("
+ "content_ref,reservation_token,content_hash,storage_locator,media_type,size,state,created,modified) "
+ "SELECT #{contentRef},#{reservationToken},#{contentHash},#{storageLocator},#{mediaType},#{size},"
+ "'PENDING',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP "
+ "WHERE #{storageLocator} IS NOT NULL AND #{storageLocator}<>'' "
+ "AND CONCAT('sha256:',#{contentHash})=#{contentRef}")
int reserve(@Param("contentRef") String contentRef,
@Param("reservationToken") String reservationToken,
@Param("contentHash") String contentHash,
@Param("storageLocator") String storageLocator,
@Param("mediaType") String mediaType,
@Param("size") long size);
/**
* 在调用方事务中将预留意图原子声明为正在写入。
*
* <p>UPDATE 会持有目标行的排他锁直至调用方事务结束;事务回滚后状态恢复为 PENDING。</p>
*
* @param contentRef 内容引用
* @param reservationToken 写入预留令牌
* @return 成功声明为 1令牌或状态不匹配为 0
*/
@Update("UPDATE tb_skill_content_write_intent SET state='WRITING',modified=CURRENT_TIMESTAMP "
+ "WHERE content_ref=#{contentRef} AND reservation_token=#{reservationToken} AND state='PENDING'")
int claimForWrite(@Param("contentRef") String contentRef,
@Param("reservationToken") String reservationToken);
/**
* 查询超过截止时间且尚未完成的写入意图。
*
* @param cutoff 截止时间
* @param limit 最大返回数量
* @return 按修改时间升序排列的过期意图
*/
@Select("SELECT content_ref AS contentRef,reservation_token AS reservationToken," +
"content_hash AS contentHash,storage_locator AS storageLocator,media_type AS mediaType," +
"size,state,created,modified "
+ "FROM tb_skill_content_write_intent "
+ "WHERE state IN ('PENDING','WRITING','CLEANING') AND modified<#{cutoff} "
+ "ORDER BY modified ASC LIMIT #{limit}")
List<SkillContentWriteIntent> findStale(@Param("cutoff") Date cutoff, @Param("limit") int limit);
/**
* 将过期意图原子声明为清理中。
*
* <p>expectedState 构成状态 CAS。传入 CLEANING 时,同一令牌可幂等重试;存在正式活动内容时
* 不允许取得清理权。</p>
*
* @param contentRef 内容引用
* @param reservationToken 写入预留令牌
* @param expectedState 查询时观察到的状态
* @param cutoff 截止时间
* @return 成功声明为 1状态已变化或存在活动内容为 0
*/
@Update("UPDATE tb_skill_content_write_intent SET state='CLEANING',modified=CURRENT_TIMESTAMP "
+ "WHERE content_ref=#{contentRef} AND reservation_token=#{reservationToken} "
+ "AND state=#{expectedState} AND state IN ('PENDING','WRITING','CLEANING') "
+ "AND modified<#{cutoff} AND NOT EXISTS ("
+ "SELECT 1 FROM tb_skill_content active_content "
+ "WHERE active_content.content_ref=tb_skill_content_write_intent.content_ref "
+ "AND active_content.ref_count>0)")
int claimForCleanup(@Param("contentRef") String contentRef,
@Param("reservationToken") String reservationToken,
@Param("expectedState") String expectedState,
@Param("cutoff") Date cutoff);
/**
* 删除当前令牌已经取得清理权的写入意图。
*
* @param contentRef 内容引用
* @param reservationToken 写入预留令牌
* @return 删除行数
*/
@Delete("DELETE FROM tb_skill_content_write_intent WHERE content_ref=#{contentRef} "
+ "AND reservation_token=#{reservationToken} AND state='CLEANING'")
int deleteClaimed(@Param("contentRef") String contentRef,
@Param("reservationToken") String reservationToken);
/**
* 正式内容已激活时删除残留写入意图。
*
* @param contentRef 内容引用
* @param reservationToken 写入预留令牌
* @return 删除行数
*/
@Delete("DELETE FROM tb_skill_content_write_intent WHERE content_ref=#{contentRef} "
+ "AND reservation_token=#{reservationToken} AND EXISTS ("
+ "SELECT 1 FROM tb_skill_content active_content "
+ "WHERE active_content.content_ref=tb_skill_content_write_intent.content_ref "
+ "AND active_content.ref_count>0)")
int deleteIfActiveExists(@Param("contentRef") String contentRef,
@Param("reservationToken") String reservationToken);
/**
* 删除调用方尚未声明写入、且确认不会产生物理对象的预留意图。
*
* @param contentRef 内容引用
* @param reservationToken 写入预留令牌
* @return 删除行数
*/
@Delete("DELETE FROM tb_skill_content_write_intent WHERE content_ref=#{contentRef} "
+ "AND reservation_token=#{reservationToken} AND state='PENDING'")
int deletePending(@Param("contentRef") String contentRef,
@Param("reservationToken") String reservationToken);
/**
* 按内容引用读取完整写入意图。
*
* @param contentRef 内容引用
* @return 写入意图,不存在时为 null
*/
@Select("SELECT content_ref AS contentRef,reservation_token AS reservationToken," +
"content_hash AS contentHash,storage_locator AS storageLocator,media_type AS mediaType," +
"size,state,created,modified "
+ "FROM tb_skill_content_write_intent WHERE content_ref=#{contentRef}")
SkillContentWriteIntent getIntent(@Param("contentRef") String contentRef);
}

View File

@@ -0,0 +1,65 @@
package tech.easyflow.skill.mapper;
import com.mybatisflex.core.BaseMapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Update;
import tech.easyflow.skill.entity.SkillImportStage;
import java.math.BigInteger;
import java.util.Date;
/**
* Skill 导入临时包 Mapper。
*/
public interface SkillImportStageMapper extends BaseMapper<SkillImportStage> {
/**
* 原子消费仍有效的导入令牌。
*
* @param token 导入令牌
* @param tenantId 租户 ID
* @param accountId 用户 ID
* @param now 当前时间
* @return 更新行数
*/
@Update("UPDATE tb_skill_import_stage SET status='PROCESSING', expires_at=#{processingExpiresAt} "
+ "WHERE import_token=#{token} AND tenant_id=#{tenantId} AND account_id=#{accountId} "
+ "AND status='PENDING' AND expires_at>#{now}")
int consume(@Param("token") String token,
@Param("tenantId") BigInteger tenantId,
@Param("accountId") BigInteger accountId,
@Param("now") Date now,
@Param("processingExpiresAt") Date processingExpiresAt);
/**
* 将待确认令牌原子转为已过期的处理中状态,阻止删除文件期间被并发消费。
*
* @param token 导入令牌
* @param tenantId 租户 ID
* @param accountId 用户 ID
* @param now 当前时间,同时作为立即清理截止时间
* @return 更新行数
*/
@Update("UPDATE tb_skill_import_stage SET status='PROCESSING', expires_at=#{now} "
+ "WHERE import_token=#{token} AND tenant_id=#{tenantId} AND account_id=#{accountId} "
+ "AND status='PENDING'")
int beginCancel(@Param("token") String token,
@Param("tenantId") BigInteger tenantId,
@Param("accountId") BigInteger accountId,
@Param("now") Date now);
/**
* 删除已原子进入取消流程且属于当前用户的令牌。
*
* @param token 导入令牌
* @param tenantId 租户 ID
* @param accountId 用户 ID
* @return 删除行数
*/
@Delete("DELETE FROM tb_skill_import_stage WHERE import_token=#{token} AND tenant_id=#{tenantId} "
+ "AND account_id=#{accountId} AND status='PROCESSING'")
int finishCancel(@Param("token") String token,
@Param("tenantId") BigInteger tenantId,
@Param("accountId") BigInteger accountId);
}

View File

@@ -1,10 +1,106 @@
package tech.easyflow.skill.mapper;
import com.mybatisflex.core.BaseMapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
import tech.easyflow.skill.entity.Skill;
import java.math.BigInteger;
import java.util.Date;
import java.util.Map;
/**
* Skill Mapper。
*/
public interface SkillMapper extends BaseMapper<Skill> {
/**
* 在租户边界内更新审批中的发布状态,并显式写入或清空审批实例 ID。
*
* @param id Skill ID
* @param tenantId 租户 ID
* @param publishStatus 发布状态
* @param approvalInstanceId 当前审批实例 ID可为空
* @return 更新行数
*/
@Update("UPDATE tb_skill SET publish_status=#{publishStatus}, "
+ "current_approval_instance_id=#{approvalInstanceId} "
+ "WHERE id=#{id} AND tenant_id=#{tenantId}")
int updateApprovalState(@Param("id") BigInteger id,
@Param("tenantId") BigInteger tenantId,
@Param("publishStatus") String publishStatus,
@Param("approvalInstanceId") BigInteger approvalInstanceId);
/**
* 在租户边界内持久化已发布快照,并原子清空审批实例 ID。
*
* @param id Skill ID
* @param tenantId 租户 ID
* @param snapshot 已发布快照
* @param publishedAt 发布时间
* @param publishedBy 发布人
* @param snapshotHash 快照哈希
* @return 更新行数
*/
@Update("UPDATE tb_skill SET publish_status='PUBLISHED', "
+ "published_snapshot_json=#{snapshot,typeHandler=com.mybatisflex.core.handler.FastjsonTypeHandler}, "
+ "published_at=#{publishedAt}, published_by=#{publishedBy}, "
+ "snapshot_hash=#{snapshotHash}, current_approval_instance_id=NULL "
+ "WHERE id=#{id} AND tenant_id=#{tenantId}")
int publish(@Param("id") BigInteger id,
@Param("tenantId") BigInteger tenantId,
@Param("snapshot") Map<String, Object> snapshot,
@Param("publishedAt") Date publishedAt,
@Param("publishedBy") BigInteger publishedBy,
@Param("snapshotHash") String snapshotHash);
/**
* 在租户边界内将 Skill 标记为下线,并清空审批实例 ID。
*
* @param id Skill ID
* @param tenantId 租户 ID
* @return 更新行数
*/
@Update("UPDATE tb_skill SET publish_status='OFFLINE', current_approval_instance_id=NULL "
+ "WHERE id=#{id} AND tenant_id=#{tenantId}")
int markOffline(@Param("id") BigInteger id, @Param("tenantId") BigInteger tenantId);
/**
* 无审计污染地回填迁移后缺失的包摘要,仅处理 package_hash 为空的旧记录。
*
* @param id Skill ID
* @param tenantId 租户 ID
* @param packageHash 包哈希
* @param resourceCount 资源总数
* @param referenceCount 引用数
* @param scriptCount 脚本数
* @param assetCount 二进制资源数
* @return 更新行数
*/
@Update("UPDATE tb_skill SET package_hash=#{packageHash}, resource_count=#{resourceCount}, "
+ "reference_count=#{referenceCount}, script_count=#{scriptCount}, asset_count=#{assetCount}, "
+ "modified=modified, modified_by=modified_by "
+ "WHERE id=#{id} AND tenant_id=#{tenantId} AND package_hash IS NULL")
int backfillPackageSummary(@Param("id") BigInteger id,
@Param("tenantId") BigInteger tenantId,
@Param("packageHash") String packageHash,
@Param("resourceCount") Integer resourceCount,
@Param("referenceCount") Integer referenceCount,
@Param("scriptCount") Integer scriptCount,
@Param("assetCount") Integer assetCount);
/**
* 无审计污染地回填迁移后缺失的能力哈希。
*
* @param id Skill ID
* @param tenantId 租户 ID
* @param capabilityHash 能力哈希
* @return 更新行数
*/
@Update("UPDATE tb_skill SET capability_hash=#{capabilityHash}, modified=modified, modified_by=modified_by "
+ "WHERE id=#{id} AND tenant_id=#{tenantId} AND capability_hash IS NULL")
int backfillCapabilityHash(@Param("id") BigInteger id,
@Param("tenantId") BigInteger tenantId,
@Param("capabilityHash") String capabilityHash);
}

View File

@@ -1,10 +0,0 @@
package tech.easyflow.skill.mapper;
import com.mybatisflex.core.BaseMapper;
import tech.easyflow.skill.entity.SkillReference;
/**
* Skill reference Mapper。
*/
public interface SkillReferenceMapper extends BaseMapper<SkillReference> {
}

View File

@@ -0,0 +1,10 @@
package tech.easyflow.skill.mapper;
import com.mybatisflex.core.BaseMapper;
import tech.easyflow.skill.entity.SkillResource;
/**
* Skill 通用资源 Mapper。
*/
public interface SkillResourceMapper extends BaseMapper<SkillResource> {
}

View File

@@ -1,10 +0,0 @@
package tech.easyflow.skill.mapper;
import com.mybatisflex.core.BaseMapper;
import tech.easyflow.skill.entity.SkillScript;
/**
* Skill script Mapper。
*/
public interface SkillScriptMapper extends BaseMapper<SkillScript> {
}

View File

@@ -2,12 +2,19 @@ package tech.easyflow.skill.publish;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Component;
import com.mybatisflex.core.query.QueryWrapper;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.ai.publish.AbstractAiResourceLifecycleHandler;
import tech.easyflow.approval.entity.ApprovalInstance;
import tech.easyflow.approval.entity.vo.ApprovalSubmitRequest;
import tech.easyflow.approval.enums.ApprovalActionType;
import tech.easyflow.approval.enums.ApprovalResourceType;
import tech.easyflow.approval.service.ApprovalInstanceService;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.skill.entity.Skill;
import tech.easyflow.skill.mapper.SkillMapper;
import tech.easyflow.skill.service.SkillService;
import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.enums.ResourceAction;
@@ -24,7 +31,9 @@ import java.util.Map;
public class SkillApprovalSubjectHandler extends AbstractAiResourceLifecycleHandler<Skill> {
private final SkillService skillService;
private final SkillMapper skillMapper;
private final ResourceAccessService resourceAccessService;
private final ApprovalInstanceService approvalInstanceService;
/**
* 创建 Skill 审批资源处理器。
@@ -32,15 +41,19 @@ public class SkillApprovalSubjectHandler extends AbstractAiResourceLifecycleHand
* @param approvalInstanceService 审批实例服务
* @param objectMapper JSON 映射器
* @param skillService Skill 服务
* @param skillMapper Skill Mapper
* @param resourceAccessService 资源访问服务
*/
public SkillApprovalSubjectHandler(ApprovalInstanceService approvalInstanceService,
ObjectMapper objectMapper,
SkillService skillService,
SkillMapper skillMapper,
ResourceAccessService resourceAccessService) {
super(approvalInstanceService, objectMapper);
this.skillService = skillService;
this.skillMapper = skillMapper;
this.resourceAccessService = resourceAccessService;
this.approvalInstanceService = approvalInstanceService;
}
/**
@@ -56,18 +69,31 @@ public class SkillApprovalSubjectHandler extends AbstractAiResourceLifecycleHand
*/
@Override
public void assertPublishedAccess(Object identifier, String denyMessage) {
Skill skill = skillService.getById(String.valueOf(identifier));
Skill skill = findCurrentTenantSkill(new BigInteger(String.valueOf(identifier)), false);
if (skill == null || !PublishStatus.from(skill.getPublishStatus()).isExternallyVisible()
|| skill.getPublishedSnapshotJson() == null || skill.getPublishedSnapshotJson().isEmpty()) {
throw new BusinessException(denyMessage);
}
}
/**
* {@inheritDoc}
*/
@Override
public ApprovalSubmitRequest buildSubmitRequest(BigInteger resourceId, String actionType, BigInteger operatorId) {
ApprovalSubmitRequest request = super.buildSubmitRequest(resourceId, actionType, operatorId);
if (ApprovalActionType.PUBLISH.getCode().equals(request.getActionType())) {
skillService.retainSnapshotContents(readResourceSnapshot(request.getSnapshotJson()));
}
return request;
}
@Override
protected Skill requireResource(BigInteger resourceId) {
Skill skill = skillService.getById(resourceId);
// 生命周期提交与审批决策均在事务中执行,行锁串行化同一 Skill 的状态迁移。
Skill skill = findCurrentTenantSkill(resourceId, true);
if (skill == null) {
throw new BusinessException("Skill 不存在");
throw new BusinessException(404, 404, "Skill 不存在");
}
return skill;
}
@@ -109,44 +135,108 @@ public class SkillApprovalSubjectHandler extends AbstractAiResourceLifecycleHand
return skillService.buildPublishSnapshot(resource);
}
/**
* 删除审批只记录最小治理信息,避免失效能力阻断删除或把提示词、资源内容及能力配置写入审批快照。
*
* @param resource Skill
* @return 删除审批治理快照
*/
@Override
protected Map<String, Object> buildDeleteResourceSnapshot(Skill resource) {
return skillService.buildGovernanceSnapshot(resource);
}
/**
* 优先使用稳定快照 hash 判断内容是否变化,兼容旧快照中的时间字段。
*
* @param currentSnapshot 当前草稿快照
* @param publishedSnapshot 已发布快照
* @return 内容一致时为 true
*/
@Override
protected boolean isSameSnapshot(Map<String, Object> currentSnapshot, Map<String, Object> publishedSnapshot) {
Object currentHash = currentSnapshot == null ? null : currentSnapshot.get("snapshotHash");
Object publishedHash = publishedSnapshot == null ? null : publishedSnapshot.get("snapshotHash");
if (currentHash != null && publishedHash != null) {
return currentHash.equals(publishedHash);
}
return super.isSameSnapshot(currentSnapshot, publishedSnapshot);
}
@Override
protected void persistResourceState(BigInteger resourceId, PublishStatus publishStatus, BigInteger currentApprovalInstanceId) {
Skill skill = new Skill();
skill.setId(resourceId);
skill.setPublishStatus(publishStatus.getCode());
skill.setCurrentApprovalInstanceId(currentApprovalInstanceId);
skillService.updateById(skill);
Skill existing = requireResource(resourceId);
if (skillMapper.updateApprovalState(resourceId, existing.getTenantId(), publishStatus.getCode(),
currentApprovalInstanceId) != 1) {
throw new BusinessException(500, 500, "更新 Skill 审批状态失败,请稍后重试");
}
}
@Override
protected void publishResource(BigInteger resourceId, Map<String, Object> resourceSnapshot, BigInteger operatorId) {
Skill skill = new Skill();
skill.setId(resourceId);
skill.setPublishStatus(PublishStatus.PUBLISHED.getCode());
skill.setPublishedSnapshotJson(resourceSnapshot);
skill.setPublishedAt(new Date());
skill.setPublishedBy(operatorId);
skill.setCurrentApprovalInstanceId(null);
skillService.updateById(skill);
Skill existing = requireResource(resourceId);
if (skillMapper.publish(resourceId, existing.getTenantId(), resourceSnapshot, new Date(), operatorId,
stringValue(resourceSnapshot.get("snapshotHash"))) != 1) {
throw new BusinessException(500, 500, "发布 Skill 失败,请稍后重试");
}
skillService.releaseSnapshotContents(existing.getPublishedSnapshotJson());
}
@Override
protected void markResourceOffline(BigInteger resourceId) {
Skill skill = new Skill();
skill.setId(resourceId);
skill.setPublishStatus(PublishStatus.OFFLINE.getCode());
skill.setCurrentApprovalInstanceId(null);
skillService.updateById(skill);
Skill existing = requireResource(resourceId);
if (skillMapper.markOffline(resourceId, existing.getTenantId()) != 1) {
throw new BusinessException(500, 500, "下线 Skill 失败,请稍后重试");
}
}
@Override
protected void removeResource(BigInteger resourceId) {
skillService.removeAggregate(resourceId);
skillService.removeLifecycleAggregate(resourceId);
}
@Override
protected String resourceLabel() {
return "Skill";
}
}
/**
* 审批驳回或撤回时释放发布候选快照持有的二进制内容。
*
* @param resourceId Skill ID
* @param previousStatus 审批前发布状态
*/
@Override
public void restoreState(BigInteger resourceId, PublishStatus previousStatus) {
Skill skill = requireResource(resourceId);
BigInteger instanceId = skill.getCurrentApprovalInstanceId();
if (instanceId != null) {
ApprovalInstance instance = approvalInstanceService.getById(instanceId);
if (instance == null) {
throw new BusinessException(500, 500, "Skill 审批状态异常,无法安全恢复内容引用");
}
if (ApprovalActionType.PUBLISH.getCode().equals(instance.getActionType())) {
skillService.releaseSnapshotContents(readResourceSnapshot(instance.getSnapshotJson()));
}
}
super.restoreState(resourceId, previousStatus);
}
private String stringValue(Object value) {
return value == null ? null : String.valueOf(value);
}
private Skill findCurrentTenantSkill(BigInteger id, boolean forUpdate) {
LoginAccount account = SaTokenUtil.getLoginAccount();
if (account == null || account.getId() == null || account.getTenantId() == null) {
throw new BusinessException(401, 401, "未登录或登录态无效");
}
QueryWrapper query = QueryWrapper.create()
.eq(Skill::getId, id)
.eq(Skill::getTenantId, account.getTenantId());
if (forUpdate) {
query.forUpdate();
}
return skillMapper.selectOneByQuery(query);
}
}

View File

@@ -61,12 +61,15 @@ public class SkillPublishAppService {
if (id == null) {
throw new BusinessException("Skill 审批时资源ID不能为空");
}
tech.easyflow.common.entity.LoginAccount account = SaTokenUtil.getLoginAccount();
if (account == null || account.getId() == null || account.getTenantId() == null) {
throw new BusinessException(401, 401, "未登录或登录态无效");
}
return aiResourceLifecycleService.submitAction(
ApprovalResourceType.SKILL.getCode(),
id,
actionType.getCode(),
SaTokenUtil.getLoginAccount().getId()
account.getId()
);
}
}

View File

@@ -4,13 +4,21 @@ import com.easyagents.skill.model.SkillDescriptor;
import com.easyagents.skill.repository.SkillRepository;
import com.mybatisflex.core.query.QueryWrapper;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
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.skill.entity.SkillResource;
import tech.easyflow.skill.service.SkillService;
import tech.easyflow.skill.security.SkillVisibilityQueryHelper;
import tech.easyflow.skill.store.DBSkillContentStore;
import tech.easyflow.skill.support.SkillModelConverter;
import java.math.BigInteger;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
@@ -20,24 +28,45 @@ import java.util.Optional;
public class DBSkillRepository implements SkillRepository {
private final SkillService skillService;
private final DBSkillContentStore contentStore;
private final SkillVisibilityQueryHelper visibilityQueryHelper;
/**
* 创建数据库 Skill 仓储。
*
* @param skillService Skill 服务
* @param contentStore 二进制内容仓库
* @param visibilityQueryHelper 可见性查询助手
*/
public DBSkillRepository(SkillService skillService) {
public DBSkillRepository(SkillService skillService,
DBSkillContentStore contentStore,
SkillVisibilityQueryHelper visibilityQueryHelper) {
this.skillService = skillService;
this.contentStore = contentStore;
this.visibilityQueryHelper = visibilityQueryHelper;
}
/**
* {@inheritDoc}
* 保存 Skill并转移新增二进制内容引用的所有权。
*
* <p>调用方在新增 Skill 或为已有 Skill 增加二进制资源前,必须通过内容仓库的
* {@code put}/{@code commit} 为每个新增资源取得一份引用。保存成功后,这些新增引用转由
* Skill 聚合持有;保存失败时不发生所有权转移,本方法产生的引用计数变更随事务回滚,
* 调用方仍负责释放在外部事务中预先取得的新引用。更新时,旧、新资源多重集的交集复用旧
* 聚合已有所有权,本适配器会在替换前 retain 相同次数,以抵消资源替换对旧聚合的 release
* 仅出现在新聚合中的引用直接接管调用方已取得的引用。</p>
*
* @param skill 待保存的 Skill 聚合
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void save(com.easyagents.skill.model.Skill skill) {
requireAccount();
Skill entity = SkillModelConverter.fromAgentSkill(skill);
BigInteger parsedId = tryParseId(skill.getId());
if (parsedId != null && skillService.getById(parsedId) != null) {
if (parsedId != null && findReadable(parsedId) != null) {
Skill existing = skillService.getDetail(parsedId);
retainReusedContentRefs(existing.getResources(), entity.getResources());
entity.setId(parsedId);
skillService.updateDraft(entity);
return;
@@ -45,12 +74,62 @@ public class DBSkillRepository implements SkillRepository {
skillService.saveDraft(entity);
}
/**
* 为旧、新资源多重集的交集增加临时持有,抵消替换流程对旧聚合引用的统一释放。
*
* @param existingResources 旧聚合资源
* @param incomingResources 新聚合资源
*/
private void retainReusedContentRefs(List<SkillResource> existingResources,
List<SkillResource> incomingResources) {
Map<String, Integer> remainingOldRefs = contentRefCounts(existingResources);
if (incomingResources == null || incomingResources.isEmpty() || remainingOldRefs.isEmpty()) {
return;
}
for (SkillResource resource : incomingResources) {
String contentRef = resource == null ? null : resource.getContentRef();
Integer remaining = remainingOldRefs.get(contentRef);
if (remaining == null || remaining <= 0) {
continue;
}
contentStore.retain(contentRef);
if (remaining == 1) {
remainingOldRefs.remove(contentRef);
} else {
remainingOldRefs.put(contentRef, remaining - 1);
}
}
}
/**
* 统计二进制内容引用多重集。
*
* @param resources Skill 资源
* @return contentRef 到出现次数的映射
*/
private Map<String, Integer> contentRefCounts(List<SkillResource> resources) {
Map<String, Integer> counts = new HashMap<>();
if (resources == null) {
return counts;
}
for (SkillResource resource : resources) {
String contentRef = resource == null ? null : resource.getContentRef();
if (contentRef != null && !contentRef.isBlank()) {
counts.merge(contentRef, 1, Integer::sum);
}
}
return counts;
}
/**
* {@inheritDoc}
*/
@Override
public Optional<com.easyagents.skill.model.Skill> get(String skillId) {
BigInteger id = parseId(skillId);
if (findReadable(id) == null) {
return Optional.empty();
}
Skill skill = skillService.getDetail(id);
return Optional.of(SkillModelConverter.toAgentSkill(skill));
}
@@ -61,7 +140,9 @@ public class DBSkillRepository implements SkillRepository {
@Override
public Optional<SkillDescriptor> getDescriptor(String skillId) {
BigInteger id = parseId(skillId);
Skill skill = skillService.getById(id);
QueryWrapper query = descriptorQuery().eq(Skill::getId, id);
visibilityQueryHelper.applyReadableAccess(query);
Skill skill = skillService.getOne(query);
if (skill == null) {
return Optional.empty();
}
@@ -74,7 +155,10 @@ public class DBSkillRepository implements SkillRepository {
*/
@Override
public List<SkillDescriptor> listDescriptors() {
return skillService.list().stream()
requireAccount();
QueryWrapper query = descriptorQuery();
visibilityQueryHelper.applyReadableAccess(query);
return skillService.list(query).stream()
.map(skill -> new SkillDescriptor(String.valueOf(skill.getId()), skill.getName(), skill.getDescription(),
new com.easyagents.skill.model.SkillMetadata(skill.getMetadataJson())))
.toList();
@@ -94,14 +178,41 @@ public class DBSkillRepository implements SkillRepository {
@Override
public boolean exists(String skillId) {
BigInteger id = parseId(skillId);
return skillService.count(QueryWrapper.create().eq(Skill::getId, id)) > 0;
QueryWrapper query = QueryWrapper.create().eq(Skill::getId, id);
visibilityQueryHelper.applyReadableAccess(query);
return skillService.count(query) > 0;
}
private Skill findReadable(BigInteger id) {
QueryWrapper query = QueryWrapper.create().eq(Skill::getId, id);
visibilityQueryHelper.applyReadableAccess(query);
return skillService.getOne(query);
}
private QueryWrapper descriptorQuery() {
return QueryWrapper.create().select(
"id", "tenant_id", "dept_id", "category_id", "name", "description", "metadata_json",
"visibility_scope", "created_by");
}
private LoginAccount requireAccount() {
LoginAccount account = SaTokenUtil.getLoginAccount();
if (account == null || account.getId() == null || account.getTenantId() == null) {
throw new BusinessException(401, 401, "未登录或登录态无效");
}
return account;
}
private BigInteger parseId(String skillId) {
requireAccount();
if (skillId == null || skillId.isBlank()) {
throw new BusinessException("Skill ID 不能为空");
}
return new BigInteger(skillId);
try {
return new BigInteger(skillId);
} catch (NumberFormatException exception) {
throw new BusinessException("Skill ID 格式不正确");
}
}
private BigInteger tryParseId(String skillId) {

View File

@@ -0,0 +1,447 @@
package tech.easyflow.skill.security;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.text.Normalizer;
import java.util.Base64;
import java.util.Locale;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* EasyFlow 平台附加配置中的高置信凭据值检测器。
*
* <p>该检测器只用于能力配置和增强包元数据,不应用于标准 Skill 文档或资源正文。</p>
*/
public final class SkillCredentialValueGuard {
private static final int MAX_PERCENT_DECODE_PASSES = 5;
private static final Pattern URI_USER_INFO = Pattern.compile(
"(?i)\\b[a-z][a-z0-9+.-]*://([^\\s/?#@]+)@");
private static final Pattern ASSIGNMENT = Pattern.compile(
"(?i)(?:^|[\\s?&#{\\[,;])['\"]?([A-Z0-9_.\\[\\]-]{1,160})['\"]?\\s*[:=]\\s*");
private static final Pattern AUTHORIZATION_SCHEME = Pattern.compile("(?i)^(?:bearer|basic)\\s+");
private static final Pattern STANDALONE_AUTHORIZATION_SCHEME = Pattern.compile(
"(?i)(?<![A-Za-z0-9])(bearer|basic)\\s+");
private static final Pattern PRIVATE_KEY_MARKER = Pattern.compile(
"(?i)-----BEGIN(?: [A-Z0-9]+)? PRIVATE KEY-----");
private static final Pattern COMMON_TOKEN_PREFIX = Pattern.compile(
"(?<![A-Za-z0-9])(?:sk-proj-[A-Za-z0-9_-]{20,}|sk-[A-Za-z0-9]{20,}|"
+ "gh[pousr]_[A-Za-z0-9]{20,}|xox[baprs]-[A-Za-z0-9-]{20,}|"
+ "glpat-[A-Za-z0-9_-]{20,}|hf_[A-Za-z0-9]{20,}|"
+ "(?:sk|pk)_(?:live|test)_[A-Za-z0-9]{16,}|npm_[A-Za-z0-9]{20,}|"
+ "AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{30,})(?![A-Za-z0-9])");
private static final Pattern JWT = Pattern.compile(
"(?<![A-Za-z0-9_-])eyJ[A-Za-z0-9_-]{5,}\\.[A-Za-z0-9_-]{5,}\\.[A-Za-z0-9_-]{8,}"
+ "(?![A-Za-z0-9_-])");
private static final Pattern PLACEHOLDER = Pattern.compile(
"(?i)^(?:\\$\\{[A-Z0-9_.:-]+}|\\{\\{\\s*[A-Z0-9_.:-]+\\s*}}|"
+ "<[A-Z0-9_.:-]+>|\\[(?:REDACTED|MASKED|HIDDEN|TOKEN|API[-_]?KEY|SECRET|PASSWORD)]|"
+ "\\*{3,})$");
private static final Pattern SAFE_SENTINEL = Pattern.compile(
"(?i)^(?:none|null|unset|disabled|not[-_ ]?set|n/?a)$");
private static final Set<String> AUTH_PROSE_WORDS = Set.of(
"authentication", "authorization", "credentials", "credential", "information",
"header", "scheme", "token", "example", "placeholder");
/**
* 禁止实例化纯静态安全工具。
*/
private SkillCredentialValueGuard() {
}
/**
* 判断字符串是否包含可识别的实际凭据材料。
*
* @param value 待检查的平台附加配置值
* @return 检测到实际凭据时为 true
*/
public static boolean containsCredential(String value) {
if (value == null || value.isBlank()) {
return false;
}
String candidate = normalize(value);
for (int pass = 0; pass <= MAX_PERCENT_DECODE_PASSES; pass++) {
if (containsCredentialNormalized(candidate)) {
return true;
}
String decoded = percentDecode(candidate);
if (decoded.equals(candidate)) {
return false;
}
if (pass == MAX_PERCENT_DECODE_PASSES) {
// 超过有界规范化深度仍持续变化时按高风险输入处理,避免任意层编码绕过。
return true;
}
candidate = normalize(decoded);
}
return false;
}
/**
* 对单层规范化文本执行结构化凭据检测。
*
* @param value 已规范化文本
* @return 检测到凭据时为 true
*/
private static boolean containsCredentialNormalized(String value) {
if (PRIVATE_KEY_MARKER.matcher(value).find()
|| COMMON_TOKEN_PREFIX.matcher(value).find()
|| JWT.matcher(value).find()) {
return true;
}
Matcher userInfoMatcher = URI_USER_INFO.matcher(value);
while (userInfoMatcher.find()) {
String userInfo = stripWrappingQuotes(userInfoMatcher.group(1));
int separator = userInfo.lastIndexOf(':');
if (separator >= 0 && !isSafeCredentialScalar(userInfo.substring(separator + 1))) {
return true;
}
}
Matcher assignmentMatcher = ASSIGNMENT.matcher(value);
while (assignmentMatcher.find()) {
if (!isSensitiveKey(assignmentMatcher.group(1))) {
continue;
}
String assignedValue = extractAssignedValue(value, assignmentMatcher.end());
if (assignedValue.isEmpty()) {
continue;
}
if (!isSafeCredentialScalar(assignedValue)) {
return true;
}
}
Matcher schemeMatcher = STANDALONE_AUTHORIZATION_SCHEME.matcher(value);
while (schemeMatcher.find()) {
String payload = extractAuthorizationPayload(value, schemeMatcher.end());
if (looksLikeAuthorizationPayload(schemeMatcher.group(1), payload)) {
return true;
}
}
return false;
}
/**
* 从赋值分隔符后提取一个受限标量,支持常见引号和占位符形式。
*
* @param source 完整文本
* @param start 值起始位置
* @return 去除外层引号的标量
*/
private static String extractAssignedValue(String source, int start) {
int cursor = start;
while (cursor < source.length() && Character.isWhitespace(source.charAt(cursor))) {
cursor++;
}
if (cursor >= source.length()) {
return "";
}
char first = source.charAt(cursor);
if (first == '\'' || first == '"') {
int quoteEnd = findClosingQuote(source, cursor, first);
if (quoteEnd < 0) {
return stripWrappingQuotes(source.substring(cursor));
}
int end = extendScalarTail(source, quoteEnd + 1);
return stripWrappingQuotes(source.substring(cursor, end));
}
Matcher schemeMatcher = AUTHORIZATION_SCHEME.matcher(source.substring(cursor));
if (schemeMatcher.find()) {
String payload = extractAssignedValue(source, cursor + schemeMatcher.end());
return source.substring(cursor, cursor + schemeMatcher.end()) + payload;
}
int placeholderEnd = findPairedPlaceholderEnd(source, cursor);
if (placeholderEnd >= 0) {
return source.substring(cursor, extendScalarTail(source, placeholderEnd)).trim();
}
int end = cursor;
while (end < source.length() && !isScalarDelimiter(source.charAt(end))) {
end++;
}
return stripWrappingQuotes(source.substring(cursor, end));
}
/**
* 提取认证方案后的值;普通说明句保留为整体,供结构化判定区分 Token 与文案。
*
* @param source 完整文本
* @param start 认证值起始位置
* @return 认证载荷
*/
private static String extractAuthorizationPayload(String source, int start) {
int cursor = start;
while (cursor < source.length() && Character.isWhitespace(source.charAt(cursor))) {
cursor++;
}
if (cursor >= source.length()) {
return "";
}
int placeholderEnd = findPairedPlaceholderEnd(source, cursor);
if (placeholderEnd >= 0) {
return source.substring(cursor, extendScalarTail(source, placeholderEnd)).trim();
}
int end = cursor;
while (end < source.length()
&& !Character.isWhitespace(source.charAt(end))
&& !isScalarDelimiter(source.charAt(end))) {
end++;
}
return stripWrappingQuotes(source.substring(cursor, end));
}
/**
* 查找当前位置开始的成对占位符结束位置。
*
* @param source 完整文本
* @param start 起始位置
* @return 占位符结束位置(不含);当前位置不是完整占位符时返回 -1
*/
private static int findPairedPlaceholderEnd(String source, int start) {
String closing;
if (source.startsWith("${", start)) {
closing = "}";
} else if (source.startsWith("{{", start)) {
closing = "}}";
} else if (source.startsWith("<", start)) {
closing = ">";
} else if (source.startsWith("[", start)) {
closing = "]";
} else {
return -1;
}
int end = source.indexOf(closing, start + 1);
return end < 0 ? -1 : end + closing.length();
}
/**
* 查找未转义的结束引号。
*
* @param source 完整文本
* @param start 起始引号位置
* @param quote 引号字符
* @return 结束引号位置;未闭合时返回 -1
*/
private static int findClosingQuote(String source, int start, char quote) {
boolean escaped = false;
for (int index = start + 1; index < source.length(); index++) {
char current = source.charAt(index);
if (current == quote && !escaped) {
return index;
}
escaped = current == '\\' && !escaped;
if (current != '\\') {
escaped = false;
}
}
return -1;
}
/**
* 将紧邻占位符或引号的尾随字符纳入同一标量,避免占位符前缀绕过。
*
* @param source 完整文本
* @param start 尾随内容起始位置
* @return 标量结束位置
*/
private static int extendScalarTail(String source, int start) {
int end = start;
while (end < source.length() && !isScalarDelimiter(source.charAt(end))) {
end++;
}
return end;
}
/**
* 判断字符是否结束当前凭据标量。
*
* @param value 待判断字符
* @return 属于结构分隔符时为 true
*/
private static boolean isScalarDelimiter(char value) {
return value == ',' || value == ';' || value == '}' || value == ']'
|| value == '&' || value == '#';
}
/**
* 判断提取值是否为明确的非凭据占位符。
*
* @param value 提取值
* @return 属于允许占位符时为 true
*/
private static boolean isPlaceholder(String value) {
return PLACEHOLDER.matcher(stripWrappingQuotes(value).trim()).matches();
}
/**
* 判断赋值或认证方案后的标量是否明确不含真实凭据。
*
* @param value 原始标量
* @return 完整占位符或明确空值哨兵时为 true
*/
private static boolean isSafeCredentialScalar(String value) {
String scalar = stripWrappingQuotes(value).trim();
if ("bearer".equalsIgnoreCase(scalar) || "basic".equalsIgnoreCase(scalar)) {
return true;
}
Matcher schemeMatcher = AUTHORIZATION_SCHEME.matcher(scalar);
if (schemeMatcher.find()) {
String scheme = scalar.substring(0, schemeMatcher.end()).trim();
String payload = scalar.substring(schemeMatcher.end()).trim();
return !looksLikeAuthorizationPayload(scheme, payload);
}
return isPlaceholder(scalar) || SAFE_SENTINEL.matcher(scalar).matches();
}
/**
* 判断认证方案后的载荷是否具有实际凭据结构。
*
* @param scheme 认证方案
* @param value 认证载荷
* @return 具有实际凭据结构时为 true
*/
private static boolean looksLikeAuthorizationPayload(String scheme, String value) {
String payload = stripWrappingQuotes(value).trim();
if (payload.isEmpty() || isPlaceholder(payload) || SAFE_SENTINEL.matcher(payload).matches()) {
return false;
}
int placeholderEnd = findPairedPlaceholderEnd(payload, 0);
if (placeholderEnd > 0 && !payload.substring(placeholderEnd).trim().isEmpty()) {
return true;
}
if (payload.chars().anyMatch(Character::isWhitespace)) {
return false;
}
if (AUTH_PROSE_WORDS.contains(payload.toLowerCase(Locale.ROOT))) {
return false;
}
if ("basic".equalsIgnoreCase(scheme)) {
return isBasicCredential(payload);
}
return payload.length() >= 12 && payload.matches("[A-Za-z0-9._~+/=-]+");
}
/**
* 判断 Basic 载荷是否能解码为 user:secret 结构。
*
* @param payload Base64 载荷
* @return 符合 Basic 凭据结构时为 true
*/
private static boolean isBasicCredential(String payload) {
if (payload.length() < 8 || !payload.matches("[A-Za-z0-9+/]+={0,2}")) {
return false;
}
try {
byte[] decoded = Base64.getDecoder().decode(payload);
String text = new String(decoded, StandardCharsets.UTF_8);
int separator = text.indexOf(':');
return separator > 0 && separator < text.length() - 1
&& text.chars().noneMatch(Character::isISOControl);
} catch (IllegalArgumentException exception) {
return false;
}
}
/**
* 判断赋值左侧字段是否属于凭据语义。
*
* @param key 原始字段名
* @return 敏感字段时为 true
*/
private static boolean isSensitiveKey(String key) {
String normalized = normalize(key).toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]", "");
return normalized.equals("key")
|| normalized.endsWith("authorization")
|| normalized.endsWith("apikey")
|| normalized.endsWith("accesskey")
|| normalized.endsWith("secretaccesskey")
|| normalized.endsWith("accesstoken")
|| normalized.endsWith("refreshtoken")
|| normalized.endsWith("idtoken")
|| normalized.endsWith("authtoken")
|| normalized.endsWith("token")
|| normalized.endsWith("clientsecret")
|| normalized.endsWith("password")
|| normalized.endsWith("passwd")
|| normalized.endsWith("secret")
|| normalized.endsWith("cookie")
|| normalized.endsWith("session")
|| normalized.endsWith("sessionid")
|| normalized.endsWith("credential")
|| normalized.endsWith("signature");
}
/**
* 去除成对单引号或双引号。
*
* @param value 原始标量
* @return 去除外层引号的标量
*/
private static String stripWrappingQuotes(String value) {
if (value == null) {
return "";
}
String trimmed = value.trim();
if (trimmed.length() >= 2) {
char first = trimmed.charAt(0);
char last = trimmed.charAt(trimmed.length() - 1);
if ((first == '\'' && last == '\'') || (first == '"' && last == '"')) {
return trimmed.substring(1, trimmed.length() - 1).trim();
}
}
return trimmed;
}
/**
* 执行 Unicode 兼容规范化。
*
* @param value 原始文本
* @return NFKC 文本
*/
private static String normalize(String value) {
String normalized = Normalizer.normalize(value, Normalizer.Form.NFKC);
StringBuilder visible = new StringBuilder(normalized.length());
normalized.codePoints()
.filter(codePoint -> Character.getType(codePoint) != Character.FORMAT)
.filter(codePoint -> !Character.isISOControl(codePoint))
.forEach(visible::appendCodePoint);
return visible.toString();
}
/**
* 尝试解码一层百分号转义,非法转义保持原文。
*
* @param value 原始文本
* @return 解码结果或原文
*/
private static String percentDecode(String value) {
try {
StringBuilder escapedInvalidPercent = new StringBuilder(value.length());
for (int index = 0; index < value.length(); index++) {
char current = value.charAt(index);
if (current == '%' && (index + 2 >= value.length()
|| !isHexDigit(value.charAt(index + 1))
|| !isHexDigit(value.charAt(index + 2)))) {
escapedInvalidPercent.append("%25");
} else {
escapedInvalidPercent.append(current);
}
}
return URLDecoder.decode(escapedInvalidPercent.toString(), StandardCharsets.UTF_8);
} catch (IllegalArgumentException exception) {
return value;
}
}
/**
* 判断字符是否为十六进制数字。
*
* @param value 待判断字符
* @return 十六进制数字时为 true
*/
private static boolean isHexDigit(char value) {
return value >= '0' && value <= '9'
|| value >= 'a' && value <= 'f'
|| value >= 'A' && value <= 'F';
}
}

View File

@@ -0,0 +1,169 @@
package tech.easyflow.skill.security;
import tech.easyflow.skill.enums.SkillCapabilityType;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.Locale;
import java.util.regex.Pattern;
/**
* Skill 跨环境目标引用和展示元数据的安全校验器。
*/
public final class SkillPortableTargetSanitizer {
private static final Pattern LOGICAL_SEGMENT = Pattern.compile("[A-Za-z0-9][A-Za-z0-9_.-]{0,199}");
private static final Pattern URI_USER_INFO = Pattern.compile(
"(?i)\\b[a-z][a-z0-9+.-]*://[^\\s/?#]*@");
private static final Pattern CREDENTIAL_QUERY = Pattern.compile(
"(?i)[?&;](?:access[_-]?token|refresh[_-]?token|id[_-]?token|token|api[_-]?key|key|"
+ "secret|client[_-]?secret|password|passwd|authorization|auth|"
+ "(?:x-amz-)?signature|credential)\\s*=");
private static final Pattern ABSOLUTE_PATH = Pattern.compile(
"(?:^|[^A-Za-z0-9_.:/-])(?:/(?!/)[^\\s]+|\\\\\\\\[^\\s]+|"
+ "[A-Za-z]:[\\\\/][^\\s]+|~[\\\\/][^\\s]+)");
private static final Pattern FILE_URI = Pattern.compile("(?i)\\bfile:(?://)?[/\\\\]");
/**
* 禁止实例化纯静态安全工具。
*/
private SkillPortableTargetSanitizer() {
}
/**
* 判断逻辑引用是否符合当前能力类型的严格可移植语法。
*
* @param type 能力类型
* @param logicalRef 待校验逻辑引用
* @return 符合安全语法时为 true
*/
public static boolean isSafeLogicalRef(SkillCapabilityType type, String logicalRef) {
if (type == null || logicalRef == null || logicalRef.isBlank()
|| SkillCredentialValueGuard.containsCredential(logicalRef)) {
return false;
}
if (unresolvedRef(type).equals(logicalRef)) {
return true;
}
return switch (type) {
case WORKFLOW -> hasSingleSafeSegment(logicalRef, "workflow:");
case MCP -> hasSingleSafeSegment(logicalRef, "mcp:");
case PLUGIN_ITEM -> hasTwoSafeSegments(logicalRef, "plugin-item:");
};
}
/**
* 返回安全逻辑引用;历史脏值统一降级为不可解析引用。
*
* @param type 能力类型
* @param logicalRef 原始逻辑引用
* @return 安全逻辑引用
*/
public static String safeLogicalRefOrUnresolved(SkillCapabilityType type, String logicalRef) {
return isSafeLogicalRef(type, logicalRef) ? logicalRef : unresolvedRef(type);
}
/**
* 构造能力类型对应的不可解析逻辑引用。
*
* @param type 能力类型
* @return 不可解析逻辑引用
*/
public static String unresolvedRef(SkillCapabilityType type) {
return "unresolved:" + type.name().toLowerCase(Locale.ROOT).replace('_', '-');
}
/**
* 判断展示元数据是否不含凭据式 URI、认证查询参数和绝对路径。
*
* @param value 待校验元数据
* @return 可安全写入增强包时为 true
*/
public static boolean isSafePortableMetadata(String value) {
if (value == null) {
return true;
}
String normalized = value;
for (int pass = 0; pass < 3; pass++) {
if (!isSafePortableMetadataValue(normalized)) {
return false;
}
String decoded = percentDecode(normalized);
if (decoded.equals(normalized)) {
return true;
}
normalized = decoded;
}
return isSafePortableMetadataValue(normalized);
}
/**
* 返回安全展示元数据;空白或不安全内容返回 null。
*
* @param value 原始元数据
* @return 安全元数据或 null
*/
public static String safePortableMetadataOrNull(String value) {
return value == null || value.isBlank() || !isSafePortableMetadata(value) ? null : value;
}
/**
* 校验单段类型逻辑引用。
*
* @param logicalRef 逻辑引用
* @param prefix 类型前缀
* @return 单段符合安全语法时为 true
*/
private static boolean hasSingleSafeSegment(String logicalRef, String prefix) {
return logicalRef.startsWith(prefix)
&& LOGICAL_SEGMENT.matcher(logicalRef.substring(prefix.length())).matches();
}
/**
* 校验插件与工具组成的双段逻辑引用。
*
* @param logicalRef 逻辑引用
* @param prefix 类型前缀
* @return 两段均符合安全语法时为 true
*/
private static boolean hasTwoSafeSegments(String logicalRef, String prefix) {
if (!logicalRef.startsWith(prefix)) {
return false;
}
String value = logicalRef.substring(prefix.length());
int separator = value.indexOf('/');
return separator > 0 && separator == value.lastIndexOf('/')
&& LOGICAL_SEGMENT.matcher(value.substring(0, separator)).matches()
&& LOGICAL_SEGMENT.matcher(value.substring(separator + 1)).matches();
}
/**
* 对单次规范化后的元数据执行危险内容检测。
*
* @param value 元数据
* @return 未发现危险内容时为 true
*/
private static boolean isSafePortableMetadataValue(String value) {
return value.chars().noneMatch(Character::isISOControl)
&& !SkillCredentialValueGuard.containsCredential(value)
&& !URI_USER_INFO.matcher(value).find()
&& !CREDENTIAL_QUERY.matcher(value).find()
&& !ABSOLUTE_PATH.matcher(value).find()
&& !FILE_URI.matcher(value).find();
}
/**
* 尝试解码一层百分号转义,非法转义保持原文。
*
* @param value 原始值
* @return 解码结果或原文
*/
private static String percentDecode(String value) {
try {
return URLDecoder.decode(value, StandardCharsets.UTF_8);
} catch (IllegalArgumentException exception) {
// 非法百分号转义不能安全规范化,按原文继续检查并由调用方的字段语法约束处理。
return value;
}
}
}

View File

@@ -0,0 +1,68 @@
package tech.easyflow.skill.security;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
/**
* Skill 平台配置的敏感字段白名单清洗器。
*/
public final class SkillSensitiveConfigSanitizer {
private static final Set<String> HITL_ALLOWED_KEYS = Set.of(
"prompt", "title", "description", "confirmLabel", "cancelLabel"
);
private static final Set<String> OPTIONS_ALLOWED_KEYS = Set.of(
"timeoutMs", "retryCount", "async", "readOnly"
);
private SkillSensitiveConfigSanitizer() {
}
/**
* 仅保留已定义的非敏感 HITL 展示配置。
*
* @param source 原始 HITL 配置
* @return 白名单配置
*/
public static Map<String, Object> sanitizeHitl(Map<String, Object> source) {
return sanitizeAllowed(source, HITL_ALLOWED_KEYS);
}
/**
* 仅保留已定义的非敏感执行选项。
*
* @param source 原始执行选项
* @return 白名单配置
*/
public static Map<String, Object> sanitizeOptions(Map<String, Object> source) {
return sanitizeAllowed(source, OPTIONS_ALLOWED_KEYS);
}
private static Map<String, Object> sanitizeAllowed(Map<String, Object> source, Set<String> allowedKeys) {
if (source == null || source.isEmpty()) {
return new LinkedHashMap<>();
}
Map<String, Object> sanitized = new LinkedHashMap<>();
for (Map.Entry<String, Object> entry : source.entrySet()) {
if (entry.getKey() == null || !allowedKeys.contains(entry.getKey())) {
continue;
}
Object value = sanitizeScalar(entry.getValue());
if (value != null) {
sanitized.put(entry.getKey(), value);
}
}
return sanitized;
}
private static Object sanitizeScalar(Object value) {
if (value == null) {
return null;
}
if (value instanceof String || value instanceof Number || value instanceof Boolean) {
return value;
}
return null;
}
}

Some files were not shown because too many files have changed in this diff Show More