feat: 增加技能管理模块试验性功能,等待优化

This commit is contained in:
2026-06-08 16:52:41 +08:00
parent cb379e071c
commit 950148b3f7
68 changed files with 4901 additions and 135 deletions

View File

@@ -0,0 +1,14 @@
package tech.easyflow.skill.config;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.context.annotation.ComponentScan;
/**
* Skill 模块自动配置。
*/
@MapperScan("tech.easyflow.skill.mapper")
@ComponentScan("tech.easyflow.skill")
@AutoConfiguration
public class SkillModuleConfig {
}

View File

@@ -0,0 +1,135 @@
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 tech.easyflow.system.permission.resource.VisibilityResource;
import java.io.Serializable;
import java.math.BigInteger;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Skill 主实体。
*/
@Table("tb_skill")
public class Skill extends DateEntity implements VisibilityResource, Serializable {
private static final long serialVersionUID = 1L;
@Id(keyType = KeyType.Generator, value = "snowFlakeId")
private BigInteger id;
@Column(tenantId = true)
private BigInteger tenantId;
private BigInteger deptId;
private BigInteger categoryId;
private String name;
private String displayName;
private String description;
@Column(typeHandler = FastjsonTypeHandler.class)
private Map<String, Object> metadataJson = new LinkedHashMap<>();
private String skillContent;
private Boolean enabled;
private String visibilityScope;
private String sourceType;
private String packageHash;
private Integer referenceCount;
private Integer scriptCount;
private Integer assetCount;
private String publishStatus;
private BigInteger currentApprovalInstanceId;
@Column(typeHandler = FastjsonTypeHandler.class)
private Map<String, Object> publishedSnapshotJson = new LinkedHashMap<>();
private Date publishedAt;
private BigInteger publishedBy;
private Date created;
private BigInteger createdBy;
private Date modified;
private BigInteger modifiedBy;
@Column(ignore = true)
private Boolean approvalPending;
@Column(ignore = true)
private String currentApprovalActionType;
@Column(ignore = true)
private String displayPublishStatus;
@Column(ignore = true)
private String createdByName;
@Column(ignore = true)
private List<SkillReference> references;
@Column(ignore = true)
private List<SkillScript> scripts;
@Column(ignore = true)
private List<SkillAsset> assets;
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 getDeptId() { return deptId; }
public void setDeptId(BigInteger deptId) { this.deptId = deptId; }
public BigInteger getCategoryId() { return categoryId; }
public void setCategoryId(BigInteger categoryId) { this.categoryId = categoryId; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getDisplayName() { return displayName; }
public void setDisplayName(String displayName) { this.displayName = displayName; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public Map<String, Object> getMetadataJson() { return metadataJson; }
public void setMetadataJson(Map<String, Object> metadataJson) { this.metadataJson = metadataJson == null ? new LinkedHashMap<>() : metadataJson; }
public String getSkillContent() { return skillContent; }
public void setSkillContent(String skillContent) { this.skillContent = skillContent; }
public Boolean getEnabled() { return enabled; }
public void setEnabled(Boolean enabled) { this.enabled = enabled; }
public String getVisibilityScope() { return visibilityScope; }
public void setVisibilityScope(String visibilityScope) { this.visibilityScope = visibilityScope; }
public String getSourceType() { return sourceType; }
public void setSourceType(String sourceType) { this.sourceType = sourceType; }
public String getPackageHash() { return packageHash; }
public void setPackageHash(String packageHash) { this.packageHash = packageHash; }
public Integer getReferenceCount() { return referenceCount; }
public void setReferenceCount(Integer referenceCount) { this.referenceCount = referenceCount; }
public Integer getScriptCount() { return scriptCount; }
public void setScriptCount(Integer scriptCount) { this.scriptCount = scriptCount; }
public Integer getAssetCount() { return assetCount; }
public void setAssetCount(Integer assetCount) { this.assetCount = assetCount; }
public String getPublishStatus() { return publishStatus; }
public void setPublishStatus(String publishStatus) { this.publishStatus = publishStatus; }
public BigInteger getCurrentApprovalInstanceId() { return currentApprovalInstanceId; }
public void setCurrentApprovalInstanceId(BigInteger currentApprovalInstanceId) { this.currentApprovalInstanceId = currentApprovalInstanceId; }
public Map<String, Object> getPublishedSnapshotJson() { return publishedSnapshotJson; }
public void setPublishedSnapshotJson(Map<String, Object> publishedSnapshotJson) { this.publishedSnapshotJson = publishedSnapshotJson == null ? new LinkedHashMap<>() : publishedSnapshotJson; }
public Date getPublishedAt() { return publishedAt; }
public void setPublishedAt(Date publishedAt) { this.publishedAt = publishedAt; }
public BigInteger getPublishedBy() { return publishedBy; }
public void setPublishedBy(BigInteger publishedBy) { this.publishedBy = publishedBy; }
@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 Boolean getApprovalPending() { return approvalPending; }
public void setApprovalPending(Boolean approvalPending) { this.approvalPending = approvalPending; }
public String getCurrentApprovalActionType() { return currentApprovalActionType; }
public void setCurrentApprovalActionType(String currentApprovalActionType) { this.currentApprovalActionType = currentApprovalActionType; }
public String getDisplayPublishStatus() { return displayPublishStatus; }
public void setDisplayPublishStatus(String displayPublishStatus) { this.displayPublishStatus = displayPublishStatus; }
public String getCreatedByName() { return createdByName; }
public void setCreatedByName(String createdByName) { this.createdByName = createdByName; }
public List<SkillReference> getReferences() { return references; }
public void setReferences(List<SkillReference> references) { this.references = references; }
public List<SkillScript> getScripts() { return scripts; }
public void setScripts(List<SkillScript> scripts) { this.scripts = scripts; }
public List<SkillAsset> getAssets() { return assets; }
public void setAssets(List<SkillAsset> assets) { this.assets = assets; }
}

View File

@@ -0,0 +1,56 @@
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 java.io.Serializable;
import java.math.BigInteger;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Skill asset 实体。
*/
@Table("tb_skill_asset")
public class SkillAsset 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 name;
private String mediaType;
private String contentRef;
private String contentHash;
private Long size;
@Column(typeHandler = FastjsonTypeHandler.class)
private Map<String, Object> metadataJson = new LinkedHashMap<>();
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 getName() { return name; }
public void setName(String name) { this.name = name; }
public String getMediaType() { return mediaType; }
public void setMediaType(String mediaType) { this.mediaType = mediaType; }
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; }
}

View File

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

View File

@@ -0,0 +1,60 @@
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 tech.easyflow.common.entity.DateEntity;
import java.io.Serializable;
import java.math.BigInteger;
import java.util.Date;
/**
* Skill 分类实体。
*/
@Table("tb_skill_category")
public class SkillCategory 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 parentId;
private String categoryName;
private Integer levelNo;
private String ancestors;
private Integer sortNo;
private Integer status;
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 getParentId() { return parentId; }
public void setParentId(BigInteger parentId) { this.parentId = parentId; }
public String getCategoryName() { return categoryName; }
public void setCategoryName(String categoryName) { this.categoryName = categoryName; }
public Integer getLevelNo() { return levelNo; }
public void setLevelNo(Integer levelNo) { this.levelNo = levelNo; }
public String getAncestors() { return ancestors; }
public void setAncestors(String ancestors) { this.ancestors = ancestors; }
public Integer getSortNo() { return sortNo; }
public void setSortNo(Integer sortNo) { this.sortNo = sortNo; }
public Integer getStatus() { return status; }
public void setStatus(Integer status) { this.status = status; }
@Override public Date getCreated() { return created; }
@Override public void setCreated(Date created) { this.created = created; }
public BigInteger getCreatedBy() { return createdBy; }
public void setCreatedBy(BigInteger createdBy) { this.createdBy = createdBy; }
@Override public Date getModified() { return modified; }
@Override public void setModified(Date modified) { this.modified = modified; }
public BigInteger getModifiedBy() { return modifiedBy; }
public void setModifiedBy(BigInteger modifiedBy) { this.modifiedBy = modifiedBy; }
}

View File

@@ -0,0 +1,53 @@
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 java.io.Serializable;
import java.math.BigInteger;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Skill reference 文档实体。
*/
@Table("tb_skill_reference")
public class SkillReference 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 name;
private String content;
private String contentHash;
private Long size;
@Column(typeHandler = FastjsonTypeHandler.class)
private Map<String, Object> metadataJson = new LinkedHashMap<>();
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 getName() { return name; }
public void setName(String name) { this.name = name; }
public String getContent() { return content; }
public void setContent(String content) { this.content = content; }
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; }
}

View File

@@ -0,0 +1,53 @@
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 java.io.Serializable;
import java.math.BigInteger;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Skill script 实体。
*/
@Table("tb_skill_script")
public class SkillScript 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 language;
private String content;
private String contentHash;
private Long size;
@Column(typeHandler = FastjsonTypeHandler.class)
private Map<String, Object> metadataJson = new LinkedHashMap<>();
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 getLanguage() { return language; }
public void setLanguage(String language) { this.language = language; }
public String getContent() { return content; }
public void setContent(String content) { this.content = content; }
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; }
}

View File

@@ -0,0 +1,31 @@
package tech.easyflow.skill.file;
/**
* Skill 逻辑文件内容。
*/
public class SkillFileContent {
private String path;
private String type;
private String content;
private String language;
private String mediaType;
private Long size;
private String downloadUrl;
public String getPath() { return path; }
public void setPath(String path) { this.path = path; }
public String getType() { return type; }
public void setType(String type) { this.type = type; }
public String getContent() { return content; }
public void setContent(String content) { this.content = content; }
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 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; }
}

View File

