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

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

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

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

View File

@@ -0,0 +1,442 @@
package tech.easyflow.skill.file;
import com.mybatisflex.core.query.QueryWrapper;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.mockito.MockedStatic;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
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.SkillResourceService;
import tech.easyflow.skill.service.SkillService;
import tech.easyflow.skill.store.DBSkillContentStore;
import tech.easyflow.system.service.ResourceAccessService;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Method;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotSame;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
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 SkillFileServiceImpl} 上传事务入口与失败回滚契约测试。
*/
public class SkillFileServiceImplTransactionTest {
private static final BigInteger SKILL_ID = BigInteger.valueOf(101);
private static final String NEW_CONTENT_REF = "sha256:" + "a".repeat(64);
private SkillService skillService;
private SkillResourceService skillResourceService;
private DBSkillContentStore contentStore;
private SkillFileServiceImpl service;
private MockedStatic<SaTokenUtil> saToken;
/**
* 初始化上传服务。
*/
@Before
public void setUp() {
skillService = mock(SkillService.class);
skillResourceService = mock(SkillResourceService.class);
contentStore = mock(DBSkillContentStore.class);
service = new SkillFileServiceImpl(
skillService,
skillResourceService,
contentStore,
mock(ResourceAccessService.class));
Skill skill = new Skill();
skill.setId(SKILL_ID);
skill.setTenantId(BigInteger.ONE);
LoginAccount account = new LoginAccount();
account.setId(BigInteger.valueOf(7));
account.setTenantId(BigInteger.ONE);
saToken = mockStatic(SaTokenUtil.class);
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
when(skillService.getOne(any(QueryWrapper.class))).thenReturn(skill);
when(skillResourceService.list(any(QueryWrapper.class))).thenReturn(List.of());
when(skillResourceService.listDescriptors(any(BigInteger.class), any(BigInteger.class)))
.thenReturn(List.of());
when(contentStore.put(any(MultipartFile.class), anyString())).thenReturn(NEW_CONTENT_REF);
}
/**
* 释放静态登录态 Mock。
*/
@After
public void tearDown() {
saToken.close();
}
/**
* 验证资源持久化失败时不手工 release 新引用,引用计数应由同一外层事务回滚。
*/
@Test
public void failedUploadDoesNotDoubleReleaseNewReference() {
when(skillResourceService.save(any(SkillResource.class))).thenReturn(false);
assertThrows(BusinessException.class, () -> service.uploadAsset(
SKILL_ID, "assets/file.bin", multipart("file.bin", "content")));
verify(contentStore).put(any(MultipartFile.class), anyString());
verify(contentStore, never()).release(NEW_CONTENT_REF);
}
/**
* 验证 uploadAsset 自身成为事务代理入口,不依赖类内调用 uploadResource 的注解。
*
* @throws Exception 反射读取方法失败
*/
@Test
public void uploadAssetIsTransactionalEntry() throws Exception {
Method method = SkillFileServiceImpl.class.getMethod(
"uploadAsset", BigInteger.class, String.class, MultipartFile.class);
assertTrue(method.isAnnotationPresent(Transactional.class));
}
/**
* 验证空 Skill 的文件树仍稳定返回三个标准目录。
*/
@Test
public void treeAlwaysContainsStandardDirectories() {
List<SkillFileNode> roots = service.tree(SKILL_ID);
assertEquals(List.of("SKILL.md", "references", "scripts", "assets"),
roots.stream().map(SkillFileNode::getPath).toList());
assertEquals(List.of("SKILL", "DIRECTORY", "DIRECTORY", "DIRECTORY"),
roots.stream().map(SkillFileNode::getType).toList());
}
/**
* 验证脚本上传按严格 UTF-8 文本保存,不进入二进制内容仓库。
*/
@Test
public void scriptUploadStoresCanonicalTextRepresentation() {
AtomicReference<SkillResource> savedResource = new AtomicReference<>();
when(skillResourceService.list(any(QueryWrapper.class)))
.thenAnswer(invocation -> savedResource.get() == null
? List.of() : List.of(savedResource.get()));
when(skillResourceService.save(any(SkillResource.class))).thenAnswer(invocation -> {
SkillResource resource = invocation.getArgument(0);
resource.setId(BigInteger.valueOf(9));
savedResource.set(resource);
return true;
});
SkillFileContent result = service.uploadResource(
SKILL_ID, "scripts/tool.py", multipart("tool.py", "print('ok')\n"));
SkillResource resource = savedResource.get();
assertTrue(resource.getIsText());
assertEquals("SCRIPT", resource.getKind());
assertEquals("PYTHON", resource.getLanguage());
assertEquals("print('ok')\n", resource.getTextContent());
assertNull(resource.getContentRef());
assertTrue(result.getIsText());
verify(contentStore, never()).put(any(MultipartFile.class), anyString());
}
/**
* 验证脚本上传拒绝非法 UTF-8且失败前不会写入资源或二进制仓库。
*/
@Test
public void scriptUploadRejectsMalformedUtf8() {
MultipartFile file = new TestMultipartFile("bad.py", new byte[]{(byte) 0xC3, (byte) 0x28});
BusinessException exception = assertThrows(BusinessException.class,
() -> service.uploadResource(SKILL_ID, "scripts/bad.py", file));
assertTrue(exception.getMessage().contains("严格 UTF-8"));
verify(skillResourceService, never()).save(any(SkillResource.class));
verify(contentStore, never()).put(any(MultipartFile.class), anyString());
}
/**
* 验证二进制资源重命名到 scripts 后转为文本,并释放原内容引用。
*/
@Test
public void binaryRenameToScriptConvertsAndReleasesContent() {
String oldRef = "sha256:" + "b".repeat(64);
String sourceHash = "b".repeat(64);
SkillResource resource = resource(
"assets/tool.bin", false, null, oldRef, sourceHash, 12L);
when(skillResourceService.list(any(QueryWrapper.class)))
.thenReturn(List.of(), List.of(resource), List.of(resource), List.of(resource));
when(skillResourceService.update(eq(resource), any(QueryWrapper.class))).thenReturn(true);
when(contentStore.open(oldRef)).thenReturn(
new ByteArrayInputStream("print('ok')\n".getBytes(StandardCharsets.UTF_8)));
SkillFileRenameRequest request = renameRequest(
"assets/tool.bin", "scripts/tool.py", sourceHash);
SkillFileContent result = service.renameFile(request);
assertTrue(resource.getIsText());
assertEquals("scripts/tool.py", resource.getNormalizedPath());
assertEquals("SCRIPT", resource.getKind());
assertEquals("PYTHON", resource.getLanguage());
assertNull(resource.getContentRef());
assertEquals("print('ok')\n", resource.getTextContent());
assertTrue(result.getIsText());
verify(contentStore).release(oldRef);
}
/**
* 验证文本资源重命名到 assets 后转为二进制内容引用。
*/
@Test
public void textRenameToAssetConvertsToBinaryRepresentation() {
String sourceHash = "c".repeat(64);
SkillResource resource = resource(
"references/guide.md", true, "# Guide\n", null, sourceHash, 8L);
when(skillResourceService.list(any(QueryWrapper.class)))
.thenReturn(List.of(), List.of(resource), List.of(resource), List.of(resource));
when(skillResourceService.update(eq(resource), any(QueryWrapper.class))).thenReturn(true);
when(contentStore.put(any(byte[].class))).thenReturn(NEW_CONTENT_REF);
SkillFileRenameRequest request = renameRequest(
"references/guide.md", "assets/guide.md", sourceHash);
SkillFileContent result = service.renameFile(request);
assertFalse(resource.getIsText());
assertEquals("ASSET", resource.getKind());
assertEquals(NEW_CONTENT_REF, resource.getContentRef());
assertNull(resource.getTextContent());
assertFalse(result.getIsText());
verify(contentStore).put("# Guide\n".getBytes(StandardCharsets.UTF_8));
}
/**
* 验证 assets 路径不能通过文本创建入口形成非规范表示。
*/
@Test
public void createTextAssetIsRejected() {
SkillFileSaveRequest request = new SkillFileSaveRequest();
request.setSkillId(SKILL_ID);
request.setPath("assets/readme.txt");
request.setContent("text");
BusinessException exception = assertThrows(BusinessException.class,
() -> service.createTextFile(request));
assertTrue(exception.getMessage().contains("二进制文件管理"));
verify(skillResourceService, never()).save(any(SkillResource.class));
}
/**
* 验证未知脚本扩展名仍可保真保存,并退化为无语言高亮的文本脚本。
*/
@Test
public void unrecognizedScriptExtensionUsesPlainTextRepresentation() {
AtomicReference<SkillResource> savedResource = new AtomicReference<>();
when(skillResourceService.list(any(QueryWrapper.class)))
.thenAnswer(invocation -> savedResource.get() == null
? List.of() : List.of(savedResource.get()));
when(skillResourceService.save(any(SkillResource.class))).thenAnswer(invocation -> {
SkillResource resource = invocation.getArgument(0);
resource.setId(BigInteger.valueOf(10));
savedResource.set(resource);
return true;
});
SkillFileSaveRequest request = new SkillFileSaveRequest();
request.setSkillId(SKILL_ID);
request.setPath("scripts/run.rb");
request.setContent("puts 'ok'\n");
SkillFileContent result = service.createTextFile(request);
assertEquals("SCRIPT", savedResource.get().getKind());
assertTrue(savedResource.get().getIsText());
assertNull(savedResource.get().getLanguage());
assertEquals("text/plain", savedResource.get().getMediaType());
assertEquals("puts 'ok'\n", result.getContent());
}
/**
* 验证非 Markdown Reference 保存后仍保留按扩展名识别的媒体类型。
*/
@Test
public void jsonReferenceSavePreservesJsonRepresentation() {
String sourceHash = "d".repeat(64);
SkillResource resource = resource(
"references/data.json", true, "{}", null, sourceHash, 2L);
resource.setKind("REFERENCE");
resource.setLanguage(null);
resource.setMediaType("application/json");
when(skillResourceService.list(any(QueryWrapper.class)))
.thenReturn(List.of(resource), List.of(resource));
when(skillResourceService.update(eq(resource), any(QueryWrapper.class))).thenReturn(true);
SkillFileSaveRequest request = new SkillFileSaveRequest();
request.setSkillId(SKILL_ID);
request.setPath("references/data.json");
request.setContent("{\"ok\":true}\n");
request.setExpectedContentHash(sourceHash);
SkillFileContent result = service.saveContent(request);
assertEquals("REFERENCE", resource.getKind());
assertEquals("application/json", resource.getMediaType());
assertNull(resource.getLanguage());
assertEquals("application/json", result.getMediaType());
assertEquals("{\"ok\":true}\n", result.getContent());
}
/**
* 验证保存 SKILL.md 时不会提前修改当前会话中的持久化实体,避免一级缓存导致版本误判。
*/
@Test
public void skillMarkdownSaveUsesDetachedUpdateForOptimisticCheck() {
String oldContent = "---\nname: demo\ndescription: old\n---\n\n# Old\n";
String newContent = "---\nname: demo\ndescription: new\n---\n\n# New\n";
String oldHash = com.easyagents.skill.util.SkillHashes.sha256Hex(
oldContent.getBytes(StandardCharsets.UTF_8));
Skill persisted = new Skill();
persisted.setId(SKILL_ID);
persisted.setTenantId(BigInteger.ONE);
persisted.setCategoryId(BigInteger.valueOf(3));
persisted.setDisplayName("演示 Skill");
persisted.setEnabled(false);
persisted.setVisibilityScope("DEPT");
persisted.setSkillContent(oldContent);
persisted.getMetadataJson().put("owner", "qa");
AtomicReference<Skill> updateRef = new AtomicReference<>();
when(skillService.getOne(any(QueryWrapper.class))).thenReturn(persisted);
when(skillService.updateDraftIfContentMatches(any(Skill.class), eq(oldHash)))
.thenAnswer(invocation -> {
updateRef.set(invocation.getArgument(0));
return persisted;
});
SkillFileSaveRequest request = new SkillFileSaveRequest();
request.setSkillId(SKILL_ID);
request.setPath("SKILL.md");
request.setContent(newContent);
request.setExpectedContentHash(oldHash);
service.saveContent(request);
Skill update = updateRef.get();
assertNotSame(persisted, update);
assertEquals(oldContent, persisted.getSkillContent());
assertEquals(newContent, update.getSkillContent());
assertEquals(persisted.getCategoryId(), update.getCategoryId());
assertEquals(persisted.getDisplayName(), update.getDisplayName());
assertEquals(persisted.getEnabled(), update.getEnabled());
assertEquals(persisted.getVisibilityScope(), update.getVisibilityScope());
assertNotSame(persisted.getMetadataJson(), update.getMetadataJson());
assertEquals(persisted.getMetadataJson(), update.getMetadataJson());
}
/**
* 创建内存上传文件。
*
* @param filename 文件名
* @param content 文件内容
* @return MultipartFile
*/
private MultipartFile multipart(String filename, String content) {
return new TestMultipartFile(filename, content.getBytes(StandardCharsets.UTF_8));
}
/**
* 创建测试资源。
*
* @param path 路径
* @param text 是否文本
* @param textContent 文本内容
* @param contentRef 内容引用
* @param contentHash 内容 hash
* @param size 字节数
* @return 资源实体
*/
private SkillResource resource(String path,
boolean text,
String textContent,
String contentRef,
String contentHash,
long size) {
SkillResource resource = new SkillResource();
resource.setId(BigInteger.valueOf(8));
resource.setTenantId(BigInteger.ONE);
resource.setSkillId(SKILL_ID);
resource.setPath(path);
resource.setNormalizedPath(path);
resource.setIsText(text);
resource.setTextContent(textContent);
resource.setContentRef(contentRef);
resource.setContentHash(contentHash);
resource.setSize(size);
return resource;
}
/**
* 创建重命名请求。
*
* @param path 原路径
* @param newPath 新路径
* @param hash 预期内容 hash
* @return 重命名请求
*/
private SkillFileRenameRequest renameRequest(String path, String newPath, String hash) {
SkillFileRenameRequest request = new SkillFileRenameRequest();
request.setSkillId(SKILL_ID);
request.setPath(path);
request.setNewPath(newPath);
request.setExpectedContentHash(hash);
return request;
}
/**
* 简单内存 MultipartFile 测试替身。
*/
private static final class TestMultipartFile implements MultipartFile {
private final String filename;
private final byte[] bytes;
/**
* 创建测试文件。
*
* @param filename 文件名
* @param bytes 内容
*/
private TestMultipartFile(String filename, byte[] bytes) {
this.filename = filename;
this.bytes = bytes;
}
@Override public String getName() { return "file"; }
@Override public String getOriginalFilename() { return filename; }
@Override public String getContentType() { return "application/octet-stream"; }
@Override public boolean isEmpty() { return bytes.length == 0; }
@Override public long getSize() { return bytes.length; }
@Override public byte[] getBytes() { return bytes.clone(); }
@Override public InputStream getInputStream() { return new ByteArrayInputStream(bytes); }
@Override public void transferTo(File destination) throws IOException {
org.springframework.util.FileCopyUtils.copy(bytes, destination);
}
}
}