feat: 完善 Skill 管理与发布治理
- 实现标准资源存储、能力绑定及双格式导入导出 - 接入分类、可见范围、审批发布与资源权限校验 - 补充并发、租户隔离、安全边界和迁移契约测试
This commit is contained in:
@@ -1,38 +1,45 @@
|
||||
package tech.easyflow.admin.controller.skill;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import tech.easyflow.common.annotation.UsePermission;
|
||||
import tech.easyflow.common.domain.Result;
|
||||
import tech.easyflow.common.web.controller.BaseCurdController;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
import tech.easyflow.skill.entity.Skill;
|
||||
import tech.easyflow.common.web.jsonbody.JsonBody;
|
||||
import tech.easyflow.skill.entity.SkillCategory;
|
||||
import tech.easyflow.skill.mapper.SkillMapper;
|
||||
import tech.easyflow.skill.service.SkillCategoryService;
|
||||
import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot;
|
||||
import tech.easyflow.system.enums.CategoryResourceType;
|
||||
import tech.easyflow.system.service.CategoryPermissionService;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.io.Serializable;
|
||||
import java.util.Collection;
|
||||
import java.math.BigInteger;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Skill 分类管理控制器。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/skillCategory")
|
||||
@RequestMapping("/api/v1/skill/category")
|
||||
@UsePermission(moduleName = "/api/v1/skill")
|
||||
public class SkillCategoryController extends BaseCurdController<SkillCategoryService, SkillCategory> {
|
||||
public class SkillCategoryController {
|
||||
|
||||
@Resource
|
||||
private SkillMapper skillMapper;
|
||||
@Resource
|
||||
private static final Set<String> SORT_COLUMNS = Set.of(
|
||||
"id", "category_name", "parent_id", "level_no", "sort_no", "status", "created", "modified");
|
||||
|
||||
private final SkillCategoryService service;
|
||||
@javax.annotation.Resource
|
||||
private CategoryPermissionService categoryPermissionService;
|
||||
|
||||
/**
|
||||
@@ -41,7 +48,7 @@ public class SkillCategoryController extends BaseCurdController<SkillCategorySer
|
||||
* @param service Skill 分类服务
|
||||
*/
|
||||
public SkillCategoryController(SkillCategoryService service) {
|
||||
super(service);
|
||||
this.service = service;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -54,8 +61,18 @@ public class SkillCategoryController extends BaseCurdController<SkillCategorySer
|
||||
* @return 可见分类列表
|
||||
*/
|
||||
@GetMapping("visibleList")
|
||||
@SaCheckPermission("/api/v1/skill/query")
|
||||
public Result<List<SkillCategory>> visibleList(SkillCategory entity, Boolean asTree, String sortKey, String sortType) {
|
||||
QueryWrapper queryWrapper = QueryWrapper.create(entity, buildOperators(entity));
|
||||
QueryWrapper queryWrapper = QueryWrapper.create()
|
||||
.eq(SkillCategory::getTenantId, currentAccount().getTenantId());
|
||||
if (entity != null) {
|
||||
queryWrapper.eq(SkillCategory::getId, entity.getId(), entity.getId() != null)
|
||||
.eq(SkillCategory::getParentId, entity.getParentId(), entity.getParentId() != null)
|
||||
.eq(SkillCategory::getLevelNo, entity.getLevelNo(), entity.getLevelNo() != null)
|
||||
.eq(SkillCategory::getStatus, entity.getStatus(), entity.getStatus() != null)
|
||||
.like(SkillCategory::getCategoryName, entity.getCategoryName(),
|
||||
entity.getCategoryName() != null && !entity.getCategoryName().isBlank());
|
||||
}
|
||||
RoleCategoryAccessSnapshot access = categoryPermissionService.getCurrentAccess(CategoryResourceType.SKILL.getCode());
|
||||
if (access.isRestricted()) {
|
||||
if (access.getCategoryIds().isEmpty()) {
|
||||
@@ -63,29 +80,157 @@ public class SkillCategoryController extends BaseCurdController<SkillCategorySer
|
||||
}
|
||||
queryWrapper.in("id", access.getCategoryIds());
|
||||
}
|
||||
queryWrapper.orderBy(buildOrderBy(sortKey, sortType, getDefaultOrderBy()));
|
||||
return Result.ok(service.list(queryWrapper));
|
||||
queryWrapper.orderBy(resolveOrderBy(sortKey, sortType));
|
||||
List<SkillCategory> categories = service.list(queryWrapper);
|
||||
return Result.ok(Boolean.FALSE.equals(asTree) ? categories : toTree(categories));
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除分类前校验是否仍被 Skill 使用。
|
||||
* 查询当前租户完整分类管理树,包含停用分类。
|
||||
*
|
||||
* @param ids 分类 ID 集合
|
||||
* @return 校验结果
|
||||
* @return 分类树
|
||||
*/
|
||||
@Override
|
||||
protected Result<?> onRemoveBefore(Collection<Serializable> ids) {
|
||||
for (Serializable id : ids) {
|
||||
List<Skill> skills = skillMapper.selectListByQuery(QueryWrapper.create().eq(Skill::getCategoryId, id));
|
||||
if (skills != null && !skills.isEmpty()) {
|
||||
throw new BusinessException("请先迁移或删除该分类下的 Skill");
|
||||
}
|
||||
List<SkillCategory> children = service.list(QueryWrapper.create().eq(SkillCategory::getParentId, id));
|
||||
if (children != null && !children.isEmpty()) {
|
||||
throw new BusinessException("请先删除子分类");
|
||||
@GetMapping("tree")
|
||||
@SaCheckPermission("/api/v1/skill/category")
|
||||
public Result<List<SkillCategory>> tree() {
|
||||
List<SkillCategory> categories = service.list(QueryWrapper.create()
|
||||
.eq(SkillCategory::getTenantId, currentAccount().getTenantId())
|
||||
.orderBy("sort_no asc, id asc"));
|
||||
return Result.ok(toTree(categories));
|
||||
}
|
||||
|
||||
/**
|
||||
* 移动 Skill 分类到新的父级。
|
||||
*
|
||||
* @param id 分类 ID
|
||||
* @param parentId 新父级 ID,根分类为空
|
||||
* @return 更新结果
|
||||
*/
|
||||
@PostMapping("move")
|
||||
@SaCheckPermission("/api/v1/skill/category")
|
||||
public Result<?> move(
|
||||
@JsonBody(value = "id", required = true, skipConvertError = false) BigInteger id,
|
||||
@JsonBody(value = "parentId", skipConvertError = false) BigInteger parentId) {
|
||||
SkillCategory category = service.getOne(QueryWrapper.create()
|
||||
.eq(SkillCategory::getId, id)
|
||||
.eq(SkillCategory::getTenantId, currentAccount().getTenantId()));
|
||||
if (category == null) {
|
||||
throw new BusinessException(404, 404, "Skill 分类不存在");
|
||||
}
|
||||
category.setParentId(parentId);
|
||||
if (!service.updateById(category)) {
|
||||
throw new BusinessException(500, 500, "移动 Skill 分类失败,请稍后重试");
|
||||
}
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 Skill 分类。
|
||||
*
|
||||
* @param entity 分类
|
||||
* @return 保存结果
|
||||
*/
|
||||
@PostMapping("save")
|
||||
@SaCheckPermission("/api/v1/skill/category")
|
||||
public Result<?> save(@JsonBody(required = true, skipConvertError = false) SkillCategory entity) {
|
||||
if (entity != null) {
|
||||
entity.setId(null);
|
||||
entity.setTenantId(null);
|
||||
entity.setAncestors(null);
|
||||
entity.setLevelNo(null);
|
||||
entity.setCreated(null);
|
||||
entity.setCreatedBy(null);
|
||||
entity.setModified(null);
|
||||
entity.setModifiedBy(null);
|
||||
}
|
||||
if (!service.save(entity)) {
|
||||
throw new BusinessException(500, 500, "创建 Skill 分类失败,请稍后重试");
|
||||
}
|
||||
return Result.ok(entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 Skill 分类。
|
||||
*
|
||||
* @param entity 分类
|
||||
* @return 更新结果
|
||||
*/
|
||||
@PostMapping("update")
|
||||
@SaCheckPermission("/api/v1/skill/category")
|
||||
public Result<?> update(@JsonBody(required = true, skipConvertError = false) SkillCategory entity) {
|
||||
if (entity != null) {
|
||||
entity.setTenantId(null);
|
||||
entity.setAncestors(null);
|
||||
entity.setLevelNo(null);
|
||||
entity.setCreated(null);
|
||||
entity.setCreatedBy(null);
|
||||
entity.setModified(null);
|
||||
entity.setModifiedBy(null);
|
||||
}
|
||||
if (entity == null || entity.getId() == null) {
|
||||
throw new BusinessException("Skill 分类 ID 不能为空");
|
||||
}
|
||||
if (!service.updateById(entity)) {
|
||||
throw new BusinessException(500, 500, "更新 Skill 分类失败,请稍后重试");
|
||||
}
|
||||
return Result.ok(entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 Skill 分类。
|
||||
*
|
||||
* @param id 分类 ID
|
||||
* @return 删除结果
|
||||
*/
|
||||
@PostMapping("remove")
|
||||
@SaCheckPermission("/api/v1/skill/category")
|
||||
public Result<?> remove(
|
||||
@JsonBody(value = "id", required = true, skipConvertError = false) Serializable id) {
|
||||
if (!service.removeById(id)) {
|
||||
throw new BusinessException(500, 500, "删除 Skill 分类失败,请稍后重试");
|
||||
}
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
private LoginAccount currentAccount() {
|
||||
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||
if (account == null || account.getId() == null || account.getTenantId() == null) {
|
||||
throw new BusinessException(401, 401, "未登录或登录态无效");
|
||||
}
|
||||
return account;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将分类排序参数收敛到固定字段白名单,禁止原始 SQL 片段进入查询。
|
||||
*
|
||||
* @param sortKey 排序字段
|
||||
* @param sortType 排序方向
|
||||
* @return 安全排序表达式
|
||||
*/
|
||||
String resolveOrderBy(String sortKey, String sortType) {
|
||||
String snake = sortKey == null ? "" : sortKey
|
||||
.replaceAll("([a-z0-9])([A-Z])", "$1_$2")
|
||||
.toLowerCase(Locale.ROOT);
|
||||
String column = SORT_COLUMNS.contains(snake) ? snake : "sort_no";
|
||||
String direction = "desc".equalsIgnoreCase(sortType) ? "desc" : "asc";
|
||||
return column + " " + direction + ("id".equals(column) ? "" : ", id asc");
|
||||
}
|
||||
|
||||
private List<SkillCategory> toTree(List<SkillCategory> categories) {
|
||||
Map<java.math.BigInteger, SkillCategory> byId = new LinkedHashMap<>();
|
||||
categories.forEach(category -> {
|
||||
category.setChildren(null);
|
||||
byId.put(category.getId(), category);
|
||||
});
|
||||
List<SkillCategory> roots = new java.util.ArrayList<>();
|
||||
for (SkillCategory category : categories) {
|
||||
SkillCategory parent = category.getParentId() == null ? null : byId.get(category.getParentId());
|
||||
if (parent == null) {
|
||||
roots.add(category);
|
||||
} else {
|
||||
parent.getChildren().add(category);
|
||||
}
|
||||
}
|
||||
return super.onRemoveBefore(ids);
|
||||
return roots;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
package tech.easyflow.admin.controller.skill;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||
import cn.dev33.satoken.annotation.SaMode;
|
||||
import cn.dev33.satoken.stp.StpUtil;
|
||||
import com.mybatisflex.core.paginate.Page;
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.util.StreamUtils;
|
||||
@@ -11,151 +12,247 @@ import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import tech.easyflow.admin.controller.ai.support.AiResourceCreatorNameSupport;
|
||||
import tech.easyflow.ai.enums.PublishStatus;
|
||||
import tech.easyflow.admin.controller.skill.vo.SkillCapabilityBindingRequest;
|
||||
import tech.easyflow.admin.controller.skill.vo.SkillCapabilityReplaceView;
|
||||
import tech.easyflow.admin.controller.skill.vo.SkillCopyRequest;
|
||||
import tech.easyflow.admin.controller.skill.vo.SkillDraftRequest;
|
||||
import tech.easyflow.admin.controller.skill.vo.SkillView;
|
||||
import tech.easyflow.admin.controller.skill.vo.SkillPublishStatusView;
|
||||
import tech.easyflow.approval.entity.vo.ApprovalActionResult;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.common.domain.Result;
|
||||
import tech.easyflow.common.web.controller.BaseCurdController;
|
||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
import tech.easyflow.common.web.jsonbody.JsonBody;
|
||||
import tech.easyflow.skill.capability.SkillCapabilityBindingService;
|
||||
import tech.easyflow.skill.capability.SkillCapabilityCandidate;
|
||||
import tech.easyflow.skill.entity.Skill;
|
||||
import tech.easyflow.skill.entity.SkillCapabilityBinding;
|
||||
import tech.easyflow.skill.enums.SkillCapabilityType;
|
||||
import tech.easyflow.skill.file.SkillFileContent;
|
||||
import tech.easyflow.skill.file.SkillFileNode;
|
||||
import tech.easyflow.skill.file.SkillFileRenameRequest;
|
||||
import tech.easyflow.skill.file.SkillFileSaveRequest;
|
||||
import tech.easyflow.skill.file.SkillFileService;
|
||||
import tech.easyflow.skill.imports.SkillExportRequest;
|
||||
import tech.easyflow.skill.imports.SkillExportArtifact;
|
||||
import tech.easyflow.skill.imports.SkillExportService;
|
||||
import tech.easyflow.skill.imports.SkillImportConfirmRequest;
|
||||
import tech.easyflow.skill.imports.SkillImportFormat;
|
||||
import tech.easyflow.skill.imports.SkillImportPreview;
|
||||
import tech.easyflow.skill.imports.SkillImportService;
|
||||
import tech.easyflow.skill.publish.SkillPublishAppService;
|
||||
import tech.easyflow.skill.security.SkillVisibilityQueryHelper;
|
||||
import tech.easyflow.skill.service.SkillApprovalStateService;
|
||||
import tech.easyflow.skill.service.SkillService;
|
||||
import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot;
|
||||
import tech.easyflow.skill.validation.SkillValidationResult;
|
||||
import tech.easyflow.system.enums.CategoryResourceType;
|
||||
import tech.easyflow.system.enums.ResourceAction;
|
||||
import tech.easyflow.system.service.CategoryPermissionService;
|
||||
import tech.easyflow.system.service.ResourceAccessService;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.Serializable;
|
||||
import java.math.BigInteger;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import static tech.easyflow.skill.entity.table.SkillTableDef.SKILL;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Skill 管理端控制器。
|
||||
* Skill 管理端 API,统一负责轻量查询、白名单写入、文件工作台、能力绑定和导入导出。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/skill")
|
||||
public class SkillController extends BaseCurdController<SkillService, Skill> {
|
||||
public class SkillController {
|
||||
|
||||
@Resource
|
||||
private SkillApprovalStateService skillApprovalStateService;
|
||||
@Resource
|
||||
private SkillPublishAppService skillPublishAppService;
|
||||
@Resource
|
||||
private SkillImportService skillImportService;
|
||||
@Resource
|
||||
private SkillExportService skillExportService;
|
||||
@Resource
|
||||
private SkillFileService skillFileService;
|
||||
@Resource
|
||||
private ResourceAccessService resourceAccessService;
|
||||
@Resource
|
||||
private CategoryPermissionService categoryPermissionService;
|
||||
@Resource
|
||||
private AiResourceCreatorNameSupport aiResourceCreatorNameSupport;
|
||||
private static final Set<String> PAGE_SORT_COLUMNS = Set.of(
|
||||
"id", "name", "display_name", "created", "modified", "publish_status", "resource_count", "capability_count");
|
||||
|
||||
private final SkillService skillService;
|
||||
private final SkillApprovalStateService skillApprovalStateService;
|
||||
private final SkillPublishAppService skillPublishAppService;
|
||||
private final SkillImportService skillImportService;
|
||||
private final SkillExportService skillExportService;
|
||||
private final SkillFileService skillFileService;
|
||||
private final SkillCapabilityBindingService capabilityBindingService;
|
||||
private final ResourceAccessService resourceAccessService;
|
||||
private final CategoryPermissionService categoryPermissionService;
|
||||
private final SkillVisibilityQueryHelper visibilityQueryHelper;
|
||||
private final AiResourceCreatorNameSupport creatorNameSupport;
|
||||
|
||||
/**
|
||||
* 创建 Skill 控制器。
|
||||
* 创建 Skill 管理控制器。
|
||||
*
|
||||
* @param service Skill 服务
|
||||
* @param skillService Skill 服务
|
||||
* @param skillApprovalStateService 审批状态服务
|
||||
* @param skillPublishAppService 发布服务
|
||||
* @param skillImportService 导入服务
|
||||
* @param skillExportService 导出服务
|
||||
* @param skillFileService 文件服务
|
||||
* @param capabilityBindingService 能力绑定服务
|
||||
* @param resourceAccessService 资源权限服务
|
||||
* @param categoryPermissionService 分类权限服务
|
||||
* @param visibilityQueryHelper 可见性查询助手
|
||||
* @param creatorNameSupport 创建人名称助手
|
||||
*/
|
||||
public SkillController(SkillService service) {
|
||||
super(service);
|
||||
public SkillController(SkillService skillService,
|
||||
SkillApprovalStateService skillApprovalStateService,
|
||||
SkillPublishAppService skillPublishAppService,
|
||||
SkillImportService skillImportService,
|
||||
SkillExportService skillExportService,
|
||||
SkillFileService skillFileService,
|
||||
SkillCapabilityBindingService capabilityBindingService,
|
||||
ResourceAccessService resourceAccessService,
|
||||
CategoryPermissionService categoryPermissionService,
|
||||
SkillVisibilityQueryHelper visibilityQueryHelper,
|
||||
AiResourceCreatorNameSupport creatorNameSupport) {
|
||||
this.skillService = skillService;
|
||||
this.skillApprovalStateService = skillApprovalStateService;
|
||||
this.skillPublishAppService = skillPublishAppService;
|
||||
this.skillImportService = skillImportService;
|
||||
this.skillExportService = skillExportService;
|
||||
this.skillFileService = skillFileService;
|
||||
this.capabilityBindingService = capabilityBindingService;
|
||||
this.resourceAccessService = resourceAccessService;
|
||||
this.categoryPermissionService = categoryPermissionService;
|
||||
this.visibilityQueryHelper = visibilityQueryHelper;
|
||||
this.creatorNameSupport = creatorNameSupport;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Skill 详情。
|
||||
* 分页查询当前用户可读的 Skill 描述信息。
|
||||
*
|
||||
* @param pageNumber 页码
|
||||
* @param pageSize 每页数量
|
||||
* @param categoryId 分类 ID
|
||||
* @param categoryScope 分类范围,UNCATEGORIZED 表示未分类
|
||||
* @param name 名称关键词
|
||||
* @param displayName 展示名称关键词
|
||||
* @param publishStatus 发布状态
|
||||
* @param sourceType 来源类型
|
||||
* @param capabilityType 能力类型
|
||||
* @param sortKey 排序字段
|
||||
* @param sortType 排序方向
|
||||
* @return 轻量分页结果
|
||||
*/
|
||||
@GetMapping("/page")
|
||||
@SaCheckPermission("/api/v1/skill/query")
|
||||
public Result<Page<SkillView>> page(Long pageNumber, Long pageSize, BigInteger categoryId, String categoryScope,
|
||||
String name, String displayName, String publishStatus, String sourceType,
|
||||
String capabilityType, String sortKey, String sortType) {
|
||||
long normalizedPage = pageNumber == null || pageNumber < 1 ? 1 : pageNumber;
|
||||
long normalizedSize = pageSize == null || pageSize < 1 ? 10 : Math.min(pageSize, 100);
|
||||
QueryWrapper query = descriptorQuery();
|
||||
visibilityQueryHelper.applyReadableAccess(query);
|
||||
if ("UNCATEGORIZED".equalsIgnoreCase(categoryScope)) {
|
||||
query.isNull("category_id");
|
||||
} else {
|
||||
query.eq("category_id", categoryId, categoryId != null);
|
||||
}
|
||||
query
|
||||
.eq("publish_status", publishStatus, hasText(publishStatus))
|
||||
.eq("source_type", sourceType, hasText(sourceType));
|
||||
String keyword = hasText(displayName) ? displayName : name;
|
||||
if (hasText(keyword)) {
|
||||
String pattern = "%" + keyword + "%";
|
||||
query.and("(name LIKE ? OR display_name LIKE ? OR description LIKE ?)", pattern, pattern, pattern);
|
||||
}
|
||||
if (hasText(capabilityType)) {
|
||||
SkillCapabilityType normalizedCapabilityType = SkillCapabilityType.from(capabilityType);
|
||||
query.and("EXISTS (SELECT 1 FROM tb_skill_capability_binding b "
|
||||
+ "WHERE b.skill_id = tb_skill.id AND b.tenant_id = tb_skill.tenant_id "
|
||||
+ "AND b.capability_type = ?)", normalizedCapabilityType.name());
|
||||
}
|
||||
query.orderBy(resolveSortColumn(sortKey) + ("asc".equalsIgnoreCase(sortType) ? " asc" : " desc"));
|
||||
Page<Skill> source = skillService.page(new Page<>(normalizedPage, normalizedSize), query);
|
||||
fillListState(source.getRecords());
|
||||
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||
boolean superAdmin = account != null && categoryPermissionService.isSuperAdmin(account);
|
||||
List<SkillView> records = source.getRecords().stream()
|
||||
.map(skill -> toPageView(skill, account, superAdmin)).toList();
|
||||
return Result.ok(new Page<>(records, source.getPageNumber(), source.getPageSize(), source.getTotalRow()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Skill 完整管理详情。
|
||||
*
|
||||
* @param id Skill ID
|
||||
* @return Skill 详情
|
||||
*/
|
||||
@GetMapping("/getDetail")
|
||||
public Result<Skill> getDetail(BigInteger id) {
|
||||
Skill skill = service.getDetail(id);
|
||||
skillApprovalStateService.fillSkillApprovalState(skill);
|
||||
return Result.ok(skill);
|
||||
@GetMapping("/detail")
|
||||
@SaCheckPermission("/api/v1/skill/getDetail")
|
||||
public Result<SkillView> detail(BigInteger id) {
|
||||
Skill skill = skillService.getManagementDetail(id);
|
||||
if (!StpUtil.hasPermission("/api/v1/skill/capability")) {
|
||||
skill.setCapabilityBindings(null);
|
||||
skill.setCapabilityHash(null);
|
||||
}
|
||||
fillListState(List.of(skill));
|
||||
return Result.ok(toView(skill));
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存 Skill 草稿。
|
||||
* 创建 Skill 草稿。
|
||||
*
|
||||
* @param skill Skill 草稿
|
||||
* @return 保存后的 Skill
|
||||
* @param request 草稿白名单请求
|
||||
* @return 创建后的 Skill
|
||||
*/
|
||||
@Override
|
||||
@PostMapping("save")
|
||||
public Result<?> save(@JsonBody Skill skill) {
|
||||
return Result.ok(service.saveDraft(skill));
|
||||
@PostMapping("/save")
|
||||
@SaCheckPermission("/api/v1/skill/save")
|
||||
public Result<SkillView> save(@JsonBody(required = true, skipConvertError = false) SkillDraftRequest request) {
|
||||
if (request == null || request.id() != null) {
|
||||
throw new BusinessException("创建 Skill 时不能指定 ID");
|
||||
}
|
||||
return Result.ok(toView(skillService.saveDraft(request.toEntity())));
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 Skill 草稿。
|
||||
*
|
||||
* @param skill Skill 草稿
|
||||
* @return 保存后的 Skill
|
||||
* @param request 草稿白名单请求
|
||||
* @return 更新后的 Skill
|
||||
*/
|
||||
@Override
|
||||
@PostMapping("update")
|
||||
public Result<?> update(@JsonBody Skill skill) {
|
||||
return Result.ok(service.updateDraft(skill));
|
||||
@PostMapping("/update")
|
||||
@SaCheckPermission("/api/v1/skill/update")
|
||||
public Result<SkillView> update(@JsonBody(required = true, skipConvertError = false) SkillDraftRequest request) {
|
||||
if (request == null || request.id() == null) {
|
||||
throw new BusinessException("Skill ID 不能为空");
|
||||
}
|
||||
return Result.ok(toView(skillService.updateDraft(request.toUpdateEntity())));
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览 zip 导入结果。
|
||||
* 复制已有 Skill 为当前用户拥有的新草稿。
|
||||
*
|
||||
* @param file zip 文件
|
||||
* @return 导入预览
|
||||
*/
|
||||
@PostMapping(value = "/import/preview", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@SaCheckPermission("/api/v1/skill/save")
|
||||
public Result<SkillImportPreview> importPreview(MultipartFile file) throws Exception {
|
||||
return Result.ok(skillImportService.preview(file.getInputStream()));
|
||||
* @param request 复制请求
|
||||
* @return 新建的 Skill 草稿
|
||||
*/
|
||||
@PostMapping("/copy")
|
||||
@SaCheckPermission(value = {"/api/v1/skill/save", "/api/v1/skill/capability"})
|
||||
public Result<SkillView> copy(@JsonBody(required = true, skipConvertError = false) SkillCopyRequest request) {
|
||||
if (request == null) {
|
||||
throw new BusinessException("复制参数不能为空");
|
||||
}
|
||||
return Result.ok(toView(skillService.copyDraft(request.sourceId(), request.name(),
|
||||
request.displayName(), request.categoryId())));
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认导入 zip。
|
||||
* 在展示发布确认前执行发布级全量校验。
|
||||
*
|
||||
* @param file zip 文件
|
||||
* @param categoryId 分类 ID
|
||||
* @param overwriteDraft 是否覆盖草稿
|
||||
* @return 导入后的 Skill 列表
|
||||
* @param id Skill ID
|
||||
* @return 包含实时能力解析的结构化校验结果
|
||||
*/
|
||||
@PostMapping(value = "/import/confirm", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@SaCheckPermission("/api/v1/skill/save")
|
||||
public Result<List<Skill>> importConfirm(MultipartFile file, BigInteger categoryId, Boolean overwriteDraft) throws Exception {
|
||||
return Result.ok(skillImportService.importZip(file.getInputStream(), categoryId, Boolean.TRUE.equals(overwriteDraft)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出 Skill zip。
|
||||
*
|
||||
* @param ids Skill ID 集合
|
||||
* @param response HTTP 响应
|
||||
*/
|
||||
@PostMapping("/export")
|
||||
public void export(@JsonBody(value = "ids", required = true) List<BigInteger> ids, HttpServletResponse response) throws Exception {
|
||||
response.setContentType("application/zip");
|
||||
response.setHeader("Content-Disposition", "attachment; filename=\"" + URLEncoder.encode("skills.zip", StandardCharsets.UTF_8) + "\"");
|
||||
skillExportService.exportZip(ids, response.getOutputStream());
|
||||
@PostMapping("/validatePublish")
|
||||
@SaCheckPermission("/api/v1/skill/submitPublishApproval")
|
||||
public Result<SkillValidationResult> validatePublish(
|
||||
@JsonBody(value = "id", required = true, skipConvertError = false) BigInteger id) {
|
||||
return Result.ok(skillService.validateSkill(id, true));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -165,78 +262,266 @@ public class SkillController extends BaseCurdController<SkillService, Skill> {
|
||||
* @return 文件树
|
||||
*/
|
||||
@GetMapping("/file/tree")
|
||||
@SaCheckPermission("/api/v1/skill/getDetail")
|
||||
public Result<List<SkillFileNode>> fileTree(BigInteger skillId) {
|
||||
return Result.ok(skillFileService.tree(skillId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Skill 文件内容。
|
||||
* 获取 Skill 文本文件内容或二进制摘要。
|
||||
*
|
||||
* @param skillId Skill ID
|
||||
* @param path 逻辑路径
|
||||
* @param path 包内路径
|
||||
* @return 文件内容
|
||||
*/
|
||||
@GetMapping("/file/content")
|
||||
@SaCheckPermission("/api/v1/skill/getDetail")
|
||||
public Result<SkillFileContent> fileContent(BigInteger skillId, String path) {
|
||||
return Result.ok(skillFileService.getContent(skillId, path));
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存 Skill 文本文件。
|
||||
* 保存已有文本文件。
|
||||
*
|
||||
* @param request 保存请求
|
||||
* @return 保存后的文件内容
|
||||
* @return 最新文件内容
|
||||
*/
|
||||
@PostMapping("/file/save")
|
||||
@SaCheckPermission("/api/v1/skill/save")
|
||||
public Result<SkillFileContent> saveFile(@JsonBody SkillFileSaveRequest request) {
|
||||
@SaCheckPermission("/api/v1/skill/file")
|
||||
public Result<SkillFileContent> saveFile(
|
||||
@JsonBody(required = true, skipConvertError = false) SkillFileSaveRequest request) {
|
||||
return Result.ok(skillFileService.saveContent(request));
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 Skill 逻辑文件。
|
||||
* 创建文本文件。
|
||||
*
|
||||
* @param request 创建请求
|
||||
* @return 文件内容
|
||||
*/
|
||||
@PostMapping("/file/create")
|
||||
@SaCheckPermission("/api/v1/skill/file")
|
||||
public Result<SkillFileContent> createFile(
|
||||
@JsonBody(required = true, skipConvertError = false) SkillFileSaveRequest request) {
|
||||
return Result.ok(skillFileService.createTextFile(request));
|
||||
}
|
||||
|
||||
/**
|
||||
* 重命名文件。
|
||||
*
|
||||
* @param request 重命名请求
|
||||
* @return 最新文件内容
|
||||
*/
|
||||
@PostMapping("/file/rename")
|
||||
@SaCheckPermission("/api/v1/skill/file")
|
||||
public Result<SkillFileContent> renameFile(
|
||||
@JsonBody(required = true, skipConvertError = false) SkillFileRenameRequest request) {
|
||||
return Result.ok(skillFileService.renameFile(request));
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除包内文件。
|
||||
*
|
||||
* @param skillId Skill ID
|
||||
* @param path 逻辑路径
|
||||
* @return 操作结果
|
||||
* @param path 文件路径
|
||||
* @return 空结果
|
||||
*/
|
||||
@PostMapping("/file/delete")
|
||||
@SaCheckPermission("/api/v1/skill/save")
|
||||
public Result<Void> deleteFile(@JsonBody(value = "skillId", required = true) BigInteger skillId,
|
||||
@JsonBody(value = "path", required = true) String path) {
|
||||
skillFileService.deleteFile(skillId, path);
|
||||
@SaCheckPermission("/api/v1/skill/file")
|
||||
public Result<Void> deleteFile(
|
||||
@JsonBody(value = "skillId", required = true, skipConvertError = false) BigInteger skillId,
|
||||
@JsonBody(value = "path", required = true, skipConvertError = false) String path,
|
||||
@JsonBody(value = "expectedContentHash", required = true, skipConvertError = false)
|
||||
String expectedContentHash) {
|
||||
skillFileService.deleteFile(skillId, path, expectedContentHash);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传 Skill asset。
|
||||
* 上传任意包内二进制资源。
|
||||
*
|
||||
* @param skillId Skill ID
|
||||
* @param path 逻辑路径
|
||||
* @param path 文件路径
|
||||
* @param file 上传文件
|
||||
* @return asset 内容
|
||||
* @return 文件摘要
|
||||
*/
|
||||
@PostMapping(value = "/file/asset/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@SaCheckPermission("/api/v1/skill/save")
|
||||
public Result<SkillFileContent> uploadAsset(BigInteger skillId, String path, MultipartFile file) {
|
||||
return Result.ok(skillFileService.uploadAsset(skillId, path, file));
|
||||
@PostMapping(value = "/file/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@SaCheckPermission("/api/v1/skill/file")
|
||||
public Result<SkillFileContent> uploadFile(BigInteger skillId,
|
||||
String path,
|
||||
String expectedContentHash,
|
||||
MultipartFile file) {
|
||||
return Result.ok(skillFileService.uploadResource(skillId, path, file, expectedContentHash));
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载或预览 Skill asset。
|
||||
* 下载包内文件。
|
||||
*
|
||||
* @param skillId Skill ID
|
||||
* @param path asset 逻辑路径
|
||||
* @param path 文件路径
|
||||
* @param response HTTP 响应
|
||||
* @throws IOException 响应写入失败
|
||||
*/
|
||||
@GetMapping("/file/download")
|
||||
@SaCheckPermission("/api/v1/skill/getDetail")
|
||||
public void downloadFile(BigInteger skillId, String path, HttpServletResponse response) throws IOException {
|
||||
transferFile(skillId, path, response, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全预览包内文件;主动内容强制下载。
|
||||
*
|
||||
* @param skillId Skill ID
|
||||
* @param path 文件路径
|
||||
* @param response HTTP 响应
|
||||
* @throws IOException 响应写入失败
|
||||
*/
|
||||
@GetMapping("/file/preview")
|
||||
@SaCheckPermission("/api/v1/skill/getDetail")
|
||||
public void previewFile(BigInteger skillId, String path, HttpServletResponse response) throws IOException {
|
||||
transferFile(skillId, path, response, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询 Skill 能力绑定。
|
||||
*
|
||||
* @param skillId Skill ID
|
||||
* @return 绑定列表
|
||||
*/
|
||||
@GetMapping("/capability/list")
|
||||
@SaCheckPermission(value = {"/api/v1/skill/getDetail", "/api/v1/skill/capability"})
|
||||
public Result<List<SkillView.CapabilityView>> capabilityList(BigInteger skillId) {
|
||||
return Result.ok(capabilityBindingService.listVisibleBindings(skillId).stream()
|
||||
.map(SkillView.CapabilityView::from).toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询当前用户可绑定的能力候选。
|
||||
*
|
||||
* @param type 能力类型
|
||||
* @param keyword 关键词
|
||||
* @return 候选列表
|
||||
*/
|
||||
@GetMapping("/capability/candidates")
|
||||
@SaCheckPermission(value = {"/api/v1/skill/capability", "/api/v1/skill/import"}, mode = SaMode.OR)
|
||||
public Result<List<SkillCapabilityCandidate>> capabilityCandidates(String type, String keyword) {
|
||||
return Result.ok(capabilityBindingService.listCandidates(SkillCapabilityType.from(type), keyword));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 MCP 工具名清单。
|
||||
*
|
||||
* @param targetId MCP ID
|
||||
* @return MCP 候选详情
|
||||
*/
|
||||
@GetMapping("/capability/tools")
|
||||
@SaCheckPermission(value = {"/api/v1/skill/capability", "/api/v1/skill/import"}, mode = SaMode.OR)
|
||||
public Result<SkillCapabilityCandidate> capabilityTools(BigInteger targetId) {
|
||||
return Result.ok(capabilityBindingService.getMcpTools(targetId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 原子替换 Skill 能力绑定。
|
||||
*
|
||||
* @param skillId Skill ID
|
||||
* @param requests 绑定白名单请求
|
||||
* @return 保存后的绑定
|
||||
*/
|
||||
@PostMapping("/capability/replace")
|
||||
@SaCheckPermission("/api/v1/skill/capability")
|
||||
public Result<SkillCapabilityReplaceView> replaceCapabilities(
|
||||
@JsonBody(value = "skillId", required = true, skipConvertError = false) BigInteger skillId,
|
||||
@JsonBody(value = "expectedCapabilityHash", required = true, skipConvertError = false)
|
||||
String expectedCapabilityHash,
|
||||
@JsonBody(value = "bindings", required = true, skipConvertError = false)
|
||||
List<SkillCapabilityBindingRequest> requests) {
|
||||
List<SkillCapabilityBinding> bindings = requests == null ? List.of()
|
||||
: requests.stream().map(SkillCapabilityBindingRequest::toEntity).toList();
|
||||
List<SkillCapabilityBinding> saved = capabilityBindingService.replaceBindings(
|
||||
skillId, bindings, expectedCapabilityHash);
|
||||
return Result.ok(new SkillCapabilityReplaceView(
|
||||
saved.stream().map(SkillView.CapabilityView::from).toList(),
|
||||
capabilityBindingService.calculateHash(saved)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览标准 ZIP 或 EasyFlow Bundle 导入内容。
|
||||
*
|
||||
* @param file 导入文件
|
||||
* @return token 化预览
|
||||
*/
|
||||
@PostMapping(value = "/import/preview", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@SaCheckPermission("/api/v1/skill/import")
|
||||
public Result<SkillImportPreview> importPreview(MultipartFile file) {
|
||||
return Result.ok(skillImportService.preview(file));
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用一次性 token 确认导入。
|
||||
*
|
||||
* @param request 导入确认请求
|
||||
* @return 导入后的 Skill
|
||||
*/
|
||||
@PostMapping("/import/confirm")
|
||||
@SaCheckPermission("/api/v1/skill/import")
|
||||
public Result<List<SkillView>> importConfirm(
|
||||
@JsonBody(required = true, skipConvertError = false) SkillImportConfirmRequest request) {
|
||||
return Result.ok(skillImportService.confirm(request).stream().map(this::toView).toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消导入并清理临时包。
|
||||
*
|
||||
* @param importToken 导入 token
|
||||
* @return 空结果
|
||||
*/
|
||||
@PostMapping("/import/cancel")
|
||||
@SaCheckPermission("/api/v1/skill/import")
|
||||
public Result<Void> importCancel(
|
||||
@JsonBody(value = "importToken", required = true, skipConvertError = false) String importToken) {
|
||||
skillImportService.cancel(importToken);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出标准 Skill ZIP 或 EasyFlow 增强包。
|
||||
*
|
||||
* @param request 导出请求
|
||||
* @param response HTTP 响应
|
||||
*/
|
||||
@GetMapping("/file/asset")
|
||||
public void asset(BigInteger skillId, String path, HttpServletResponse response) throws Exception {
|
||||
SkillFileContent content = skillFileService.getContent(skillId, path);
|
||||
response.setContentType(content.getMediaType() == null ? MediaType.APPLICATION_OCTET_STREAM_VALUE : content.getMediaType());
|
||||
response.setHeader("Content-Disposition", "inline; filename=\"" + URLEncoder.encode(fileName(path), StandardCharsets.UTF_8) + "\"");
|
||||
try (InputStream inputStream = skillFileService.openAsset(skillId, path)) {
|
||||
StreamUtils.copy(inputStream, response.getOutputStream());
|
||||
@PostMapping("/export")
|
||||
@SaCheckPermission("/api/v1/skill/export")
|
||||
public void export(@JsonBody(required = true, skipConvertError = false) SkillExportRequest request,
|
||||
HttpServletResponse response) {
|
||||
if (request == null || request.getIds().isEmpty()) {
|
||||
throw new BusinessException("请选择要导出的 Skill");
|
||||
}
|
||||
if (request.getIds().size() > 100) {
|
||||
throw new BusinessException("单次最多导出 100 个 Skill");
|
||||
}
|
||||
SkillImportFormat format = SkillImportFormat.from(request.getFormat());
|
||||
assertEnhancedExportPermission(format);
|
||||
try (SkillExportArtifact artifact = skillExportService.prepare(request.getIds(), format)) {
|
||||
response.setContentType(artifact.getMediaType());
|
||||
response.setHeader("Content-Disposition", attachment(artifact.getFileName()));
|
||||
artifact.transferTo(output(response));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出单个标准或增强 Skill 包。
|
||||
*
|
||||
* @param id Skill ID
|
||||
* @param format 导出格式
|
||||
* @param response HTTP 响应
|
||||
*/
|
||||
@GetMapping("/export")
|
||||
@SaCheckPermission("/api/v1/skill/export")
|
||||
public void exportOne(BigInteger id, String format, HttpServletResponse response) {
|
||||
if (id == null) {
|
||||
throw new BusinessException("Skill ID 不能为空");
|
||||
}
|
||||
writeExport(List.of(id), SkillImportFormat.from(format), response);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -246,9 +531,10 @@ public class SkillController extends BaseCurdController<SkillService, Skill> {
|
||||
* @return 审批实例 ID
|
||||
*/
|
||||
@PostMapping("/submitPublishApproval")
|
||||
@SaCheckPermission("/api/v1/skill/save")
|
||||
public Result<BigInteger> submitPublishApproval(@JsonBody("id") BigInteger id) {
|
||||
return buildApprovalActionResult(skillPublishAppService.submitPublishApproval(id), "已提交发布审批", "已直接发布");
|
||||
@SaCheckPermission("/api/v1/skill/submitPublishApproval")
|
||||
public Result<BigInteger> submitPublishApproval(
|
||||
@JsonBody(value = "id", required = true, skipConvertError = false) BigInteger id) {
|
||||
return approvalResult(skillPublishAppService.submitPublishApproval(id), "已提交发布审批", "已直接发布");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -258,9 +544,10 @@ public class SkillController extends BaseCurdController<SkillService, Skill> {
|
||||
* @return 审批实例 ID
|
||||
*/
|
||||
@PostMapping("/submitOfflineApproval")
|
||||
@SaCheckPermission("/api/v1/skill/save")
|
||||
public Result<BigInteger> submitOfflineApproval(@JsonBody("id") BigInteger id) {
|
||||
return buildApprovalActionResult(skillPublishAppService.submitOfflineApproval(id), "已提交下线审批", "已直接下线");
|
||||
@SaCheckPermission("/api/v1/skill/submitOfflineApproval")
|
||||
public Result<BigInteger> submitOfflineApproval(
|
||||
@JsonBody(value = "id", required = true, skipConvertError = false) BigInteger id) {
|
||||
return approvalResult(skillPublishAppService.submitOfflineApproval(id), "已提交下线审批", "已直接下线");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -270,90 +557,142 @@ public class SkillController extends BaseCurdController<SkillService, Skill> {
|
||||
* @return 审批实例 ID
|
||||
*/
|
||||
@PostMapping("/submitDeleteApproval")
|
||||
@SaCheckPermission("/api/v1/skill/remove")
|
||||
public Result<BigInteger> submitDeleteApproval(@JsonBody("id") BigInteger id) {
|
||||
return buildApprovalActionResult(skillPublishAppService.submitDeleteApproval(id), "已提交删除审批", "已直接删除");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Result<?> onRemoveBefore(Collection<Serializable> ids) {
|
||||
for (Serializable id : ids) {
|
||||
Skill skill = service.getById(String.valueOf(id));
|
||||
if (skill != null) {
|
||||
resourceAccessService.assertAccess(CategoryResourceType.SKILL, skill, ResourceAction.MANAGE, "无权限删除该 Skill");
|
||||
}
|
||||
}
|
||||
return super.onRemoveBefore(ids);
|
||||
@SaCheckPermission("/api/v1/skill/submitDeleteApproval")
|
||||
public Result<BigInteger> submitDeleteApproval(
|
||||
@JsonBody(value = "id", required = true, skipConvertError = false) BigInteger id) {
|
||||
return approvalResult(skillPublishAppService.submitDeleteApproval(id), "已提交删除审批", "已直接删除");
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询 Skill 分页。
|
||||
* 查询 Skill 发布和审批派生状态。
|
||||
*
|
||||
* @param page 分页参数
|
||||
* @param queryWrapper 查询条件
|
||||
* @return Skill 分页
|
||||
* @param id Skill ID
|
||||
* @return 发布状态
|
||||
*/
|
||||
@Override
|
||||
protected Page<Skill> queryPage(Page<Skill> page, QueryWrapper queryWrapper) {
|
||||
if (!applyCategoryPermission(queryWrapper)) {
|
||||
return new Page<>(Collections.emptyList(), page.getPageNumber(), page.getPageSize(), 0L);
|
||||
@GetMapping("/publish/status")
|
||||
@SaCheckPermission("/api/v1/skill/getDetail")
|
||||
public Result<SkillPublishStatusView> publishStatus(BigInteger id) {
|
||||
QueryWrapper query = descriptorQuery().eq(Skill::getId, id);
|
||||
visibilityQueryHelper.applyReadableAccess(query);
|
||||
Skill skill = skillService.getOne(query);
|
||||
if (skill == null) {
|
||||
throw new BusinessException(404, 404, "Skill 不存在");
|
||||
}
|
||||
applyPublishedOnlyFilter(queryWrapper);
|
||||
Page<Skill> result = super.queryPage(page, queryWrapper);
|
||||
if (isPublishedOnlyRequest()) {
|
||||
result.setRecords(result.getRecords().stream().map(skill -> service.fromSnapshot(skill.getPublishedSnapshotJson())).toList());
|
||||
}
|
||||
skillApprovalStateService.fillSkillApprovalState(result.getRecords());
|
||||
aiResourceCreatorNameSupport.fillSkillCreatorNames(result.getRecords());
|
||||
return result;
|
||||
fillListState(List.of(skill));
|
||||
return Result.ok(new SkillPublishStatusView(skill.getId(), skill.getPublishStatus(),
|
||||
skill.getApprovalPending(), skill.getCurrentApprovalActionType(), skill.getDisplayPublishStatus(),
|
||||
skill.getCurrentApprovalInstanceId()));
|
||||
}
|
||||
|
||||
private boolean applyCategoryPermission(QueryWrapper queryWrapper) {
|
||||
RoleCategoryAccessSnapshot access = categoryPermissionService.getCurrentAccess(CategoryResourceType.SKILL.getCode());
|
||||
if (!access.isRestricted()) {
|
||||
return true;
|
||||
}
|
||||
if (access.getCategoryIds().isEmpty()) {
|
||||
queryWrapper.eq(Skill::getCreatedBy, access.getAccountId());
|
||||
return true;
|
||||
}
|
||||
queryWrapper.and(SKILL.CREATED_BY.eq(access.getAccountId()).or(SKILL.CATEGORY_ID.in(access.getCategoryIds())));
|
||||
return true;
|
||||
private QueryWrapper descriptorQuery() {
|
||||
return QueryWrapper.create().select("id", "tenant_id", "dept_id", "category_id", "name", "display_name", "description",
|
||||
"enabled", "visibility_scope", "source_type", "package_hash", "capability_hash", "snapshot_hash",
|
||||
"resource_count", "capability_count", "reference_count", "script_count", "asset_count",
|
||||
"publish_status", "current_approval_instance_id", "created", "created_by", "modified", "modified_by");
|
||||
}
|
||||
|
||||
private void applyPublishedOnlyFilter(QueryWrapper queryWrapper) {
|
||||
if (isPublishedOnlyRequest()) {
|
||||
queryWrapper.eq("publish_status", PublishStatus.PUBLISHED.getCode());
|
||||
private void writeExport(List<BigInteger> ids, SkillImportFormat format, HttpServletResponse response) {
|
||||
assertEnhancedExportPermission(format);
|
||||
try (SkillExportArtifact artifact = skillExportService.prepare(ids, format)) {
|
||||
response.setContentType(artifact.getMediaType());
|
||||
response.setHeader("Content-Disposition", attachment(artifact.getFileName()));
|
||||
artifact.transferTo(output(response));
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isPublishedOnlyRequest() {
|
||||
HttpServletRequest request = currentRequest();
|
||||
if (request == null) {
|
||||
return false;
|
||||
/**
|
||||
* EasyFlow 增强包包含能力配置,导出时额外校验能力绑定查看权限。
|
||||
*
|
||||
* @param format 导出格式
|
||||
*/
|
||||
void assertEnhancedExportPermission(SkillImportFormat format) {
|
||||
if (SkillImportFormat.EASYFLOW == format) {
|
||||
StpUtil.checkPermission("/api/v1/skill/capability");
|
||||
}
|
||||
return "true".equalsIgnoreCase(request.getParameter("publishedOnly"));
|
||||
}
|
||||
|
||||
private HttpServletRequest currentRequest() {
|
||||
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
||||
if (attributes == null) {
|
||||
return null;
|
||||
}
|
||||
return attributes.getRequest();
|
||||
private void fillListState(List<Skill> skills) {
|
||||
skillApprovalStateService.fillSkillApprovalState(skills);
|
||||
creatorNameSupport.fillSkillCreatorNames(skills);
|
||||
}
|
||||
|
||||
private Result<BigInteger> buildApprovalActionResult(ApprovalActionResult actionResult,
|
||||
String approvalMessage,
|
||||
String directMessage) {
|
||||
return Result.ok(actionResult.isApprovalRequired() ? approvalMessage : directMessage, actionResult.getInstanceId());
|
||||
private SkillView toView(Skill skill) {
|
||||
boolean readable = resourceAccessService.canAccess(CategoryResourceType.SKILL, skill, ResourceAction.READ);
|
||||
boolean manageable = resourceAccessService.canAccess(CategoryResourceType.SKILL, skill, ResourceAction.MANAGE);
|
||||
return SkillView.from(skill, readable, manageable);
|
||||
}
|
||||
|
||||
private SkillView toPageView(Skill skill, LoginAccount account, boolean superAdmin) {
|
||||
boolean sameTenant = account != null && account.getTenantId() != null
|
||||
&& Objects.equals(account.getTenantId(), skill.getTenantId());
|
||||
boolean manageable = sameTenant && (superAdmin || Objects.equals(account.getId(), skill.getCreatedBy()));
|
||||
return SkillView.from(skill, sameTenant, manageable);
|
||||
}
|
||||
|
||||
private void transferFile(BigInteger skillId, String path, HttpServletResponse response, boolean preview) throws IOException {
|
||||
SkillFileContent content = skillFileService.getContent(skillId, path);
|
||||
String mediaType = content.getMediaType() == null ? MediaType.APPLICATION_OCTET_STREAM_VALUE : content.getMediaType();
|
||||
boolean inline = preview && isSafeInline(mediaType);
|
||||
response.setContentType(inline ? mediaType : MediaType.APPLICATION_OCTET_STREAM_VALUE);
|
||||
response.setHeader("X-Content-Type-Options", "nosniff");
|
||||
response.setHeader("Content-Security-Policy", "sandbox; default-src 'none'");
|
||||
response.setHeader("Content-Disposition", (inline ? "inline" : "attachment") + filenameParameter(fileName(path)));
|
||||
if (Boolean.TRUE.equals(content.getIsText())) {
|
||||
response.getOutputStream().write((content.getContent() == null ? "" : content.getContent())
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
return;
|
||||
}
|
||||
try (InputStream inputStream = skillFileService.openResource(skillId, path)) {
|
||||
StreamUtils.copy(inputStream, response.getOutputStream());
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isSafeInline(String mediaType) {
|
||||
String normalized = mediaType.toLowerCase(Locale.ROOT).split(";", 2)[0];
|
||||
return normalized.equals("application/pdf") || normalized.equals("text/plain")
|
||||
|| normalized.equals("text/markdown") || normalized.equals("image/png")
|
||||
|| normalized.equals("image/jpeg") || normalized.equals("image/gif")
|
||||
|| normalized.equals("image/webp") || normalized.equals("image/avif");
|
||||
}
|
||||
|
||||
private String resolveSortColumn(String sortKey) {
|
||||
if (!hasText(sortKey)) {
|
||||
return "modified";
|
||||
}
|
||||
String snake = sortKey.replaceAll("([a-z0-9])([A-Z])", "$1_$2").toLowerCase(Locale.ROOT);
|
||||
return PAGE_SORT_COLUMNS.contains(snake) ? snake : "modified";
|
||||
}
|
||||
|
||||
private String attachment(String fileName) {
|
||||
return "attachment" + filenameParameter(fileName);
|
||||
}
|
||||
|
||||
private String filenameParameter(String fileName) {
|
||||
String encoded = URLEncoder.encode(fileName, StandardCharsets.UTF_8).replace("+", "%20");
|
||||
return "; filename*=UTF-8''" + encoded;
|
||||
}
|
||||
|
||||
private String fileName(String path) {
|
||||
if (path == null || path.isBlank()) {
|
||||
return "asset";
|
||||
if (!hasText(path)) {
|
||||
return "resource.bin";
|
||||
}
|
||||
int index = path.lastIndexOf('/');
|
||||
return index < 0 ? path : path.substring(index + 1);
|
||||
}
|
||||
|
||||
private Result<BigInteger> approvalResult(ApprovalActionResult result, String approvalMessage, String directMessage) {
|
||||
return Result.ok(result.isApprovalRequired() ? approvalMessage : directMessage, result.getInstanceId());
|
||||
}
|
||||
|
||||
private java.io.OutputStream output(HttpServletResponse response) {
|
||||
try {
|
||||
return response.getOutputStream();
|
||||
} catch (IOException exception) {
|
||||
throw new BusinessException(500, 500, "创建 Skill 导出响应失败", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean hasText(String value) {
|
||||
return value != null && !value.isBlank();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package tech.easyflow.admin.controller.skill.vo;
|
||||
|
||||
import tech.easyflow.skill.entity.SkillCapabilityBinding;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Skill 能力绑定写入白名单。
|
||||
*
|
||||
* @param capabilityType 能力类型
|
||||
* @param targetId 当前环境目标 ID
|
||||
* @param targetLogicalRef 跨环境逻辑引用
|
||||
* @param runtimeName 运行时名称
|
||||
* @param enabled 是否启用
|
||||
* @param selectionMode MCP 工具选择模式
|
||||
* @param selectedToolNamesJson 已选 MCP 工具
|
||||
* @param executionMode 执行模式
|
||||
* @param hitlEnabled 是否需要人工确认
|
||||
* @param hitlConfigJson 人工确认安全配置
|
||||
* @param optionsJson 执行安全配置
|
||||
* @param sortNo 排序号
|
||||
*/
|
||||
public record SkillCapabilityBindingRequest(String capabilityType,
|
||||
BigInteger targetId,
|
||||
String targetLogicalRef,
|
||||
String runtimeName,
|
||||
Boolean enabled,
|
||||
String selectionMode,
|
||||
List<String> selectedToolNamesJson,
|
||||
String executionMode,
|
||||
Boolean hitlEnabled,
|
||||
Map<String, Object> hitlConfigJson,
|
||||
Map<String, Object> optionsJson,
|
||||
Integer sortNo) {
|
||||
|
||||
/**
|
||||
* 转换为能力绑定业务实体。
|
||||
*
|
||||
* @return 仅包含可写字段的绑定实体
|
||||
*/
|
||||
public SkillCapabilityBinding toEntity() {
|
||||
SkillCapabilityBinding binding = new SkillCapabilityBinding();
|
||||
binding.setCapabilityType(capabilityType);
|
||||
binding.setTargetId(targetId);
|
||||
binding.setTargetLogicalRef(targetLogicalRef);
|
||||
binding.setRuntimeName(runtimeName);
|
||||
binding.setEnabled(enabled);
|
||||
binding.setSelectionMode(selectionMode);
|
||||
binding.setSelectedToolNamesJson(selectedToolNamesJson);
|
||||
binding.setExecutionMode(executionMode);
|
||||
binding.setHitlEnabled(hitlEnabled);
|
||||
binding.setHitlConfigJson(hitlConfigJson);
|
||||
binding.setOptionsJson(optionsJson);
|
||||
binding.setSortNo(sortNo);
|
||||
return binding;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package tech.easyflow.admin.controller.skill.vo;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 能力绑定原子替换结果。
|
||||
*
|
||||
* @param bindings 保存后的白名单绑定视图
|
||||
* @param capabilityHash 新能力配置哈希
|
||||
*/
|
||||
public record SkillCapabilityReplaceView(List<SkillView.CapabilityView> bindings,
|
||||
String capabilityHash) {
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package tech.easyflow.admin.controller.skill.vo;
|
||||
|
||||
import java.math.BigInteger;
|
||||
|
||||
/**
|
||||
* Skill 复制请求白名单。
|
||||
*
|
||||
* @param sourceId 源 Skill ID
|
||||
* @param name 新 Skill 标准名称
|
||||
* @param displayName 新 Skill 展示名称
|
||||
* @param categoryId 目标分类 ID,可为空
|
||||
*/
|
||||
public record SkillCopyRequest(BigInteger sourceId,
|
||||
String name,
|
||||
String displayName,
|
||||
BigInteger categoryId) {
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package tech.easyflow.admin.controller.skill.vo;
|
||||
|
||||
import tech.easyflow.skill.entity.Skill;
|
||||
|
||||
import java.math.BigInteger;
|
||||
|
||||
/**
|
||||
* Skill 草稿写入白名单,拒绝客户端覆盖租户、归属人、发布态、快照和 hash 等服务端字段。
|
||||
*
|
||||
* @param id Skill ID,创建时为空
|
||||
* @param categoryId 分类 ID
|
||||
* @param displayName 展示名称
|
||||
* @param skillContent SKILL.md 内容,仅创建时使用;已有草稿正文通过文件接口原子保存
|
||||
* @param enabled 是否启用
|
||||
* @param visibilityScope 可见范围
|
||||
*/
|
||||
public record SkillDraftRequest(BigInteger id,
|
||||
BigInteger categoryId,
|
||||
String displayName,
|
||||
String skillContent,
|
||||
Boolean enabled,
|
||||
String visibilityScope) {
|
||||
|
||||
/**
|
||||
* 转换为仅包含可写字段的业务实体。
|
||||
*
|
||||
* @return Skill 草稿实体
|
||||
*/
|
||||
public Skill toEntity() {
|
||||
Skill skill = new Skill();
|
||||
skill.setId(id);
|
||||
skill.setCategoryId(categoryId);
|
||||
skill.setDisplayName(displayName);
|
||||
skill.setSkillContent(skillContent);
|
||||
skill.setEnabled(enabled);
|
||||
skill.setVisibilityScope(visibilityScope);
|
||||
return skill;
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为不包含 SKILL.md 正文的基础配置更新实体。
|
||||
*
|
||||
* @return Skill 基础配置实体
|
||||
*/
|
||||
public Skill toUpdateEntity() {
|
||||
Skill skill = toEntity();
|
||||
skill.setSkillContent(null);
|
||||
return skill;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package tech.easyflow.admin.controller.skill.vo;
|
||||
|
||||
import java.math.BigInteger;
|
||||
|
||||
/**
|
||||
* Skill 发布和审批派生状态。
|
||||
*
|
||||
* @param id Skill ID
|
||||
* @param publishStatus 真实发布状态
|
||||
* @param approvalPending 是否存在进行中审批
|
||||
* @param currentApprovalActionType 当前审批动作
|
||||
* @param displayPublishStatus 前端展示状态
|
||||
* @param currentApprovalInstanceId 当前审批实例 ID
|
||||
*/
|
||||
public record SkillPublishStatusView(BigInteger id,
|
||||
String publishStatus,
|
||||
Boolean approvalPending,
|
||||
String currentApprovalActionType,
|
||||
String displayPublishStatus,
|
||||
BigInteger currentApprovalInstanceId) {
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package tech.easyflow.admin.controller.skill.vo;
|
||||
|
||||
import tech.easyflow.skill.entity.Skill;
|
||||
import tech.easyflow.skill.entity.SkillCapabilityBinding;
|
||||
import tech.easyflow.skill.entity.SkillResource;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 管理端 Skill 视图,不暴露租户字段、二进制内部引用和发布快照。
|
||||
*
|
||||
* @param id Skill ID
|
||||
* @param categoryId 分类 ID
|
||||
* @param name 规范名称
|
||||
* @param displayName 展示名称
|
||||
* @param description 描述
|
||||
* @param metadataJson frontmatter 扩展元数据
|
||||
* @param skillContent SKILL.md 内容
|
||||
* @param enabled 是否启用
|
||||
* @param visibilityScope 可见范围
|
||||
* @param sourceType 来源类型
|
||||
* @param packageHash 包 hash
|
||||
* @param capabilityHash 能力 hash
|
||||
* @param snapshotHash 发布快照 hash
|
||||
* @param resourceCount 资源数
|
||||
* @param capabilityCount 能力数
|
||||
* @param referenceCount 参考文档数
|
||||
* @param scriptCount 脚本数
|
||||
* @param assetCount 二进制资源数
|
||||
* @param publishStatus 发布状态
|
||||
* @param currentApprovalInstanceId 当前审批实例 ID
|
||||
* @param approvalPending 是否审批中
|
||||
* @param currentApprovalActionType 当前审批动作
|
||||
* @param displayPublishStatus 展示发布状态
|
||||
* @param created 创建时间
|
||||
* @param modified 修改时间
|
||||
* @param createdByName 创建人名称
|
||||
* @param readable 当前用户是否可读
|
||||
* @param manageable 当前用户是否可管理
|
||||
* @param resources 包内资源摘要
|
||||
* @param bindings 能力绑定
|
||||
*/
|
||||
public record SkillView(BigInteger id,
|
||||
BigInteger categoryId,
|
||||
String name,
|
||||
String displayName,
|
||||
String description,
|
||||
Map<String, Object> metadataJson,
|
||||
String skillContent,
|
||||
Boolean enabled,
|
||||
String visibilityScope,
|
||||
String sourceType,
|
||||
String packageHash,
|
||||
String capabilityHash,
|
||||
String snapshotHash,
|
||||
Integer resourceCount,
|
||||
Integer capabilityCount,
|
||||
Integer referenceCount,
|
||||
Integer scriptCount,
|
||||
Integer assetCount,
|
||||
String publishStatus,
|
||||
BigInteger currentApprovalInstanceId,
|
||||
Boolean approvalPending,
|
||||
String currentApprovalActionType,
|
||||
String displayPublishStatus,
|
||||
Date created,
|
||||
Date modified,
|
||||
String createdByName,
|
||||
boolean readable,
|
||||
boolean manageable,
|
||||
List<ResourceView> resources,
|
||||
List<CapabilityView> bindings) {
|
||||
|
||||
/**
|
||||
* 从业务实体创建安全视图。
|
||||
*
|
||||
* @param skill Skill 实体
|
||||
* @param readable 是否可读
|
||||
* @param manageable 是否可管理
|
||||
* @return Skill 管理视图
|
||||
*/
|
||||
public static SkillView from(Skill skill, boolean readable, boolean manageable) {
|
||||
List<ResourceView> resources = skill.getResources() == null ? null
|
||||
: skill.getResources().stream().map(ResourceView::from).toList();
|
||||
List<CapabilityView> bindings = skill.getCapabilityBindings() == null ? null
|
||||
: skill.getCapabilityBindings().stream()
|
||||
.map(binding -> CapabilityView.from(binding, manageable)).toList();
|
||||
return new SkillView(skill.getId(), skill.getCategoryId(), skill.getName(), skill.getDisplayName(),
|
||||
skill.getDescription(), skill.getMetadataJson(), skill.getSkillContent(), skill.getEnabled(),
|
||||
skill.getVisibilityScope(), skill.getSourceType(), skill.getPackageHash(), skill.getCapabilityHash(),
|
||||
skill.getSnapshotHash(), skill.getResourceCount(), skill.getCapabilityCount(), skill.getReferenceCount(),
|
||||
skill.getScriptCount(), skill.getAssetCount(), skill.getPublishStatus(),
|
||||
skill.getCurrentApprovalInstanceId(), skill.getApprovalPending(), skill.getCurrentApprovalActionType(),
|
||||
skill.getDisplayPublishStatus(), skill.getCreated(), skill.getModified(), skill.getCreatedByName(),
|
||||
readable, manageable, resources, bindings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill 包内资源摘要。
|
||||
*
|
||||
* @param id 资源 ID
|
||||
* @param path 路径
|
||||
* @param kind 类型
|
||||
* @param language 脚本语言
|
||||
* @param mediaType 媒体类型
|
||||
* @param isText 是否文本
|
||||
* @param contentHash 内容 hash
|
||||
* @param size 字节数
|
||||
* @param metadataJson 扩展元数据
|
||||
*/
|
||||
public record ResourceView(BigInteger id, String path, String kind, String language, String mediaType,
|
||||
Boolean isText, String contentHash, Long size, Map<String, Object> metadataJson) {
|
||||
|
||||
/**
|
||||
* 转换资源实体。
|
||||
*
|
||||
* @param resource 资源实体
|
||||
* @return 资源视图
|
||||
*/
|
||||
public static ResourceView from(SkillResource resource) {
|
||||
return new ResourceView(resource.getId(), resource.getNormalizedPath(), resource.getKind(),
|
||||
resource.getLanguage(), resource.getMediaType(), resource.getIsText(), resource.getContentHash(),
|
||||
resource.getSize(), resource.getMetadataJson());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill 能力绑定视图。
|
||||
*
|
||||
* @param id 绑定 ID
|
||||
* @param capabilityType 能力类型
|
||||
* @param targetId 目标 ID
|
||||
* @param targetLogicalRef 跨环境逻辑引用
|
||||
* @param runtimeName 运行时名称
|
||||
* @param enabled 是否启用
|
||||
* @param selectionMode 工具选择模式
|
||||
* @param selectedToolNamesJson 已选工具
|
||||
* @param executionMode 执行模式
|
||||
* @param hitlEnabled 是否人工确认
|
||||
* @param hitlConfigJson 人工确认安全配置
|
||||
* @param optionsJson 执行安全配置
|
||||
* @param sortNo 排序号
|
||||
* @param targetName 目标名称
|
||||
* @param targetStatus 目标状态
|
||||
* @param resolvedToolNames 已解析工具
|
||||
*/
|
||||
public record CapabilityView(BigInteger id, String capabilityType, BigInteger targetId, String targetLogicalRef,
|
||||
String runtimeName, Boolean enabled, String selectionMode,
|
||||
List<String> selectedToolNamesJson, String executionMode, Boolean hitlEnabled,
|
||||
Map<String, Object> hitlConfigJson, Map<String, Object> optionsJson, Integer sortNo,
|
||||
String targetName, String targetStatus, List<String> resolvedToolNames) {
|
||||
|
||||
/**
|
||||
* 转换绑定实体。
|
||||
*
|
||||
* @param binding 绑定实体
|
||||
* @return 绑定视图
|
||||
*/
|
||||
public static CapabilityView from(SkillCapabilityBinding binding) {
|
||||
return from(binding, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按管理权限转换绑定实体,READ 用户看不到当前环境内部目标 ID。
|
||||
*
|
||||
* @param binding 绑定实体
|
||||
* @param includeTargetId 是否包含目标 ID
|
||||
* @return 绑定视图
|
||||
*/
|
||||
public static CapabilityView from(SkillCapabilityBinding binding, boolean includeTargetId) {
|
||||
boolean hideUnavailableTarget = !includeTargetId && "NO_PERMISSION".equals(binding.getTargetStatus());
|
||||
return new CapabilityView(binding.getId(), binding.getCapabilityType(),
|
||||
includeTargetId ? binding.getTargetId() : null,
|
||||
binding.getTargetLogicalRef(), binding.getRuntimeName(), binding.getEnabled(),
|
||||
binding.getSelectionMode(), binding.getSelectedToolNamesJson(), binding.getExecutionMode(),
|
||||
binding.getHitlEnabled(), binding.getHitlConfigJson(), binding.getOptionsJson(), binding.getSortNo(),
|
||||
hideUnavailableTarget ? null : binding.getTargetName(), binding.getTargetStatus(),
|
||||
hideUnavailableTarget ? List.of() : binding.getResolvedToolNames());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package tech.easyflow.admin.controller.skill;
|
||||
|
||||
import org.testng.Assert;
|
||||
import org.testng.annotations.Test;
|
||||
import tech.easyflow.skill.service.SkillCategoryService;
|
||||
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
/**
|
||||
* {@link SkillCategoryController} 查询参数安全契约测试。
|
||||
*/
|
||||
public class SkillCategoryControllerContractTest {
|
||||
|
||||
/**
|
||||
* 分类排序只接受固定字段和方向,恶意片段应回退到默认排序。
|
||||
*/
|
||||
@Test
|
||||
public void categorySortUsesStrictAllowlist() {
|
||||
SkillCategoryController controller = new SkillCategoryController(mock(SkillCategoryService.class));
|
||||
|
||||
Assert.assertEquals(controller.resolveOrderBy("categoryName", "desc"),
|
||||
"category_name desc, id asc");
|
||||
Assert.assertEquals(controller.resolveOrderBy("sort_no desc; drop table tb_skill", null),
|
||||
"sort_no asc, id asc");
|
||||
Assert.assertEquals(controller.resolveOrderBy("id", "unexpected"), "id asc");
|
||||
}
|
||||
|
||||
/**
|
||||
* 分类控制器不得继承未加租户范围的通用 list、page 和 detail 入口。
|
||||
*/
|
||||
@Test
|
||||
public void categoryControllerDoesNotExposeInheritedCrudQueries() {
|
||||
Assert.expectThrows(NoSuchMethodException.class,
|
||||
() -> SkillCategoryController.class.getMethod("detail", String.class));
|
||||
Assert.assertFalse(java.util.Arrays.stream(SkillCategoryController.class.getMethods())
|
||||
.anyMatch(method -> "list".equals(method.getName()) || "page".equals(method.getName())));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
package tech.easyflow.admin.controller.skill;
|
||||
|
||||
import com.alibaba.fastjson.JSON;
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||
import cn.dev33.satoken.stp.StpUtil;
|
||||
import org.testng.Assert;
|
||||
import org.testng.annotations.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
import tech.easyflow.admin.controller.ai.support.AiResourceCreatorNameSupport;
|
||||
import tech.easyflow.admin.controller.skill.vo.SkillCapabilityBindingRequest;
|
||||
import tech.easyflow.admin.controller.skill.vo.SkillCopyRequest;
|
||||
import tech.easyflow.admin.controller.skill.vo.SkillDraftRequest;
|
||||
import tech.easyflow.admin.controller.skill.vo.SkillView;
|
||||
import tech.easyflow.common.domain.Result;
|
||||
import tech.easyflow.common.web.jsonbody.JsonBody;
|
||||
import tech.easyflow.common.web.jsonbody.JsonBodyParser;
|
||||
import tech.easyflow.skill.capability.SkillCapabilityBindingService;
|
||||
import tech.easyflow.skill.entity.Skill;
|
||||
import tech.easyflow.skill.entity.SkillCapabilityBinding;
|
||||
import tech.easyflow.skill.file.SkillFileService;
|
||||
import tech.easyflow.skill.imports.SkillExportService;
|
||||
import tech.easyflow.skill.imports.SkillImportFormat;
|
||||
import tech.easyflow.skill.imports.SkillImportService;
|
||||
import tech.easyflow.skill.publish.SkillPublishAppService;
|
||||
import tech.easyflow.skill.security.SkillVisibilityQueryHelper;
|
||||
import tech.easyflow.skill.service.SkillApprovalStateService;
|
||||
import tech.easyflow.skill.service.SkillService;
|
||||
import tech.easyflow.skill.validation.SkillValidationResult;
|
||||
import tech.easyflow.system.service.CategoryPermissionService;
|
||||
import tech.easyflow.system.service.ResourceAccessService;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.ParameterizedType;
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* {@link SkillController} 写入 DTO 与返回视图静态契约测试。
|
||||
*/
|
||||
public class SkillControllerContractTest {
|
||||
|
||||
/**
|
||||
* 验证当前 Fastjson 与 JsonBody 解析链路支持 Skill 草稿 record。
|
||||
*
|
||||
* @throws Exception DTO 反序列化失败
|
||||
*/
|
||||
@Test
|
||||
public void jsonBodyParserDeserializesSkillDraftRecord() throws Exception {
|
||||
JSONObject json = JSON.parseObject("""
|
||||
{
|
||||
"id": 101,
|
||||
"categoryId": 9,
|
||||
"displayName": "演示 Skill",
|
||||
"skillContent": "---\\nname: demo-skill\\ndescription: Demo\\n---\\n# Demo\\n",
|
||||
"enabled": true,
|
||||
"visibilityScope": "PRIVATE"
|
||||
}
|
||||
""");
|
||||
|
||||
SkillDraftRequest request = (SkillDraftRequest) JsonBodyParser.parseJsonBody(
|
||||
json, SkillDraftRequest.class, SkillDraftRequest.class, "");
|
||||
|
||||
Assert.assertEquals(request.id(), BigInteger.valueOf(101));
|
||||
Assert.assertEquals(request.categoryId(), BigInteger.valueOf(9));
|
||||
Assert.assertEquals(request.displayName(), "演示 Skill");
|
||||
Assert.assertTrue(request.enabled());
|
||||
Assert.assertEquals(request.visibilityScope(), "PRIVATE");
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证当前 Fastjson 与 JsonBody 解析链路支持含集合和映射的能力绑定 record。
|
||||
*
|
||||
* @throws Exception DTO 反序列化失败
|
||||
*/
|
||||
@Test
|
||||
public void jsonBodyParserDeserializesCapabilityBindingRecord() throws Exception {
|
||||
JSONObject json = JSON.parseObject("""
|
||||
{
|
||||
"capabilityType": "MCP",
|
||||
"targetId": 77,
|
||||
"targetLogicalRef": "mcp://demo",
|
||||
"runtimeName": "demo_mcp",
|
||||
"enabled": true,
|
||||
"selectionMode": "SELECTED",
|
||||
"selectedToolNamesJson": ["search", "fetch"],
|
||||
"executionMode": "SYNC",
|
||||
"hitlEnabled": true,
|
||||
"hitlConfigJson": {"prompt": "确认执行"},
|
||||
"optionsJson": {"timeoutMs": 3000},
|
||||
"sortNo": 2
|
||||
}
|
||||
""");
|
||||
|
||||
SkillCapabilityBindingRequest request = (SkillCapabilityBindingRequest) JsonBodyParser.parseJsonBody(
|
||||
json, SkillCapabilityBindingRequest.class, SkillCapabilityBindingRequest.class, "");
|
||||
|
||||
Assert.assertEquals(request.capabilityType(), "MCP");
|
||||
Assert.assertEquals(request.targetId(), BigInteger.valueOf(77));
|
||||
Assert.assertEquals(request.selectedToolNamesJson(), List.of("search", "fetch"));
|
||||
Assert.assertEquals(request.hitlConfigJson().get("prompt"), "确认执行");
|
||||
Assert.assertEquals(((Number) request.optionsJson().get("timeoutMs")).intValue(), 3000);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证草稿写入口使用 JsonBody 白名单 DTO,并返回 SkillView。
|
||||
*
|
||||
* @throws Exception 控制器方法反射失败
|
||||
*/
|
||||
@Test
|
||||
public void saveEndpointUsesDraftRequestAndSkillView() throws Exception {
|
||||
Method method = SkillController.class.getMethod("save", SkillDraftRequest.class);
|
||||
JsonBody jsonBody = method.getParameters()[0].getAnnotation(JsonBody.class);
|
||||
ParameterizedType returnType = (ParameterizedType) method.getGenericReturnType();
|
||||
|
||||
Assert.assertNotNull(jsonBody);
|
||||
Assert.assertEquals(returnType.getRawType(), Result.class);
|
||||
Assert.assertEquals(returnType.getActualTypeArguments()[0], SkillView.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证详情视图按 MANAGE 权限隐藏或保留当前环境目标 ID。
|
||||
*/
|
||||
@Test
|
||||
public void detailViewProjectsCapabilityTargetIdByManagePermission() {
|
||||
SkillCapabilityBinding binding = binding(BigInteger.valueOf(77));
|
||||
Skill skill = new Skill();
|
||||
skill.setCapabilityBindings(List.of(binding));
|
||||
|
||||
SkillView readOnly = SkillView.from(skill, true, false);
|
||||
SkillView manageable = SkillView.from(skill, true, true);
|
||||
|
||||
Assert.assertNull(readOnly.bindings().get(0).targetId());
|
||||
Assert.assertEquals(manageable.bindings().get(0).targetId(), BigInteger.valueOf(77));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证能力替换响应沿用可编辑投影并保留目标 ID。
|
||||
*/
|
||||
@Test
|
||||
public void replaceResponseProjectionKeepsEditableTargetId() {
|
||||
SkillView.CapabilityView view = SkillView.CapabilityView.from(binding(BigInteger.valueOf(88)));
|
||||
|
||||
Assert.assertEquals(view.targetId(), BigInteger.valueOf(88));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证能力列表端点只调用按权限脱敏的读取方法。
|
||||
*/
|
||||
@Test
|
||||
public void capabilityListEndpointUsesPermissionAwareBindingRead() {
|
||||
BigInteger skillId = BigInteger.valueOf(101);
|
||||
SkillCapabilityBindingService bindingService = mock(SkillCapabilityBindingService.class);
|
||||
SkillCapabilityBinding redacted = binding(null);
|
||||
when(bindingService.listVisibleBindings(skillId)).thenReturn(List.of(redacted));
|
||||
SkillController controller = controller(bindingService);
|
||||
|
||||
Result<List<SkillView.CapabilityView>> result = controller.capabilityList(skillId);
|
||||
|
||||
Assert.assertNull(result.getData().get(0).targetId());
|
||||
verify(bindingService).listVisibleBindings(skillId);
|
||||
verify(bindingService, never()).listBindings(skillId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证复制需要新建和能力绑定双重操作权限。
|
||||
*
|
||||
* @throws Exception 控制器方法反射失败
|
||||
*/
|
||||
@Test
|
||||
public void copyEndpointDeclaresIndependentOperationPermissions() throws Exception {
|
||||
SaCheckPermission copyPermission = SkillController.class
|
||||
.getMethod("copy", SkillCopyRequest.class).getAnnotation(SaCheckPermission.class);
|
||||
|
||||
Assert.assertEquals(copyPermission.value(),
|
||||
new String[]{"/api/v1/skill/save", "/api/v1/skill/capability"});
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证正式删除审批入口使用真实操作权限,不引用历史死权限。
|
||||
*
|
||||
* @throws Exception 控制器方法反射失败
|
||||
*/
|
||||
@Test
|
||||
public void deleteEndpointUsesCanonicalDeletePermission() throws Exception {
|
||||
SaCheckPermission submitPermission = SkillController.class
|
||||
.getMethod("submitDeleteApproval", BigInteger.class).getAnnotation(SaCheckPermission.class);
|
||||
|
||||
Assert.assertEquals(submitPermission.value(),
|
||||
new String[]{"/api/v1/skill/submitDeleteApproval"});
|
||||
Assert.assertFalse(java.util.Arrays.stream(SkillController.class.getDeclaredMethods())
|
||||
.map(method -> method.getAnnotation(SaCheckPermission.class))
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.flatMap(permission -> java.util.Arrays.stream(permission.value()))
|
||||
.anyMatch("/api/v1/skill/remove"::equals));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证发布预检复用发布权限,并明确调用发布级校验。
|
||||
*
|
||||
* @throws Exception 控制器方法反射失败
|
||||
*/
|
||||
@Test
|
||||
public void publishValidationUsesPublishPermissionAndFullValidation() throws Exception {
|
||||
BigInteger skillId = BigInteger.valueOf(101);
|
||||
SkillService skillService = mock(SkillService.class);
|
||||
SkillValidationResult validation = new SkillValidationResult();
|
||||
validation.setValid(true);
|
||||
when(skillService.validateSkill(skillId, true)).thenReturn(validation);
|
||||
SkillController controller = controller(skillService, mock(SkillCapabilityBindingService.class));
|
||||
|
||||
Result<SkillValidationResult> result = controller.validatePublish(skillId);
|
||||
SaCheckPermission permission = SkillController.class
|
||||
.getMethod("validatePublish", BigInteger.class)
|
||||
.getAnnotation(SaCheckPermission.class);
|
||||
|
||||
Assert.assertSame(result.getData(), validation);
|
||||
Assert.assertEquals(permission.value(), new String[]{"/api/v1/skill/submitPublishApproval"});
|
||||
verify(skillService).validateSkill(skillId, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证标准导出不追加能力权限,EasyFlow 增强导出必须检查能力绑定查看权限。
|
||||
*/
|
||||
@Test
|
||||
public void enhancedExportRequiresCapabilityPermission() {
|
||||
SkillController controller = controller(mock(SkillCapabilityBindingService.class));
|
||||
|
||||
try (MockedStatic<StpUtil> stp = mockStatic(StpUtil.class)) {
|
||||
controller.assertEnhancedExportPermission(SkillImportFormat.STANDARD);
|
||||
stp.verify(() -> StpUtil.checkPermission("/api/v1/skill/capability"), never());
|
||||
|
||||
controller.assertEnhancedExportPermission(SkillImportFormat.EASYFLOW);
|
||||
stp.verify(() -> StpUtil.checkPermission("/api/v1/skill/capability"), times(1));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建测试能力绑定。
|
||||
*
|
||||
* @param targetId 目标 ID
|
||||
* @return 能力绑定
|
||||
*/
|
||||
private SkillCapabilityBinding binding(BigInteger targetId) {
|
||||
SkillCapabilityBinding binding = new SkillCapabilityBinding();
|
||||
binding.setId(BigInteger.ONE);
|
||||
binding.setCapabilityType("MCP");
|
||||
binding.setTargetId(targetId);
|
||||
binding.setTargetLogicalRef("mcp:demo");
|
||||
binding.setRuntimeName("demo_mcp");
|
||||
binding.setEnabled(true);
|
||||
binding.setSelectionMode("ALL");
|
||||
binding.setHitlEnabled(false);
|
||||
return binding;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建只注入能力服务的控制器测试实例。
|
||||
*
|
||||
* @param bindingService 能力绑定服务
|
||||
* @return 控制器实例
|
||||
*/
|
||||
private SkillController controller(SkillCapabilityBindingService bindingService) {
|
||||
return controller(mock(SkillService.class), bindingService);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建注入指定 Skill 与能力服务的控制器测试实例。
|
||||
*
|
||||
* @param skillService Skill 服务
|
||||
* @param bindingService 能力绑定服务
|
||||
* @return 控制器实例
|
||||
*/
|
||||
private SkillController controller(SkillService skillService, SkillCapabilityBindingService bindingService) {
|
||||
return new SkillController(skillService, mock(SkillApprovalStateService.class),
|
||||
mock(SkillPublishAppService.class), mock(SkillImportService.class), mock(SkillExportService.class),
|
||||
mock(SkillFileService.class), bindingService, mock(ResourceAccessService.class),
|
||||
mock(CategoryPermissionService.class), mock(SkillVisibilityQueryHelper.class),
|
||||
mock(AiResourceCreatorNameSupport.class));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package tech.easyflow.admin.controller.skill;
|
||||
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import org.testng.Assert;
|
||||
import org.testng.annotations.Test;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Locale;
|
||||
|
||||
import static org.mockito.Answers.CALLS_REAL_METHODS;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
/**
|
||||
* Skill 列表轻量投影的权限字段回归测试。
|
||||
*/
|
||||
public class SkillControllerProjectionTenantTest {
|
||||
|
||||
/**
|
||||
* 验证列表投影包含内部 tenant_id,以便资源权限派生时不会将合法记录误判为不可读。
|
||||
*
|
||||
* @throws Exception 反射调用失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void descriptorProjectionShouldIncludeTenantId() throws Exception {
|
||||
SkillController controller = mock(SkillController.class, CALLS_REAL_METHODS);
|
||||
Method method = SkillController.class.getDeclaredMethod("descriptorQuery");
|
||||
method.setAccessible(true);
|
||||
|
||||
QueryWrapper query = (QueryWrapper) method.invoke(controller);
|
||||
|
||||
Assert.assertTrue(query.toSQL().toLowerCase(Locale.ROOT).contains("tenant_id"),
|
||||
"Skill descriptor projection 缺少 tenant_id: " + query.toSQL());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证列表投影不会加载正文或发布快照等重字段。
|
||||
*
|
||||
* @throws Exception 反射调用失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void descriptorProjectionShouldExcludeHeavyContent() throws Exception {
|
||||
SkillController controller = mock(SkillController.class, CALLS_REAL_METHODS);
|
||||
Method method = SkillController.class.getDeclaredMethod("descriptorQuery");
|
||||
method.setAccessible(true);
|
||||
|
||||
String sql = ((QueryWrapper) method.invoke(controller)).toSQL().toLowerCase(Locale.ROOT);
|
||||
|
||||
Assert.assertFalse(sql.contains("skill_content"), "列表投影不应加载 SKILL.md 正文: " + sql);
|
||||
Assert.assertFalse(sql.contains("published_snapshot_json"), "列表投影不应加载发布快照: " + sql);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
// 隔离类加载器符合测试前提。
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 + "'}";
|
||||
}
|
||||
}
|
||||
@@ -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'}";
|
||||
}
|
||||
}
|
||||
@@ -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("文件存储写句柄不属于本地后端");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,15 @@ public interface ApprovalActionFacade {
|
||||
*/
|
||||
ApprovalActionResult submit(ApprovalSubmitRequest request);
|
||||
|
||||
/**
|
||||
* 判断当前登录用户是否经资源处理器授权查看审批详情。
|
||||
*
|
||||
* @param resourceType 资源类型
|
||||
* @param identifier 资源标识
|
||||
* @return 允许查看时返回 {@code true}
|
||||
*/
|
||||
boolean canAccessApprovalDetail(String resourceType, Object identifier);
|
||||
|
||||
/**
|
||||
* 处理审批通过后的业务回调。
|
||||
*
|
||||
|
||||
@@ -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);
|
||||
|
||||
/**
|
||||
* 校验资源是否已发布。
|
||||
*
|
||||
|
||||
@@ -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}
|
||||
*/
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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 审批实例租户迁移");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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("不支持的能力执行模式");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 工具选择模式");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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("不支持的能力类型");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user