@@ -0,0 +1,37 @@
package tech.easyflow.skill.file;
import java.util.ArrayList;
import java.util.List;
/**
* Skill 逻辑文件树节点。
*/
public class SkillFileNode {
private String key;
private String path;
private String name;
private String type;
private String language;
private String mediaType;
private Long size;
private List<SkillFileNode> children = new ArrayList<>();
public String getKey() { return key; }
public void setKey(String key) { this.key = key; }
public String getPath() { return path; }
public void setPath(String path) { this.path = path; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getType() { return type; }
public void setType(String type) { this.type = type; }
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 Long getSize() { return size; }
public void setSize(Long size) { this.size = size; }
public List<SkillFileNode> getChildren() { return children; }
public void setChildren(List<SkillFileNode> children) { this.children = children == null ? new ArrayList<>() : children; }
}

View File

@@ -0,0 +1,21 @@
package tech.easyflow.skill.file;
import java.math.BigInteger;
/**
* Skill 文本文件保存请求。
*/
public class SkillFileSaveRequest {
private BigInteger skillId;
private String path;
private String content;
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 getContent() { return content; }
public void setContent(String content) { this.content = content; }
}

View File

@@ -0,0 +1,66 @@
package tech.easyflow.skill.file;
import org.springframework.web.multipart.MultipartFile;
import java.io.InputStream;
import java.math.BigInteger;
import java.util.List;
/**
* Skill 逻辑文件服务。
*/
public interface SkillFileService {
/**
* 获取 Skill 文件树。
*
* @param skillId Skill ID
* @return 文件树
*/
List<SkillFileNode> tree(BigInteger skillId);
/**
* 获取逻辑文件内容。
*
* @param skillId Skill ID
* @param path 逻辑路径
* @return 文件内容
*/
SkillFileContent getContent(BigInteger skillId, String path);
/**
* 保存文本文件内容。
*
* @param request 保存请求
* @return 保存后的文件内容
*/
SkillFileContent saveContent(SkillFileSaveRequest request);
/**
* 删除逻辑文件。
*
* @param skillId Skill ID
* @param path 逻辑路径
*/
void deleteFile(BigInteger skillId, String path);
/**
* 上传 asset 文件。
*
* @param skillId Skill ID
* @param path asset 逻辑路径
* @param file 上传文件
* @return asset 文件内容
*/
SkillFileContent uploadAsset(BigInteger skillId, String path, MultipartFile file);
/**
* 打开 asset 输入流。
*
* @param skillId Skill ID
* @param path asset 逻辑路径
* @return asset 输入流
*/
InputStream openAsset(BigInteger skillId, String path);
}

View File

@@ -0,0 +1,460 @@
package tech.easyflow.skill.file;
import com.easyagents.skill.model.SkillScriptLanguage;
import com.easyagents.skill.util.SkillHashes;
import com.easyagents.skill.util.SkillPaths;
import com.mybatisflex.core.query.QueryWrapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
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.SkillAssetService;
import tech.easyflow.skill.service.SkillReferenceService;
import tech.easyflow.skill.service.SkillScriptService;
import tech.easyflow.skill.service.SkillService;
import tech.easyflow.skill.store.DBSkillContentStore;
import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.enums.ResourceAction;
import tech.easyflow.system.service.ResourceAccessService;
import java.io.IOException;
import java.io.InputStream;
import java.math.BigInteger;
import java.net.URLConnection;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Skill 逻辑文件服务实现。
*/
@Service
public class SkillFileServiceImpl implements SkillFileService {
private static final String DEFAULT_MEDIA_TYPE = "application/octet-stream";
private final SkillService skillService;
private final SkillReferenceService skillReferenceService;
private final SkillScriptService skillScriptService;
private final SkillAssetService skillAssetService;
private final DBSkillContentStore contentStore;
private final ResourceAccessService resourceAccessService;
/**
* 创建 Skill 逻辑文件服务。
*
* @param skillService Skill 服务
* @param skillReferenceService reference 服务
* @param skillScriptService script 服务
* @param skillAssetService asset 服务
* @param contentStore asset 内容存储
* @param resourceAccessService 资源访问服务
*/
public SkillFileServiceImpl(SkillService skillService,
SkillReferenceService skillReferenceService,
SkillScriptService skillScriptService,
SkillAssetService skillAssetService,
DBSkillContentStore contentStore,
ResourceAccessService resourceAccessService) {
this.skillService = skillService;
this.skillReferenceService = skillReferenceService;
this.skillScriptService = skillScriptService;
this.skillAssetService = skillAssetService;
this.contentStore = contentStore;
this.resourceAccessService = resourceAccessService;
}
/**
* {@inheritDoc}
*/
@Override
public List<SkillFileNode> tree(BigInteger skillId) {
Skill skill = requireReadableSkill(skillId);
List<SkillFileNode> roots = new ArrayList<>();
roots.add(fileNode(SkillPaths.SKILL_FILE, "SKILL.md", SkillFileType.SKILL.name()));
roots.add(directoryNode(SkillPaths.REFERENCES_DIR, skill.getReferences().stream()
.map(item -> fileNode(item.getPath(), SkillPaths.fileName(item.getPath()), SkillFileType.REFERENCE.name()))
.toList()));
roots.add(directoryNode(SkillPaths.SCRIPTS_DIR, skill.getScripts().stream()
.map(item -> {
SkillFileNode node = fileNode(item.getPath(), SkillPaths.fileName(item.getPath()), SkillFileType.SCRIPT.name());
node.setLanguage(item.getLanguage());
return node;
})
.toList()));
roots.add(directoryNode(SkillPaths.ASSETS_DIR, skill.getAssets().stream()
.map(item -> {
SkillFileNode node = fileNode(item.getPath(), SkillPaths.fileName(item.getPath()), SkillFileType.ASSET.name());
node.setMediaType(item.getMediaType());
node.setSize(item.getSize());
return node;
})
.toList()));
return roots;
}
/**
* {@inheritDoc}
*/
@Override
public SkillFileContent getContent(BigInteger skillId, String path) {
Skill skill = requireReadableSkill(skillId);
String normalizedPath = SkillPaths.normalize(path);
if (SkillPaths.SKILL_FILE.equals(normalizedPath)) {
SkillFileContent content = new SkillFileContent();
content.setPath(SkillPaths.SKILL_FILE);
content.setType(SkillFileType.SKILL.name());
content.setContent(skill.getSkillContent());
content.setSize((long) bytes(skill.getSkillContent()).length);
return content;
}
String topDir = SkillPaths.firstSegment(normalizedPath);
if (SkillPaths.REFERENCES_DIR.equals(topDir)) {
SkillReference reference = requireReference(skillId, normalizedPath);
SkillFileContent content = new SkillFileContent();
content.setPath(reference.getPath());
content.setType(SkillFileType.REFERENCE.name());
content.setContent(reference.getContent());
content.setSize(reference.getSize());
return content;
}
if (SkillPaths.SCRIPTS_DIR.equals(topDir)) {
SkillScript script = requireScript(skillId, normalizedPath);
SkillFileContent content = new SkillFileContent();
content.setPath(script.getPath());
content.setType(SkillFileType.SCRIPT.name());
content.setContent(script.getContent());
content.setLanguage(script.getLanguage());
content.setSize(script.getSize());
return content;
}
if (SkillPaths.ASSETS_DIR.equals(topDir)) {
SkillAsset asset = requireAsset(skillId, normalizedPath);
SkillFileContent content = new SkillFileContent();
content.setPath(asset.getPath());
content.setType(SkillFileType.ASSET.name());
content.setMediaType(asset.getMediaType());
content.setSize(asset.getSize());
content.setDownloadUrl("/api/v1/skill/file/asset");
return content;
}
throw new BusinessException("不支持的 Skill 文件路径");
}
/**
* {@inheritDoc}
*/
@Override
@Transactional(rollbackFor = Exception.class)
public SkillFileContent saveContent(SkillFileSaveRequest request) {
if (request == null || request.getSkillId() == null) {
throw new BusinessException("Skill ID 不能为空");
}
Skill skill = requireManageSkill(request.getSkillId());
String normalizedPath = SkillPaths.normalize(request.getPath());
String content = request.getContent() == null ? "" : request.getContent();
if (SkillPaths.SKILL_FILE.equals(normalizedPath)) {
skill.setSkillContent(content);
skillService.updateDraft(skill);
return getContent(skill.getId(), SkillPaths.SKILL_FILE);
}
String topDir = SkillPaths.firstSegment(normalizedPath);
if (SkillPaths.REFERENCES_DIR.equals(topDir)) {
saveReference(skill, normalizedPath, content);
refreshSkillCounts(skill.getId());
return getContent(skill.getId(), normalizedPath);
}
if (SkillPaths.SCRIPTS_DIR.equals(topDir)) {
saveScript(skill, normalizedPath, content);
refreshSkillCounts(skill.getId());
return getContent(skill.getId(), normalizedPath);
}
throw new BusinessException("仅支持保存 SKILL.md、references/*.md 和 scripts/*.py|*.js|*.sh");
}
/**
* {@inheritDoc}
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void deleteFile(BigInteger skillId, String path) {
requireManageSkill(skillId);
String normalizedPath = SkillPaths.normalize(path);
if (SkillPaths.SKILL_FILE.equals(normalizedPath)) {
throw new BusinessException("SKILL.md 不允许删除");
}
String topDir = SkillPaths.firstSegment(normalizedPath);
if (SkillPaths.REFERENCES_DIR.equals(topDir)) {
skillReferenceService.remove(QueryWrapper.create().eq(SkillReference::getSkillId, skillId).eq(SkillReference::getPath, normalizedPath));
} else if (SkillPaths.SCRIPTS_DIR.equals(topDir)) {
skillScriptService.remove(QueryWrapper.create().eq(SkillScript::getSkillId, skillId).eq(SkillScript::getPath, normalizedPath));
} else if (SkillPaths.ASSETS_DIR.equals(topDir)) {
skillAssetService.remove(QueryWrapper.create().eq(SkillAsset::getSkillId, skillId).eq(SkillAsset::getPath, normalizedPath));
} else {
throw new BusinessException("不支持的 Skill 文件路径");
}
refreshSkillCounts(skillId);
}
/**
* {@inheritDoc}
*/
@Override
@Transactional(rollbackFor = Exception.class)
public SkillFileContent uploadAsset(BigInteger skillId, String path, MultipartFile file) {
Skill skill = requireManageSkill(skillId);
if (file == null || file.isEmpty()) {
throw new BusinessException("asset 文件不能为空");
}
String normalizedPath = normalizeAssetPath(path, file.getOriginalFilename());
try {
String contentRef = contentStore.put(file.getBytes());
String hash = contentRef.substring("sha256:".length());
SkillAsset asset = findAsset(skillId, normalizedPath);
if (asset == null) {
asset = new SkillAsset();
asset.setTenantId(skill.getTenantId());
asset.setSkillId(skillId);
asset.setPath(normalizedPath);
}
asset.setName(SkillPaths.fileName(normalizedPath));
asset.setMediaType(detectMediaType(normalizedPath));
asset.setContentRef(contentRef);
asset.setContentHash(hash);
asset.setSize(file.getSize());
if (asset.getId() == null) {
skillAssetService.save(asset);
} else {
skillAssetService.updateById(asset);
}
refreshSkillCounts(skillId);
return getContent(skillId, normalizedPath);
} catch (IOException e) {
throw new BusinessException("读取 asset 文件失败");
}
}
/**
* {@inheritDoc}
*/
@Override
public InputStream openAsset(BigInteger skillId, String path) {
requireReadableSkill(skillId);
SkillAsset asset = requireAsset(skillId, SkillPaths.normalize(path));
return contentStore.open(asset.getContentRef());
}
private Skill requireReadableSkill(BigInteger skillId) {
Skill skill = requireSkill(skillId);
resourceAccessService.assertAccess(CategoryResourceType.SKILL, skill, ResourceAction.READ, "无权限查看该 Skill");
return skillService.getDetail(skillId);
}
private Skill requireManageSkill(BigInteger skillId) {
Skill skill = skillService.getDetail(skillId);
resourceAccessService.assertAccess(CategoryResourceType.SKILL, skill, ResourceAction.MANAGE, "无权限管理该 Skill");
return skill;
}
private Skill requireSkill(BigInteger skillId) {
if (skillId == null) {
throw new BusinessException("Skill ID 不能为空");
}
Skill skill = skillService.getById(skillId);
if (skill == null) {
throw new BusinessException("Skill 不存在");
}
return skill;
}
private void saveReference(Skill skill, String path, String content) {
if (!SkillPaths.hasExtension(path, ".md")) {
throw new BusinessException("reference 仅支持 .md 文件");
}
SkillReference reference = findReference(skill.getId(), path);
if (reference == null) {
reference = new SkillReference();
reference.setTenantId(skill.getTenantId());
reference.setSkillId(skill.getId());
reference.setPath(path);
}
byte[] bytes = bytes(content);
reference.setName(SkillPaths.fileName(path));
reference.setContent(content);
reference.setContentHash(SkillHashes.sha256Hex(bytes));
reference.setSize((long) bytes.length);
if (reference.getId() == null) {
skillReferenceService.save(reference);
} else {
skillReferenceService.updateById(reference);
}
}
private void saveScript(Skill skill, String path, String content) {
SkillScriptLanguage language = SkillScriptLanguage.fromPath(path);
if (language == SkillScriptLanguage.UNKNOWN) {
throw new BusinessException("script 仅支持 .py、.js、.sh 文件");
}
SkillScript script = findScript(skill.getId(), path);
if (script == null) {
script = new SkillScript();
script.setTenantId(skill.getTenantId());
script.setSkillId(skill.getId());
script.setPath(path);
}
byte[] bytes = bytes(content);
script.setLanguage(language.name());
script.setContent(content);
script.setContentHash(SkillHashes.sha256Hex(bytes));
script.setSize((long) bytes.length);
if (script.getId() == null) {
skillScriptService.save(script);
} else {
skillScriptService.updateById(script);
}
}
private void refreshSkillCounts(BigInteger skillId) {
Skill skill = skillService.getDetail(skillId);
Skill update = new Skill();
update.setId(skillId);
update.setReferenceCount(skill.getReferences() == null ? 0 : skill.getReferences().size());
update.setScriptCount(skill.getScripts() == null ? 0 : skill.getScripts().size());
update.setAssetCount(skill.getAssets() == null ? 0 : skill.getAssets().size());
skillService.updateById(update);
}
private SkillReference findReference(BigInteger skillId, String path) {
List<SkillReference> records = skillReferenceService.list(QueryWrapper.create()
.eq(SkillReference::getSkillId, skillId)
.eq(SkillReference::getPath, path));
return records.isEmpty() ? null : records.get(0);
}
private SkillReference requireReference(BigInteger skillId, String path) {
SkillReference reference = findReference(skillId, path);
if (reference == null) {
throw new BusinessException("reference 文件不存在");
}
return reference;
}
private SkillScript findScript(BigInteger skillId, String path) {
List<SkillScript> records = skillScriptService.list(QueryWrapper.create()
.eq(SkillScript::getSkillId, skillId)
.eq(SkillScript::getPath, path));
return records.isEmpty() ? null : records.get(0);
}
private SkillScript requireScript(BigInteger skillId, String path) {
SkillScript script = findScript(skillId, path);
if (script == null) {
throw new BusinessException("script 文件不存在");
}
return script;
}
private SkillAsset findAsset(BigInteger skillId, String path) {
List<SkillAsset> records = skillAssetService.list(QueryWrapper.create()
.eq(SkillAsset::getSkillId, skillId)
.eq(SkillAsset::getPath, path));
return records.isEmpty() ? null : records.get(0);
}
private SkillAsset requireAsset(BigInteger skillId, String path) {
SkillAsset asset = findAsset(skillId, path);
if (asset == null) {
throw new BusinessException("asset 文件不存在");
}
return asset;
}
private String normalizeAssetPath(String path, String originalFilename) {
String effectivePath = path;
if (effectivePath == null || effectivePath.isBlank()) {
effectivePath = SkillPaths.ASSETS_DIR + "/" + (originalFilename == null ? "asset.bin" : originalFilename);
}
String normalized = SkillPaths.normalize(effectivePath);
if (!SkillPaths.ASSETS_DIR.equals(SkillPaths.firstSegment(normalized))) {
throw new BusinessException("asset 必须位于 assets/ 目录下");
}
return normalized;
}
private SkillFileNode directoryNode(String name, List<SkillFileNode> flatChildren) {
SkillFileNode node = new SkillFileNode();
node.setKey(name);
node.setPath(name);
node.setName(name + "/");
node.setType("DIRECTORY");
node.setChildren(toNestedChildren(name, flatChildren));
return node;
}
private List<SkillFileNode> toNestedChildren(String root, List<SkillFileNode> flatChildren) {
Map<String, SkillFileNode> nodes = new LinkedHashMap<>();
for (SkillFileNode child : flatChildren) {
String[] segments = child.getPath().substring(root.length() + 1).split("/");
String currentPath = root;
for (int i = 0; i < segments.length; i++) {
currentPath = currentPath + "/" + segments[i];
boolean leaf = i == segments.length - 1;
if (leaf) {
nodes.put(currentPath, child);
} else {
String segmentName = segments[i];
nodes.computeIfAbsent(currentPath, key -> {
SkillFileNode directory = new SkillFileNode();
directory.setKey(key);
directory.setPath(key);
directory.setName(segmentName + "/");
directory.setType("DIRECTORY");
return directory;
});
}
}
}
List<SkillFileNode> roots = new ArrayList<>();
for (SkillFileNode node : nodes.values()) {
String parentPath = parentPath(node.getPath());
if (root.equals(parentPath)) {
roots.add(node);
} else {
SkillFileNode parent = nodes.get(parentPath);
if (parent != null) {
parent.getChildren().add(node);
}
}
}
return roots;
}
private SkillFileNode fileNode(String path, String name, String type) {
SkillFileNode node = new SkillFileNode();
node.setKey(path);
node.setPath(path);
node.setName(name);
node.setType(type);
return node;
}
private String parentPath(String path) {
int index = path.lastIndexOf('/');
return index < 0 ? "" : path.substring(0, index);
}
private byte[] bytes(String content) {
return (content == null ? "" : content).getBytes(StandardCharsets.UTF_8);
}
private String detectMediaType(String path) {
String mediaType = URLConnection.guessContentTypeFromName(path);
return mediaType == null ? DEFAULT_MEDIA_TYPE : mediaType;
}
}

View File

@@ -0,0 +1,28 @@
package tech.easyflow.skill.file;
/**
* Skill 逻辑文件类型。
*/
public enum SkillFileType {
/**
* SKILL.md 主文件。
*/
SKILL,
/**
* references/ 下的 Markdown 文档。
*/
REFERENCE,
/**
* scripts/ 下的脚本文件。
*/
SCRIPT,
/**
* assets/ 下的静态资产。
*/
ASSET
}

View File

@@ -0,0 +1,20 @@
package tech.easyflow.skill.imports;
import java.io.OutputStream;
import java.math.BigInteger;
import java.util.Collection;
/**
* Skill zip 导出服务。
*/
public interface SkillExportService {
/**
* 导出一个或多个 Skill 为标准 zip 包。
*
* @param skillIds Skill ID 集合
* @param outputStream zip 输出流
*/
void exportZip(Collection<BigInteger> skillIds, OutputStream outputStream);
}

View File

@@ -0,0 +1,106 @@
package tech.easyflow.skill.imports;
import com.easyagents.skill.util.SkillPaths;
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 java.io.IOException;
import java.io.OutputStream;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.Set;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
/**
* Skill zip 导出服务实现。
*/
@Service
public class SkillExportServiceImpl implements SkillExportService {
private final SkillService skillService;
private final DBSkillContentStore contentStore;
/**
* 创建 Skill 导出服务。
*
* @param skillService Skill 服务
* @param contentStore Skill asset 内容存储
*/
public SkillExportServiceImpl(SkillService skillService, DBSkillContentStore contentStore) {
this.skillService = skillService;
this.contentStore = contentStore;
}
/**
* {@inheritDoc}
*/
@Override
public void exportZip(Collection<BigInteger> skillIds, OutputStream outputStream) {
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);
}
} catch (IOException e) {
throw new BusinessException("导出 Skill 失败");
}
}
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());
}
}
if (skill.getScripts() != null) {
for (SkillScript script : skill.getScripts()) {
writeText(zipOutputStream, folder + "/" + script.getPath(), script.getContent());
}
}
if (skill.getAssets() != null) {
for (SkillAsset asset : skill.getAssets()) {
zipOutputStream.putNextEntry(new ZipEntry(folder + "/" + asset.getPath()));
zipOutputStream.write(contentStore.readAllBytes(asset.getContentRef()));
zipOutputStream.closeEntry();
}
}
}
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++;
}
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;
}
}

View File

@@ -0,0 +1,31 @@
package tech.easyflow.skill.imports;
import java.util.ArrayList;
import java.util.List;
/**
* Skill 导入预览结果。
*/
public class SkillImportPreview {
private List<SkillImportPreviewItem> skills = new ArrayList<>();
/**
* 获取导入 Skill 预览项。
*
* @return 预览项列表
*/
public List<SkillImportPreviewItem> getSkills() {
return skills;
}
/**
* 设置导入 Skill 预览项。
*
* @param skills 预览项列表
*/
public void setSkills(List<SkillImportPreviewItem> skills) {
this.skills = skills == null ? new ArrayList<>() : skills;
}
}

View File

@@ -0,0 +1,47 @@
package tech.easyflow.skill.imports;
/**
* Skill 导入预览项。
*/
public class SkillImportPreviewItem {
private String packageId;
private String name;
private String description;
private int referenceCount;
private int scriptCount;
private int assetCount;
private boolean conflict;
/**
* 获取包内 Skill ID。
*
* @return 包内 Skill ID
*/
public String getPackageId() {
return packageId;
}
/**
* 设置包内 Skill ID。
*
* @param packageId 包内 Skill ID
*/
public void setPackageId(String packageId) {
this.packageId = packageId;
}
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 int getReferenceCount() { return referenceCount; }
public void setReferenceCount(int referenceCount) { this.referenceCount = referenceCount; }
public int getScriptCount() { return scriptCount; }
public void setScriptCount(int scriptCount) { this.scriptCount = scriptCount; }
public int getAssetCount() { return assetCount; }
public void setAssetCount(int assetCount) { this.assetCount = assetCount; }
public boolean isConflict() { return conflict; }
public void setConflict(boolean conflict) { this.conflict = conflict; }
}

View File

@@ -0,0 +1,32 @@
package tech.easyflow.skill.imports;
import tech.easyflow.skill.entity.Skill;
import java.io.InputStream;
import java.math.BigInteger;
import java.util.List;
/**
* Skill zip 导入服务。
*/
public interface SkillImportService {
/**
* 预览 zip 中的 Skill 包。
*
* @param inputStream zip 输入流
* @return 导入预览
*/
SkillImportPreview preview(InputStream inputStream);
/**
* 确认导入 zip 中的 Skill 包。
*
* @param inputStream zip 输入流
* @param categoryId 目标分类 ID可为空
* @param overwriteDraft 是否覆盖同名草稿
* @return 已保存 Skill 列表
*/
List<Skill> importZip(InputStream inputStream, BigInteger categoryId, boolean overwriteDraft);
}

View File

@@ -0,0 +1,102 @@
package tech.easyflow.skill.imports;
import com.easyagents.skill.codec.ZipSkillPackageCodec;
import com.easyagents.skill.util.SkillHashes;
import com.mybatisflex.core.query.QueryWrapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.skill.entity.Skill;
import tech.easyflow.skill.service.SkillService;
import tech.easyflow.skill.store.DBSkillContentStore;
import tech.easyflow.skill.support.SkillModelConverter;
import java.io.InputStream;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
/**
* Skill zip 导入服务实现。
*/
@Service
public class SkillImportServiceImpl implements SkillImportService {
private final SkillService skillService;
private final DBSkillContentStore contentStore;
/**
* 创建 Skill 导入服务。
*
* @param skillService Skill 服务
* @param contentStore Skill asset 内容存储
*/
public SkillImportServiceImpl(SkillService skillService, DBSkillContentStore contentStore) {
this.skillService = skillService;
this.contentStore = contentStore;
}
/**
* {@inheritDoc}
*/
@Override
public SkillImportPreview preview(InputStream inputStream) {
List<com.easyagents.skill.model.Skill> importedSkills = new ZipSkillPackageCodec().importZip(inputStream);
SkillImportPreview preview = new SkillImportPreview();
preview.setSkills(importedSkills.stream().map(this::toPreviewItem).toList());
return preview;
}
/**
* {@inheritDoc}
*/
@Override
@Transactional(rollbackFor = Exception.class)
public List<Skill> importZip(InputStream inputStream, BigInteger categoryId, boolean overwriteDraft) {
List<com.easyagents.skill.model.Skill> importedSkills = new ZipSkillPackageCodec(contentStore).importZip(inputStream);
List<Skill> savedSkills = new ArrayList<>();
for (com.easyagents.skill.model.Skill imported : importedSkills) {
Skill existing = findByName(imported.getName());
if (existing != null) {
if (!overwriteDraft || !PublishStatus.DRAFT.getCode().equals(existing.getPublishStatus())) {
throw new BusinessException("Skill 已存在,且不允许覆盖:" + imported.getName());
}
Skill replacement = SkillModelConverter.fromAgentSkill(imported);
replacement.setId(existing.getId());
replacement.setCategoryId(categoryId);
replacement.setSourceType("ZIP");
replacement.setPackageHash(SkillHashes.sha256Hex(imported.getSkillContent().getBytes(StandardCharsets.UTF_8)));
savedSkills.add(skillService.updateDraft(replacement));
} else {
Skill skill = SkillModelConverter.fromAgentSkill(imported);
skill.setCategoryId(categoryId);
skill.setSourceType("ZIP");
skill.setPackageHash(SkillHashes.sha256Hex(imported.getSkillContent().getBytes(StandardCharsets.UTF_8)));
savedSkills.add(skillService.saveDraft(skill));
}
}
return savedSkills;
}
private SkillImportPreviewItem toPreviewItem(com.easyagents.skill.model.Skill skill) {
SkillImportPreviewItem item = new SkillImportPreviewItem();
item.setPackageId(skill.getId());
item.setName(skill.getName());
item.setDescription(skill.getDescription());
item.setReferenceCount(skill.getReferences().size());
item.setScriptCount(skill.getScripts().size());
item.setAssetCount(skill.getAssets().size());
item.setConflict(findByName(skill.getName()) != null);
return item;
}
private Skill findByName(String name) {
if (name == null || name.isBlank()) {
return null;
}
List<Skill> skills = skillService.list(QueryWrapper.create().eq(Skill::getName, name));
return skills.isEmpty() ? null : skills.get(0);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,152 @@
package tech.easyflow.skill.publish;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Component;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.ai.publish.AbstractAiResourceLifecycleHandler;
import tech.easyflow.approval.enums.ApprovalResourceType;
import tech.easyflow.approval.service.ApprovalInstanceService;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.skill.entity.Skill;
import tech.easyflow.skill.service.SkillService;
import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.enums.ResourceAction;
import tech.easyflow.system.service.ResourceAccessService;
import java.math.BigInteger;
import java.util.Date;
import java.util.Map;
/**
* Skill 审批资源处理器。
*/
@Component
public class SkillApprovalSubjectHandler extends AbstractAiResourceLifecycleHandler<Skill> {
private final SkillService skillService;
private final ResourceAccessService resourceAccessService;
/**
* 创建 Skill 审批资源处理器。
*
* @param approvalInstanceService 审批实例服务
* @param objectMapper JSON 映射器
* @param skillService Skill 服务
* @param resourceAccessService 资源访问服务
*/
public SkillApprovalSubjectHandler(ApprovalInstanceService approvalInstanceService,
ObjectMapper objectMapper,
SkillService skillService,
ResourceAccessService resourceAccessService) {
super(approvalInstanceService, objectMapper);
this.skillService = skillService;
this.resourceAccessService = resourceAccessService;
}
/**
* {@inheritDoc}
*/
@Override
public String resourceType() {
return ApprovalResourceType.SKILL.getCode();
}
/**
* {@inheritDoc}
*/
@Override
public void assertPublishedAccess(Object identifier, String denyMessage) {
Skill skill = skillService.getById(String.valueOf(identifier));
if (skill == null || !PublishStatus.from(skill.getPublishStatus()).isExternallyVisible()
|| skill.getPublishedSnapshotJson() == null || skill.getPublishedSnapshotJson().isEmpty()) {
throw new BusinessException(denyMessage);
}
}
@Override
protected Skill requireResource(BigInteger resourceId) {
Skill skill = skillService.getById(resourceId);
if (skill == null) {
throw new BusinessException("Skill 不存在");
}
return skill;
}
@Override
protected void assertManagePermission(Skill resource) {
resourceAccessService.assertAccess(CategoryResourceType.SKILL, resource, ResourceAction.MANAGE, "无权限管理该 Skill");
}
@Override
protected BigInteger getCategoryId(Skill resource) {
return resource.getCategoryId();
}
@Override
protected BigInteger getDeptId(Skill resource) {
return resource.getDeptId();
}
@Override
protected String getTitle(Skill resource) {
return resource.getDisplayName() == null || resource.getDisplayName().isBlank()
? resource.getName()
: resource.getDisplayName();
}
@Override
protected PublishStatus getCurrentStatus(Skill resource) {
return PublishStatus.from(resource.getPublishStatus());
}
@Override
protected Map<String, Object> getPublishedSnapshot(Skill resource) {
return resource.getPublishedSnapshotJson();
}
@Override
protected Map<String, Object> buildResourceSnapshot(Skill resource) {
return skillService.buildPublishSnapshot(resource);
}
@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);
}
@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);
}
@Override
protected void markResourceOffline(BigInteger resourceId) {
Skill skill = new Skill();
skill.setId(resourceId);
skill.setPublishStatus(PublishStatus.OFFLINE.getCode());
skill.setCurrentApprovalInstanceId(null);
skillService.updateById(skill);
}
@Override
protected void removeResource(BigInteger resourceId) {
skillService.removeAggregate(resourceId);
}
@Override
protected String resourceLabel() {
return "Skill";
}
}

View File

@@ -0,0 +1,72 @@
package tech.easyflow.skill.publish;
import org.springframework.stereotype.Service;
import tech.easyflow.ai.publish.AiResourceLifecycleService;
import tech.easyflow.approval.entity.vo.ApprovalActionResult;
import tech.easyflow.approval.enums.ApprovalActionType;
import tech.easyflow.approval.enums.ApprovalResourceType;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.math.BigInteger;
/**
* Skill 发布生命周期应用服务。
*/
@Service
public class SkillPublishAppService {
private final AiResourceLifecycleService aiResourceLifecycleService;
/**
* 创建 Skill 发布应用服务。
*
* @param aiResourceLifecycleService AI 资源生命周期服务
*/
public SkillPublishAppService(AiResourceLifecycleService aiResourceLifecycleService) {
this.aiResourceLifecycleService = aiResourceLifecycleService;
}
/**
* 提交 Skill 发布审批。
*
* @param id Skill ID
* @return 审批动作结果
*/
public ApprovalActionResult submitPublishApproval(BigInteger id) {
return submit(id, ApprovalActionType.PUBLISH);
}
/**
* 提交 Skill 下线审批。
*
* @param id Skill ID
* @return 审批动作结果
*/
public ApprovalActionResult submitOfflineApproval(BigInteger id) {
return submit(id, ApprovalActionType.OFFLINE);
}
/**
* 提交 Skill 删除审批。
*
* @param id Skill ID
* @return 审批动作结果
*/
public ApprovalActionResult submitDeleteApproval(BigInteger id) {
return submit(id, ApprovalActionType.DELETE);
}
private ApprovalActionResult submit(BigInteger id, ApprovalActionType actionType) {
if (id == null) {
throw new BusinessException("Skill 审批时资源ID不能为空");
}
return aiResourceLifecycleService.submitAction(
ApprovalResourceType.SKILL.getCode(),
id,
actionType.getCode(),
SaTokenUtil.getLoginAccount().getId()
);
}
}

View File

@@ -0,0 +1,117 @@
package tech.easyflow.skill.repository;
import com.easyagents.skill.model.SkillDescriptor;
import com.easyagents.skill.repository.SkillRepository;
import com.mybatisflex.core.query.QueryWrapper;
import org.springframework.stereotype.Repository;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.skill.entity.Skill;
import tech.easyflow.skill.service.SkillService;
import tech.easyflow.skill.support.SkillModelConverter;
import java.math.BigInteger;
import java.util.List;
import java.util.Optional;
/**
* 基于数据库的 easy-agents-skill 仓储适配器。
*/
@Repository
public class DBSkillRepository implements SkillRepository {
private final SkillService skillService;
/**
* 创建数据库 Skill 仓储。
*
* @param skillService Skill 服务
*/
public DBSkillRepository(SkillService skillService) {
this.skillService = skillService;
}
/**
* {@inheritDoc}
*/
@Override
public void save(com.easyagents.skill.model.Skill skill) {
Skill entity = SkillModelConverter.fromAgentSkill(skill);
BigInteger parsedId = tryParseId(skill.getId());
if (parsedId != null && skillService.getById(parsedId) != null) {
entity.setId(parsedId);
skillService.updateDraft(entity);
return;
}
skillService.saveDraft(entity);
}
/**
* {@inheritDoc}
*/
@Override
public Optional<com.easyagents.skill.model.Skill> get(String skillId) {
BigInteger id = parseId(skillId);
Skill skill = skillService.getDetail(id);
return Optional.of(SkillModelConverter.toAgentSkill(skill));
}
/**
* {@inheritDoc}
*/
@Override
public Optional<SkillDescriptor> getDescriptor(String skillId) {
BigInteger id = parseId(skillId);
Skill skill = skillService.getById(id);
if (skill == null) {
return Optional.empty();
}
return Optional.of(new SkillDescriptor(String.valueOf(skill.getId()), skill.getName(), skill.getDescription(),
new com.easyagents.skill.model.SkillMetadata(skill.getMetadataJson())));
}
/**
* {@inheritDoc}
*/
@Override
public List<SkillDescriptor> listDescriptors() {
return skillService.list().stream()
.map(skill -> new SkillDescriptor(String.valueOf(skill.getId()), skill.getName(), skill.getDescription(),
new com.easyagents.skill.model.SkillMetadata(skill.getMetadataJson())))
.toList();
}
/**
* {@inheritDoc}
*/
@Override
public void delete(String skillId) {
skillService.removeAggregate(parseId(skillId));
}
/**
* {@inheritDoc}
*/
@Override
public boolean exists(String skillId) {
BigInteger id = parseId(skillId);
return skillService.count(QueryWrapper.create().eq(Skill::getId, id)) > 0;
}
private BigInteger parseId(String skillId) {
if (skillId == null || skillId.isBlank()) {
throw new BusinessException("Skill ID 不能为空");
}
return new BigInteger(skillId);
}
private BigInteger tryParseId(String skillId) {
if (skillId == null || skillId.isBlank()) {
return null;
}
try {
return new BigInteger(skillId);
} catch (NumberFormatException ignored) {
return null;
}
}
}

View File

@@ -0,0 +1,25 @@
package tech.easyflow.skill.service;
import tech.easyflow.skill.entity.Skill;
import java.util.Collection;
/**
* Skill 审批展示态派生服务。
*/
public interface SkillApprovalStateService {
/**
* 填充单个 Skill 审批展示态。
*
* @param skill Skill
*/
void fillSkillApprovalState(Skill skill);
/**
* 批量填充 Skill 审批展示态。
*
* @param skills Skill 集合
*/
void fillSkillApprovalState(Collection<Skill> skills);
}

View File

@@ -0,0 +1,10 @@
package tech.easyflow.skill.service;
import com.mybatisflex.core.service.IService;
import tech.easyflow.skill.entity.SkillAssetContent;
/**
* Skill asset 内容索引服务。
*/
public interface SkillAssetContentService extends IService<SkillAssetContent> {
}

View File

@@ -0,0 +1,10 @@
package tech.easyflow.skill.service;
import com.mybatisflex.core.service.IService;
import tech.easyflow.skill.entity.SkillAsset;
/**
* Skill asset 服务。
*/
public interface SkillAssetService extends IService<SkillAsset> {
}

View File

@@ -0,0 +1,19 @@
package tech.easyflow.skill.service;
import com.mybatisflex.core.service.IService;
import tech.easyflow.skill.entity.SkillCategory;
import java.math.BigInteger;
/**
* Skill 分类服务。
*/
public interface SkillCategoryService extends IService<SkillCategory> {
/**
* 校验目标分类可作为 Skill 分类。
*
* @param categoryId 分类 ID可为空
*/
void validateUsableCategory(BigInteger categoryId);
}

View File

@@ -0,0 +1,10 @@
package tech.easyflow.skill.service;
import com.mybatisflex.core.service.IService;
import tech.easyflow.skill.entity.SkillReference;
/**
* Skill reference 服务。
*/
public interface SkillReferenceService extends IService<SkillReference> {
}

View File

@@ -0,0 +1,10 @@
package tech.easyflow.skill.service;
import com.mybatisflex.core.service.IService;
import tech.easyflow.skill.entity.SkillScript;
/**
* Skill script 服务。
*/
public interface SkillScriptService extends IService<SkillScript> {
}

View File

@@ -0,0 +1,60 @@
package tech.easyflow.skill.service;
import com.mybatisflex.core.service.IService;
import tech.easyflow.skill.entity.Skill;
import java.math.BigInteger;
import java.util.Map;
/**
* Skill 业务服务。
*/
public interface SkillService extends IService<Skill> {
/**
* 获取 Skill 详情。
*
* @param id Skill ID
* @return Skill 详情
*/
Skill getDetail(BigInteger id);
/**
* 保存 Skill 草稿。
*
* @param skill Skill 草稿
* @return 保存后的 Skill
*/
Skill saveDraft(Skill skill);
/**
* 更新 Skill 草稿。
*
* @param skill Skill 草稿
* @return 更新后的 Skill
*/
Skill updateDraft(Skill skill);
/**
* 构建发布快照。
*
* @param skill Skill 草稿
* @return 发布快照
*/
Map<String, Object> buildPublishSnapshot(Skill skill);
/**
* 从发布快照还原 Skill。
*
* @param snapshot 发布快照
* @return Skill
*/
Skill fromSnapshot(Map<String, Object> snapshot);
/**
* 删除 Skill 聚合。
*
* @param id Skill ID
*/
void removeAggregate(BigInteger id);
}

View File

@@ -0,0 +1,115 @@
package tech.easyflow.skill.service.impl;
import com.mybatisflex.core.query.QueryWrapper;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.approval.entity.ApprovalInstance;
import tech.easyflow.approval.enums.ApprovalActionType;
import tech.easyflow.approval.enums.ApprovalInstanceStatus;
import tech.easyflow.approval.enums.ApprovalResourceType;
import tech.easyflow.approval.mapper.ApprovalInstanceMapper;
import tech.easyflow.skill.entity.Skill;
import tech.easyflow.skill.service.SkillApprovalStateService;
import java.math.BigInteger;
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* Skill 审批展示态派生服务实现。
*/
@Service
public class SkillApprovalStateServiceImpl implements SkillApprovalStateService {
private final ApprovalInstanceMapper approvalInstanceMapper;
/**
* 创建 Skill 审批展示态派生服务。
*
* @param approvalInstanceMapper 审批实例 Mapper
*/
public SkillApprovalStateServiceImpl(ApprovalInstanceMapper approvalInstanceMapper) {
this.approvalInstanceMapper = approvalInstanceMapper;
}
/**
* {@inheritDoc}
*/
@Override
public void fillSkillApprovalState(Skill skill) {
fillSkillApprovalState(skill == null ? List.of() : List.of(skill));
}
/**
* {@inheritDoc}
*/
@Override
public void fillSkillApprovalState(Collection<Skill> skills) {
if (CollectionUtils.isEmpty(skills)) {
return;
}
List<Skill> validSkills = skills.stream().filter(Objects::nonNull).toList();
if (validSkills.isEmpty()) {
return;
}
Map<BigInteger, ApprovalInstance> instanceMap = loadInstanceMap(validSkills);
for (Skill skill : validSkills) {
fillOne(skill, instanceMap.get(skill.getCurrentApprovalInstanceId()));
}
}
private void fillOne(Skill skill, ApprovalInstance instance) {
PublishStatus currentStatus = PublishStatus.from(skill.getPublishStatus());
if (!isValidCurrentInstance(instance)) {
skill.setApprovalPending(false);
skill.setCurrentApprovalActionType(null);
skill.setDisplayPublishStatus(currentStatus.getCode());
return;
}
ApprovalInstanceStatus instanceStatus = ApprovalInstanceStatus.from(instance.getStatus());
if (instanceStatus.isFinished()) {
skill.setApprovalPending(false);
skill.setCurrentApprovalActionType(null);
skill.setDisplayPublishStatus(currentStatus.getCode());
return;
}
ApprovalActionType actionType = ApprovalActionType.from(instance.getActionType());
skill.setApprovalPending(true);
skill.setCurrentApprovalActionType(actionType.getCode());
skill.setDisplayPublishStatus(resolveDisplayStatusWithActiveInstance(currentStatus, actionType).getCode());
}
private Map<BigInteger, ApprovalInstance> loadInstanceMap(Collection<Skill> skills) {
Set<BigInteger> instanceIds = skills.stream()
.map(Skill::getCurrentApprovalInstanceId)
.filter(Objects::nonNull)
.collect(Collectors.toCollection(LinkedHashSet::new));
if (instanceIds.isEmpty()) {
return Collections.emptyMap();
}
List<ApprovalInstance> instances = approvalInstanceMapper.selectListByQuery(
QueryWrapper.create().in(ApprovalInstance::getId, instanceIds)
);
return instances.stream().collect(Collectors.toMap(ApprovalInstance::getId, Function.identity()));
}
private boolean isValidCurrentInstance(ApprovalInstance instance) {
return instance != null && ApprovalResourceType.SKILL.getCode().equals(instance.getResourceType());
}
private PublishStatus resolveDisplayStatusWithActiveInstance(PublishStatus currentStatus,
ApprovalActionType actionType) {
if (currentStatus == PublishStatus.PUBLISH_PENDING
|| currentStatus == PublishStatus.OFFLINE_PENDING
|| currentStatus == PublishStatus.DELETE_PENDING) {
return currentStatus;
}
return switch (actionType) {
case PUBLISH -> PublishStatus.PUBLISH_PENDING;
case OFFLINE -> PublishStatus.OFFLINE_PENDING;
case DELETE -> PublishStatus.DELETE_PENDING;
};
}
}

View File

@@ -0,0 +1,14 @@
package tech.easyflow.skill.service.impl;
import com.mybatisflex.spring.service.impl.ServiceImpl;
import org.springframework.stereotype.Service;
import tech.easyflow.skill.entity.SkillAssetContent;
import tech.easyflow.skill.mapper.SkillAssetContentMapper;
import tech.easyflow.skill.service.SkillAssetContentService;
/**
* Skill asset 内容索引服务实现。
*/
@Service
public class SkillAssetContentServiceImpl extends ServiceImpl<SkillAssetContentMapper, SkillAssetContent> implements SkillAssetContentService {
}

View File

@@ -0,0 +1,14 @@
package tech.easyflow.skill.service.impl;
import com.mybatisflex.spring.service.impl.ServiceImpl;
import org.springframework.stereotype.Service;
import tech.easyflow.skill.entity.SkillAsset;
import tech.easyflow.skill.mapper.SkillAssetMapper;
import tech.easyflow.skill.service.SkillAssetService;
/**
* Skill asset 服务实现。
*/
@Service
public class SkillAssetServiceImpl extends ServiceImpl<SkillAssetMapper, SkillAsset> implements SkillAssetService {
}

View File

@@ -0,0 +1,120 @@
package tech.easyflow.skill.service.impl;
import com.mybatisflex.core.query.QueryWrapper;
import com.mybatisflex.spring.service.impl.ServiceImpl;
import org.springframework.stereotype.Service;
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.SkillCategory;
import tech.easyflow.skill.mapper.SkillCategoryMapper;
import tech.easyflow.skill.service.SkillCategoryService;
import java.math.BigInteger;
import java.util.Date;
/**
* Skill 分类服务实现。
*/
@Service
public class SkillCategoryServiceImpl extends ServiceImpl<SkillCategoryMapper, SkillCategory> implements SkillCategoryService {
private static final int MAX_LEVEL = 3;
/**
* {@inheritDoc}
*/
@Override
public void validateUsableCategory(BigInteger categoryId) {
if (categoryId == null) {
return;
}
SkillCategory category = getById(categoryId);
if (category == null) {
throw new BusinessException("Skill 分类不存在");
}
if (category.getLevelNo() != null && category.getLevelNo() > MAX_LEVEL) {
throw new BusinessException("Skill 分类最多支持三级");
}
if (category.getStatus() != null && category.getStatus() != 1) {
throw new BusinessException("Skill 分类不可用");
}
}
/**
* {@inheritDoc}
*/
@Override
public boolean save(SkillCategory entity) {
applyCategoryFields(entity);
return super.save(entity);
}
/**
* {@inheritDoc}
*/
@Override
public boolean updateById(SkillCategory entity) {
applyCategoryFields(entity);
return super.updateById(entity);
}
/**
* 判断分类下是否存在子分类。
*
* @param categoryId 分类 ID
* @return 存在子分类时返回 true
*/
public boolean hasChildren(BigInteger categoryId) {
if (categoryId == null) {
return false;
}
return count(QueryWrapper.create().eq(SkillCategory::getParentId, categoryId)) > 0;
}
private void applyCategoryFields(SkillCategory category) {
if (category == null) {
throw new BusinessException("Skill 分类不能为空");
}
if (category.getCategoryName() == null || category.getCategoryName().isBlank()) {
throw new BusinessException("Skill 分类名称不能为空");
}
SkillCategory parent = null;
if (category.getParentId() != null) {
parent = getById(category.getParentId());
if (parent == null) {
throw new BusinessException("父级 Skill 分类不存在");
}
if (category.getId() != null && category.getId().equals(category.getParentId())) {
throw new BusinessException("父级分类不能是自身");
}
}
int level = parent == null ? 1 : (parent.getLevelNo() == null ? 1 : parent.getLevelNo()) + 1;
if (level > MAX_LEVEL) {
throw new BusinessException("Skill 分类最多支持三级");
}
LoginAccount account = SaTokenUtil.getLoginAccount();
Date now = new Date();
category.setLevelNo(level);
category.setAncestors(parent == null ? "" : appendAncestor(parent));
category.setStatus(category.getStatus() == null ? 1 : category.getStatus());
category.setSortNo(category.getSortNo() == null ? 0 : category.getSortNo());
if (category.getId() == null) {
category.setTenantId(account.getTenantId());
category.setCreated(now);
category.setCreatedBy(account.getId());
}
category.setModified(now);
category.setModifiedBy(account.getId());
}
private String appendAncestor(SkillCategory parent) {
String ancestors = parent.getAncestors() == null || parent.getAncestors().isBlank()
? String.valueOf(parent.getId())
: parent.getAncestors() + "," + parent.getId();
if (ancestors.length() > 512) {
throw new BusinessException("Skill 分类层级路径过长");
}
return ancestors;
}
}

View File

@@ -0,0 +1,14 @@
package tech.easyflow.skill.service.impl;
import com.mybatisflex.spring.service.impl.ServiceImpl;
import org.springframework.stereotype.Service;
import tech.easyflow.skill.entity.SkillReference;
import tech.easyflow.skill.mapper.SkillReferenceMapper;
import tech.easyflow.skill.service.SkillReferenceService;
/**
* Skill reference 服务实现。
*/
@Service
public class SkillReferenceServiceImpl extends ServiceImpl<SkillReferenceMapper, SkillReference> implements SkillReferenceService {
}

View File

@@ -0,0 +1,14 @@
package tech.easyflow.skill.service.impl;
import com.mybatisflex.spring.service.impl.ServiceImpl;
import org.springframework.stereotype.Service;
import tech.easyflow.skill.entity.SkillScript;
import tech.easyflow.skill.mapper.SkillScriptMapper;
import tech.easyflow.skill.service.SkillScriptService;
/**
* Skill script 服务实现。
*/
@Service
public class SkillScriptServiceImpl extends ServiceImpl<SkillScriptMapper, SkillScript> implements SkillScriptService {
}

View File

@@ -0,0 +1,327 @@
package tech.easyflow.skill.service.impl;
import com.easyagents.skill.exception.SkillException;
import com.easyagents.skill.factory.SkillFactory;
import com.easyagents.skill.validation.defaults.DefaultSkillValidator;
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.enums.PublishStatus;
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.SkillAsset;
import tech.easyflow.skill.entity.SkillReference;
import tech.easyflow.skill.entity.SkillScript;
import tech.easyflow.skill.mapper.SkillMapper;
import tech.easyflow.skill.service.*;
import tech.easyflow.skill.support.SkillModelConverter;
import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot;
import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.enums.ResourceAction;
import tech.easyflow.system.enums.VisibilityScope;
import tech.easyflow.system.service.CategoryPermissionService;
import tech.easyflow.system.service.ResourceAccessService;
import javax.annotation.Resource;
import java.math.BigInteger;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Skill 业务服务实现。
*/
@Service
public class SkillServiceImpl extends ServiceImpl<SkillMapper, Skill> implements SkillService {
private final DefaultSkillValidator skillValidator = new DefaultSkillValidator();
@Resource
private SkillCategoryService skillCategoryService;
@Resource
private SkillReferenceService skillReferenceService;
@Resource
private SkillScriptService skillScriptService;
@Resource
private SkillAssetService skillAssetService;
@Resource
private ResourceAccessService resourceAccessService;
@Resource
private CategoryPermissionService categoryPermissionService;
@Resource
private ObjectMapper objectMapper;
/**
* {@inheritDoc}
*/
@Override
public Skill getDetail(BigInteger id) {
Skill skill = requireSkill(id);
resourceAccessService.assertAccess(CategoryResourceType.SKILL, skill, ResourceAction.READ, "无权限查看该 Skill");
fillResources(skill);
return skill;
}
/**
* {@inheritDoc}
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Skill saveDraft(Skill skill) {
validateDraft(skill);
applyDraftDefaults(skill);
save(skill);
replaceResources(skill);
return getDetail(skill.getId());
}
/**
* {@inheritDoc}
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Skill updateDraft(Skill skill) {
if (skill == null || skill.getId() == null) {
throw new BusinessException("Skill ID 不能为空");
}
Skill existing = requireSkill(skill.getId());
resourceAccessService.assertAccess(CategoryResourceType.SKILL, existing, ResourceAction.MANAGE, "无权限管理该 Skill");
validateDraft(skill);
applyDraftUpdate(existing, skill);
updateById(existing);
replaceResources(existing);
return getDetail(existing.getId());
}
/**
* {@inheritDoc}
*/
@Override
public Map<String, Object> buildPublishSnapshot(Skill skill) {
Skill detail = getDetail(skill.getId());
com.easyagents.skill.model.Skill agentSkill = SkillModelConverter.toAgentSkill(detail);
Map<String, Object> snapshot = new LinkedHashMap<>();
snapshot.put("id", detail.getId());
snapshot.put("tenantId", detail.getTenantId());
snapshot.put("deptId", detail.getDeptId());
snapshot.put("createdBy", detail.getCreatedBy());
snapshot.put("categoryId", detail.getCategoryId());
snapshot.put("name", detail.getName());
snapshot.put("displayName", detail.getDisplayName());
snapshot.put("description", detail.getDescription());
snapshot.put("metadataJson", detail.getMetadataJson());
snapshot.put("skillContent", detail.getSkillContent());
snapshot.put("enabled", detail.getEnabled());
snapshot.put("visibilityScope", detail.getVisibilityScope());
snapshot.put("sourceType", detail.getSourceType());
snapshot.put("packageHash", detail.getPackageHash());
snapshot.put("references", agentSkill.getReferences());
snapshot.put("scripts", agentSkill.getScripts());
snapshot.put("assets", agentSkill.getAssets());
snapshot.put("snapshotAt", new Date());
return snapshot;
}
/**
* {@inheritDoc}
*/
@Override
public Skill fromSnapshot(Map<String, Object> snapshot) {
if (snapshot == null || snapshot.isEmpty()) {
throw new BusinessException("Skill 发布快照为空");
}
Skill skill = objectMapper.convertValue(snapshot, Skill.class);
skill.setId(toBigInteger(snapshot.get("id")));
skill.setTenantId(toBigInteger(snapshot.get("tenantId")));
skill.setDeptId(toBigInteger(snapshot.get("deptId")));
skill.setCreatedBy(toBigInteger(snapshot.get("createdBy")));
skill.setCategoryId(toBigInteger(snapshot.get("categoryId")));
skill.setPublishStatus(PublishStatus.PUBLISHED.getCode());
skill.setPublishedSnapshotJson(snapshot);
return skill;
}
/**
* {@inheritDoc}
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void removeAggregate(BigInteger id) {
if (id == null) {
return;
}
removeResources(id);
removeById(id);
}
private Skill requireSkill(BigInteger id) {
Skill skill = getById(id);
if (skill == null) {
throw new BusinessException("Skill 不存在");
}
return skill;
}
private void validateDraft(Skill skill) {
if (skill == null) {
throw new BusinessException("Skill 不能为空");
}
normalizeFromSkillContent(skill);
if (skill.getName() == null || skill.getName().isBlank()) {
throw new BusinessException("Skill 名称不能为空");
}
if (skill.getDescription() == null || skill.getDescription().isBlank()) {
throw new BusinessException("Skill 描述不能为空");
}
if (skill.getSkillContent() == null || skill.getSkillContent().isBlank()) {
throw new BusinessException("SKILL.md 内容不能为空");
}
skillCategoryService.validateUsableCategory(skill.getCategoryId());
validateTargetCategoryVisible(skill.getCategoryId());
skill.setVisibilityScope(VisibilityScope.fromOrDefault(skill.getVisibilityScope(), VisibilityScope.PRIVATE).name());
validateSkillPackage(skill);
}
private void validateTargetCategoryVisible(BigInteger categoryId) {
if (categoryId == null) {
return;
}
RoleCategoryAccessSnapshot access = categoryPermissionService.getCurrentAccess(CategoryResourceType.SKILL.getCode());
if (access.isRestricted() && !access.getCategoryIds().contains(categoryId)) {
throw new BusinessException("无权限使用该 Skill 分类");
}
}
private void applyDraftDefaults(Skill skill) {
LoginAccount account = requireCurrentLoginAccount();
Date now = new Date();
skill.setTenantId(account.getTenantId());
skill.setDeptId(account.getDeptId());
skill.setCreated(now);
skill.setCreatedBy(account.getId());
skill.setModified(now);
skill.setModifiedBy(account.getId());
skill.setEnabled(skill.getEnabled() == null || skill.getEnabled());
skill.setSourceType(skill.getSourceType() == null ? "MANUAL" : skill.getSourceType());
skill.setPublishStatus(PublishStatus.DRAFT.getCode());
syncCounts(skill);
}
private void applyDraftUpdate(Skill existing, Skill incoming) {
LoginAccount account = requireCurrentLoginAccount();
existing.setCategoryId(incoming.getCategoryId());
existing.setName(incoming.getName());
existing.setDisplayName(incoming.getDisplayName());
existing.setDescription(incoming.getDescription());
existing.setMetadataJson(incoming.getMetadataJson());
existing.setSkillContent(incoming.getSkillContent());
existing.setEnabled(incoming.getEnabled() == null || incoming.getEnabled());
existing.setVisibilityScope(incoming.getVisibilityScope());
existing.setSourceType(incoming.getSourceType());
existing.setPackageHash(incoming.getPackageHash());
existing.setReferences(incoming.getReferences());
existing.setScripts(incoming.getScripts());
existing.setAssets(incoming.getAssets());
syncCounts(existing);
existing.setModified(new Date());
existing.setModifiedBy(account.getId());
}
private void syncCounts(Skill skill) {
skill.setReferenceCount(skill.getReferences() == null ? 0 : skill.getReferences().size());
skill.setScriptCount(skill.getScripts() == null ? 0 : skill.getScripts().size());
skill.setAssetCount(skill.getAssets() == null ? 0 : skill.getAssets().size());
}
private void fillResources(Skill skill) {
skill.setReferences(skillReferenceService.list(QueryWrapper.create().eq(SkillReference::getSkillId, skill.getId()).orderBy("path asc")));
skill.setScripts(skillScriptService.list(QueryWrapper.create().eq(SkillScript::getSkillId, skill.getId()).orderBy("path asc")));
skill.setAssets(skillAssetService.list(QueryWrapper.create().eq(SkillAsset::getSkillId, skill.getId()).orderBy("path asc")));
}
private void replaceResources(Skill skill) {
removeResources(skill.getId());
BigInteger tenantId = skill.getTenantId();
BigInteger skillId = skill.getId();
if (skill.getReferences() != null) {
for (SkillReference reference : skill.getReferences()) {
reference.setTenantId(tenantId);
reference.setSkillId(skillId);
skillReferenceService.save(reference);
}
}
if (skill.getScripts() != null) {
for (SkillScript script : skill.getScripts()) {
script.setTenantId(tenantId);
script.setSkillId(skillId);
skillScriptService.save(script);
}
}
if (skill.getAssets() != null) {
for (SkillAsset asset : skill.getAssets()) {
asset.setTenantId(tenantId);
asset.setSkillId(skillId);
skillAssetService.save(asset);
}
}
}
private void removeResources(BigInteger skillId) {
skillReferenceService.remove(QueryWrapper.create().eq(SkillReference::getSkillId, skillId));
skillScriptService.remove(QueryWrapper.create().eq(SkillScript::getSkillId, skillId));
skillAssetService.remove(QueryWrapper.create().eq(SkillAsset::getSkillId, skillId));
}
private void normalizeFromSkillContent(Skill skill) {
try {
com.easyagents.skill.model.Skill parsed = SkillFactory.create(
skill.getId() == null ? "draft" : String.valueOf(skill.getId()),
skill.getSkillContent(),
SkillModelConverter.toAgentReferences(skill.getReferences()),
SkillModelConverter.toAgentScripts(skill.getScripts()),
SkillModelConverter.toAgentAssets(skill.getAssets())
);
skill.setName(parsed.getName());
if (skill.getDisplayName() == null || skill.getDisplayName().isBlank()) {
skill.setDisplayName(parsed.getName());
}
skill.setDescription(parsed.getDescription());
skill.setMetadataJson(parsed.getMetadata().getValues());
} catch (SkillException e) {
throw new BusinessException("SKILL.md frontmatter 不合法:" + e.getMessage());
}
}
private void validateSkillPackage(Skill skill) {
try {
skillValidator.validate(SkillModelConverter.toAgentSkill(skill));
} catch (SkillException e) {
throw new BusinessException("Skill 包校验失败:" + e.getMessage());
}
}
private LoginAccount requireCurrentLoginAccount() {
LoginAccount account = SaTokenUtil.getLoginAccount();
if (account == null || account.getId() == null) {
throw new BusinessException("未登录或登录态无效");
}
return account;
}
private BigInteger toBigInteger(Object value) {
if (value == null) {
return null;
}
if (value instanceof BigInteger bigInteger) {
return bigInteger;
}
if (value instanceof Number number) {
return BigInteger.valueOf(number.longValue());
}
return new BigInteger(String.valueOf(value));
}
}

View File

@@ -0,0 +1,138 @@
package tech.easyflow.skill.store;
import com.easyagents.skill.store.SkillContentStore;
import com.easyagents.skill.util.SkillHashes;
import com.mybatisflex.core.query.QueryWrapper;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import tech.easyflow.common.filestorage.FileStorageService;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.skill.entity.SkillAssetContent;
import tech.easyflow.skill.service.SkillAssetContentService;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.util.Date;
/**
* 基于数据库索引和现有文件存储的 Skill 内容存储。
*/
@Component
public class DBSkillContentStore implements SkillContentStore {
private static final String DEFAULT_MEDIA_TYPE = "application/octet-stream";
private final SkillAssetContentService skillAssetContentService;
private final FileStorageService fileStorageService;
/**
* 创建数据库 Skill 内容存储。
*
* @param skillAssetContentService asset 内容索引服务
* @param fileStorageService 文件存储服务
*/
public DBSkillContentStore(SkillAssetContentService skillAssetContentService,
@Qualifier("default") FileStorageService fileStorageService) {
this.skillAssetContentService = skillAssetContentService;
this.fileStorageService = fileStorageService;
}
/**
* {@inheritDoc}
*/
@Override
public String put(byte[] bytes) {
String contentRef = SkillHashes.sha256Ref(bytes);
SkillAssetContent existing = skillAssetContentService.getById(contentRef);
if (existing != null) {
existing.setRefCount((existing.getRefCount() == null ? 0 : existing.getRefCount()) + 1);
existing.setModified(new Date());
skillAssetContentService.updateById(existing);
return contentRef;
}
String hex = contentRef.substring("sha256:".length());
String prePath = "skill-assets/" + hex.substring(0, Math.min(2, hex.length()));
MultipartFile file = new ByteArrayMultipartFile(bytes, contentRef + ".bin", DEFAULT_MEDIA_TYPE);
String filePath = fileStorageService.save(file, prePath);
SkillAssetContent content = new SkillAssetContent();
content.setContentRef(contentRef);
content.setContentHash(hex);
content.setFilePath(filePath);
content.setMediaType(DEFAULT_MEDIA_TYPE);
content.setSize((long) (bytes == null ? 0 : bytes.length));
content.setRefCount(1);
content.setCreated(new Date());
content.setModified(new Date());
skillAssetContentService.save(content);
return contentRef;
}
/**
* {@inheritDoc}
*/
@Override
public InputStream open(String contentRef) {
SkillAssetContent content = requireContent(contentRef);
try {
return fileStorageService.readStream(content.getFilePath());
} catch (IOException e) {
throw new BusinessException("读取 Skill asset 失败");
}
}
/**
* {@inheritDoc}
*/
@Override
public byte[] readAllBytes(String contentRef) {
try (InputStream inputStream = open(contentRef)) {
return inputStream.readAllBytes();
} catch (IOException e) {
throw new BusinessException("读取 Skill asset 失败");
}
}
/**
* {@inheritDoc}
*/
@Override
public boolean exists(String contentRef) {
if (contentRef == null || contentRef.isBlank()) {
return false;
}
return skillAssetContentService.count(QueryWrapper.create().eq(SkillAssetContent::getContentRef, contentRef)) > 0;
}
private SkillAssetContent requireContent(String contentRef) {
SkillAssetContent content = skillAssetContentService.getById(contentRef);
if (content == null) {
throw new BusinessException("Skill asset 内容不存在");
}
return content;
}
private static class ByteArrayMultipartFile implements MultipartFile {
private final byte[] bytes;
private final String filename;
private final String contentType;
private ByteArrayMultipartFile(byte[] bytes, String filename, String contentType) {
this.bytes = bytes == null ? new byte[0] : bytes;
this.filename = filename;
this.contentType = contentType;
}
@Override public String getName() { return "file"; }
@Override public String getOriginalFilename() { return filename; }
@Override public String getContentType() { return contentType; }
@Override public boolean isEmpty() { return bytes.length == 0; }
@Override public long getSize() { return bytes.length; }
@Override public byte[] getBytes() { return bytes; }
@Override public InputStream getInputStream() { return new ByteArrayInputStream(bytes); }
@Override public void transferTo(File dest) throws IOException { org.springframework.util.FileCopyUtils.copy(bytes, dest); }
}
}

View File

@@ -0,0 +1,201 @@
package tech.easyflow.skill.support;
import com.easyagents.skill.factory.SkillFactory;
import com.easyagents.skill.model.SkillMetadata;
import com.easyagents.skill.model.SkillScriptLanguage;
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 java.math.BigInteger;
import java.util.List;
/**
* EasyFlow Skill 实体与 easy-agents-skill 模型转换器。
*/
public final class SkillModelConverter {
private SkillModelConverter() {
}
/**
* 转换为 easy-agents-skill 聚合。
*
* @param skill Skill 主实体
* @return easy-agents-skill 聚合
*/
public static com.easyagents.skill.model.Skill toAgentSkill(Skill skill) {
return SkillFactory.create(
String.valueOf(skill.getId()),
skill.getSkillContent(),
toAgentReferences(skill.getReferences()),
toAgentScripts(skill.getScripts()),
toAgentAssets(skill.getAssets())
);
}
/**
* 转换导入模型到主实体。
*
* @param imported 导入 Skill
* @return Skill 主实体
*/
public static Skill fromAgentSkill(com.easyagents.skill.model.Skill imported) {
Skill skill = new Skill();
skill.setName(imported.getName());
skill.setDisplayName(imported.getName());
skill.setDescription(imported.getDescription());
skill.setMetadataJson(imported.getMetadata().getValues());
skill.setSkillContent(imported.getSkillContent());
skill.setReferences(imported.getReferences().stream()
.map(item -> fromAgentReference(null, null, item))
.toList());
skill.setScripts(imported.getScripts().stream()
.map(item -> fromAgentScript(null, null, item))
.toList());
skill.setAssets(imported.getAssets().stream()
.map(item -> fromAgentAsset(null, null, item))
.toList());
skill.setReferenceCount(imported.getReferences().size());
skill.setScriptCount(imported.getScripts().size());
skill.setAssetCount(imported.getAssets().size());
return skill;
}
/**
* 转换 reference 列表。
*
* @param references reference 实体
* @return easy-agents-skill reference
*/
public static List<com.easyagents.skill.model.SkillReference> toAgentReferences(List<SkillReference> references) {
return references == null ? List.of() : references.stream().map(item -> {
com.easyagents.skill.model.SkillReference target = new com.easyagents.skill.model.SkillReference();
target.setPath(item.getPath());
target.setName(item.getName());
target.setContent(item.getContent());
target.setContentHash(item.getContentHash());
target.setSize(item.getSize() == null ? 0L : item.getSize());
target.setMetadata(new SkillMetadata(item.getMetadataJson()));
return target;
}).toList();
}
/**
* 转换 script 列表。
*
* @param scripts script 实体
* @return easy-agents-skill script
*/
public static List<com.easyagents.skill.model.SkillScript> toAgentScripts(List<SkillScript> scripts) {
return scripts == null ? List.of() : scripts.stream().map(item -> {
com.easyagents.skill.model.SkillScript target = new com.easyagents.skill.model.SkillScript();
target.setPath(item.getPath());
target.setLanguage(parseLanguage(item.getLanguage()));
target.setContent(item.getContent());
target.setContentHash(item.getContentHash());
target.setSize(item.getSize() == null ? 0L : item.getSize());
target.setMetadata(new SkillMetadata(item.getMetadataJson()));
return target;
}).toList();
}
/**
* 转换 asset 列表。
*
* @param assets asset 实体
* @return easy-agents-skill asset
*/
public static List<com.easyagents.skill.model.SkillAsset> toAgentAssets(List<SkillAsset> assets) {
return assets == null ? List.of() : assets.stream().map(item -> {
com.easyagents.skill.model.SkillAsset target = new com.easyagents.skill.model.SkillAsset();
target.setPath(item.getPath());
target.setName(item.getName());
target.setMediaType(item.getMediaType());
target.setContentRef(item.getContentRef());
target.setContentHash(item.getContentHash());
target.setSize(item.getSize() == null ? 0L : item.getSize());
target.setMetadata(new SkillMetadata(item.getMetadataJson()));
return target;
}).toList();
}
/**
* 转换导入 reference。
*
* @param skillId Skill ID
* @param tenantId 租户 ID
* @param source 导入模型
* @return reference 实体
*/
public static SkillReference fromAgentReference(BigInteger skillId, BigInteger tenantId,
com.easyagents.skill.model.SkillReference source) {
SkillReference target = new SkillReference();
target.setTenantId(tenantId);
target.setSkillId(skillId);
target.setPath(source.getPath());
target.setName(source.getName());
target.setContent(source.getContent());
target.setContentHash(source.getContentHash());
target.setSize(source.getSize());
target.setMetadataJson(source.getMetadata().getValues());
return target;
}
/**
* 转换导入 script。
*
* @param skillId Skill ID
* @param tenantId 租户 ID
* @param source 导入模型
* @return script 实体
*/
public static SkillScript fromAgentScript(BigInteger skillId, BigInteger tenantId,
com.easyagents.skill.model.SkillScript source) {
SkillScript target = new SkillScript();
target.setTenantId(tenantId);
target.setSkillId(skillId);
target.setPath(source.getPath());
target.setLanguage(source.getLanguage().name());
target.setContent(source.getContent());
target.setContentHash(source.getContentHash());
target.setSize(source.getSize());
target.setMetadataJson(source.getMetadata().getValues());
return target;
}
/**
* 转换导入 asset。
*
* @param skillId Skill ID
* @param tenantId 租户 ID
* @param source 导入模型
* @return asset 实体
*/
public static SkillAsset fromAgentAsset(BigInteger skillId, BigInteger tenantId,
com.easyagents.skill.model.SkillAsset source) {
SkillAsset target = new SkillAsset();
target.setTenantId(tenantId);
target.setSkillId(skillId);
target.setPath(source.getPath());
target.setName(source.getName());
target.setMediaType(source.getMediaType());
target.setContentRef(source.getContentRef());
target.setContentHash(source.getContentHash());
target.setSize(source.getSize());
target.setMetadataJson(source.getMetadata().getValues());
return target;
}
private static SkillScriptLanguage parseLanguage(String language) {
if (language == null || language.isBlank()) {
return SkillScriptLanguage.UNKNOWN;
}
try {
return SkillScriptLanguage.valueOf(language);
} catch (IllegalArgumentException ignored) {
return SkillScriptLanguage.UNKNOWN;
}
}
}