Explorar el Código

feat(agent): 技能存储改为官方 JDBC 仓库,按 namespace 做用户隔离

- SkillRepositoryConfig:AbstractJdbcDialect(enableSkillTables) + JdbcAgentSkillRepository,
  建表交给 sql/agent_skill.sql(autoCreateTable=false,守「建表只在 sql/」的约定)
- SkillRegistryService:技能增删改查 / zip 安装 / 启停。namespace = 登录用户 ID,
  官方表 UNIQUE(namespace, name) 让不同用户可拥有同名技能,不再需要自建归属表
- SkillPackageImporter:保留 50MB / 2000 条 / 路径穿越校验(官方 createFromZip 无任何 zip 炸弹防护),
  解压后交给官方 SkillFileSystemHelper 解析,二进制按 base64: 前缀入库
- UserSkillRepositoryProvider:按用户缓存绑定 namespace 的只读仓库,buildAgent 直接挂载,
  不再需要按用户过滤的包装层
- agent_skill_state(user_id + skill_name 唯一):官方仓库没有启停概念,这里记每个用户自己的开关
- 技能增删改 / 安装后 evictAllAgentPools(),下一轮对话立即生效(启停走每轮 SkillFilter overlay,不驱逐)
- 删除纯磁盘实现:SkillStore、SkillRepositorySupport、SkillRepositoryConfigEntry、resolveUserSkillsPath
- 测试:SkillPackageImporterTest(9 个安全边界用例)+ SkillRegistryServiceLiveTest(真库往返、
  跨用户同名、启停隔离),AgentExpertTest 的白名单校验改为走归属查询
cc hace 1 semana
padre
commit
a721cab494
Se han modificado 27 ficheros con 1339 adiciones y 1553 borrados
  1. 2 2
      AI_AGENT.md
  2. 0 21
      ai-server/src/main/java/com/zsjz/ai/module/agent/WorkspaceManagerFactory.java
  3. 71 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/config/SkillRepositoryConfig.java
  4. 0 137
      ai-server/src/main/java/com/zsjz/ai/module/agent/config/SkillRepositoryConfigEntry.java
  5. 0 142
      ai-server/src/main/java/com/zsjz/ai/module/agent/config/SkillRepositorySupport.java
  6. 16 21
      ai-server/src/main/java/com/zsjz/ai/module/agent/controller/AgentCapabilityController.java
  7. 4 4
      ai-server/src/main/java/com/zsjz/ai/module/agent/dto/SaveSkillDTO.java
  8. 46 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/entity/AgentSkillStateEntity.java
  9. 9 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/mapper/AgentSkillStateMapper.java
  10. 18 19
      ai-server/src/main/java/com/zsjz/ai/module/agent/service/AgentCapabilityService.java
  11. 13 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/service/AgentInstancePool.java
  12. 27 42
      ai-server/src/main/java/com/zsjz/ai/module/agent/service/AgentService.java
  13. 22 131
      ai-server/src/main/java/com/zsjz/ai/module/agent/service/impl/AgentCapabilityServiceImpl.java
  14. 10 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/skill/SkillFileEntry.java
  15. 176 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/skill/SkillPackageImporter.java
  16. 442 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/skill/SkillRegistryService.java
  17. 0 687
      ai-server/src/main/java/com/zsjz/ai/module/agent/skill/SkillStore.java
  18. 78 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/skill/UserSkillRepositoryProvider.java
  19. 0 3
      ai-server/src/main/java/com/zsjz/ai/module/agent/vo/SkillDetailVO.java
  20. 3 5
      ai-server/src/main/java/com/zsjz/ai/module/agent/vo/SkillVO.java
  21. 2 1
      ai-server/src/test/java/com/zsjz/ai/module/agent/service/AgentCapabilityToolkitTest.java
  22. 16 44
      ai-server/src/test/java/com/zsjz/ai/module/agent/service/AgentExpertTest.java
  23. 2 1
      ai-server/src/test/java/com/zsjz/ai/module/agent/service/impl/AgentToolCatalogServiceImplTest.java
  24. 153 0
      ai-server/src/test/java/com/zsjz/ai/module/agent/skill/SkillPackageImporterTest.java
  25. 154 0
      ai-server/src/test/java/com/zsjz/ai/module/agent/skill/SkillRegistryServiceLiveTest.java
  26. 0 293
      ai-server/src/test/java/com/zsjz/ai/module/agent/skill/SkillStoreTest.java
  27. 75 0
      sql/agent_skill.sql

+ 2 - 2
AI_AGENT.md

@@ -60,7 +60,7 @@ zsjz-ai/
 | 缓存 | Redis |
 | 图库 | Neo4j(配置存在,非全链路强依赖) |
 | 鉴权 | Sa-Token 1.46 |
-| Agent | **AgentScope 2.0.4**(harness + dashscope/ollama/openai + mem0 + rag-simple + redis) |
+| Agent | **AgentScope 2.1.0-SNAPSHOT**(本地安装 main 分支;harness + dashscope/ollama/openai + mem0 + rag-simple + redis + jdbc 技能仓库) |
 | 检索 | Elasticsearch 8.18(spring-boot-starter-data-elasticsearch,单索引+caseId 隔离) |
 | 其他 | fesod-sheet(Excel)、Hutool、Guava、MapStruct 1.6.3、commons-compress、JNA |
 | 启动类 | `com.zsjz.ai.App` |
@@ -143,7 +143,7 @@ ai-server/src/main/resources/mappers/<domain>/<Xxx>Mapper.xml
 
 | 数据源 | 用途 |
 |---|---|
-| **master**(PostgreSQL + pgvector) | 平台元数据、`table_info`/`table_field`、Agent 会话/消息、模型与 Provider 配置、RAG 索引状态、向量检索 |
+| **master**(PostgreSQL + pgvector) | 平台元数据、`table_info`/`table_field`、Agent 会话/消息、模型与 Provider 配置、**技能**(`agentscope_skills`/`agentscope_skill_resources`,按 namespace=用户ID 隔离 + 启停表 `agent_skill_state`)、RAG 索引状态、向量检索 |
 | **slave**(DuckDB) | **按工作空间切换**的案情库;Agent 的 `execute_sql` 只在这里跑 |
 | Redis | Sa-Token 登录态、业务缓存(`qing_jian_ai*`)、mem0、**Agent 会话状态**(`qing_jian:agent:{agentscope}:session:*`,由 `RedisAgentStateStore` 存对话历史) |
 | Neo4j | 图谱(配置在 `application-dev.yaml`) |

+ 0 - 21
ai-server/src/main/java/com/zsjz/ai/module/agent/WorkspaceManagerFactory.java

@@ -11,8 +11,6 @@ import java.nio.file.Paths;
  * <p>目录结构固定为:
  * <pre>{cwd}/.agentscope/users/{userId}/workspaces/{workspaceId}/agents/{agentRowId}/</pre>
  *
- * <p>另有一处<b>用户级</b>目录(技能仓库,与工作空间无关):{@link #resolveUserSkillsPath}</p>
- *
  * <p>用户间、工作空间间的 agent 文件空间(skills / 记忆 / 产出文件)相互独立,
  * 模型切换不改变数据归属。
  *
@@ -48,25 +46,6 @@ public final class WorkspaceManagerFactory {
                 .normalize();
     }
 
-    /**
-     * 解析用户的<b>技能仓库</b>目录。
-     *
-     * <p>目录结构:{@code {cwd}/.agentscope/users/{userId}/skills} —— 与
-     * {@link #resolveAgentDataPath} 里用户根目录平级的兄弟目录,agent 相关文件一律留在
-     * {@code .agentscope} 下。技能按<b>用户</b>归属、与案件无关:同一用户在任意案件里看到的
-     * 是同一批技能,用户之间互不可见。
-     *
-     * @param userId 用户标识(业务用户ID字符串)
-     * @return 用户技能目录的绝对路径(调用方负责按需创建)
-     */
-    public Path resolveUserSkillsPath(Path cwd, Long userId) {
-        validateSegment("userId", String.valueOf(userId));
-        return cwd.resolve(".agentscope")
-                .resolve("users").resolve(userId.toString())
-                .resolve("skills")
-                .normalize();
-    }
-
     private static void validateSegment(String label, String value) {
         if (value == null || value.isBlank()) {
             throw new IllegalArgumentException(label + " must not be null or blank");

+ 71 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/config/SkillRepositoryConfig.java

@@ -0,0 +1,71 @@
+package com.zsjz.ai.module.agent.config;
+
+import io.agentscope.extensions.jdbc.dialect.AbstractJdbcDialect;
+import io.agentscope.extensions.jdbc.skill.JdbcAgentSkillRepository;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import javax.sql.DataSource;
+
+/**
+ * 技能仓库装配:官方 JDBC 技能仓库(PostgreSQL)。
+ *
+ * <p>技能本体存在 {@code agentscope_skills} / {@code agentscope_skill_resources} 两张表
+ * (DDL 见 {@code sql/agent_skill.sql}),官方用 {@code namespace} 做作用域隔离:
+ * {@code UNIQUE(namespace, name)},同一 namespace 内名字唯一、跨 namespace 可重名。
+ * 本项目一个用户一个 namespace(见 {@code UserSkillRepositoryProvider}),
+ * 启停状态记在自建的 {@code agent_skill_state}。
+ *
+ * <p>版本口径:官方的 PostgreSQL 专用仓库({@code PostgresSkillRepository})已被文档标记为过时,
+ * 统一 JDBC 版({@code JdbcAgentSkillRepository})在其后的版本提供,且 namespace 只在
+ * main/2.1.0-SNAPSHOT 里才有(2.1.0-BETA1 的同一个类没有 namespace),
+ * 因此本项目 {@code agentscope.version} 用本地安装的 {@code 2.1.0-SNAPSHOT}。
+ *
+ * <p>这里的 bean 绑定默认 namespace、可写,供管理端 CRUD 用(配合 namespace 重载方法);
+ * agent 挂载用的是按用户绑定 namespace 的只读实例,见 {@code UserSkillRepositoryProvider}。
+ *
+ * <p>两个刻意的配置:
+ * <ul>
+ *   <li>{@code enableBaseTables(false)}:方言默认会连带管理 store/session/snapshot 三组表,
+ *       本项目用不到(会话状态走 Redis),关掉避免启动时去校验不存在的表;</li>
+ *   <li>{@code autoCreateTable(false)}:建表由人工执行 {@code sql/agent_skill.sql},
+ *       代码里不做 DDL —— 与 {@code agent_tool_pack.sql} 等项目约定一致。
+ *       表不存在时方言构造期就会报错,属预期的「先执行脚本再启动」。</li>
+ * </ul>
+ */
+@Slf4j
+@Configuration
+public class SkillRepositoryConfig {
+
+    /**
+     * 技能表组方言。
+     *
+     * <p>{@link AbstractJdbcDialect#from(DataSource)} 通过 SPI 按数据库类型选实现
+     * (本项目固定跑 PostgreSQL),{@code build()} 会校验表结构(含 namespace 列与联合唯一键)。
+     */
+    @Bean
+    public AbstractJdbcDialect skillJdbcDialect(DataSource dataSource) {
+        AbstractJdbcDialect dialect = AbstractJdbcDialect.from(dataSource)
+                .enableBaseTables(false)
+                .enableSkillTables(true)
+                .autoCreateTable(false)
+                .build();
+        log.info("技能仓库方言:{},技能表 {} / 资源表 {}",
+                dialect.getClass().getSimpleName(),
+                dialect.skillTableName(), dialect.skillResourcesTableName());
+        return dialect;
+    }
+
+    /**
+     * 管理端用的技能仓库:绑定默认 namespace、可写,CRUD 时显式传 namespace 重载方法。
+     *
+     * <p>{@code destroyMethod = ""}:不要随容器关闭去调 {@code close()},
+     * 数据源归 Spring 管理,仓库本身不持有需要释放的资源。
+     */
+    @Bean(destroyMethod = "")
+    public JdbcAgentSkillRepository agentSkillRepository(DataSource dataSource,
+                                                        AbstractJdbcDialect skillJdbcDialect) {
+        return new JdbcAgentSkillRepository(dataSource, skillJdbcDialect);
+    }
+}

+ 0 - 137
ai-server/src/main/java/com/zsjz/ai/module/agent/config/SkillRepositoryConfigEntry.java

@@ -1,137 +0,0 @@
-/*
- * Copyright 2024-2026 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package com.zsjz.ai.module.agent.config;
-
-import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
-import com.fasterxml.jackson.annotation.JsonInclude;
-import com.fasterxml.jackson.annotation.JsonProperty;
-
-/**
- * Declarative skill repository settings for {@code agentscope.json} (and similar) configs.
- *
- * <p>Use {@code type: "filesystem"} with {@link #path} or {@code type: "git"} with {@link
- * #remoteUrl}. Git support requires {@code io.agentscope:agentscope-extensions-skill-git-repository}
- * on the classpath.
- */
-@JsonInclude(JsonInclude.Include.NON_EMPTY)
-@JsonIgnoreProperties(ignoreUnknown = true)
-public class SkillRepositoryConfigEntry {
-
-    /**
-     * {@code filesystem} — load from a directory ({@link #path}, relative to bootstrap {@code cwd}).
-     *
-     * <p>{@code git} — clone / sync a remote repository ({@link #remoteUrl}, optional {@link
-     * #branch}, {@link #localPath}, etc.).
-     */
-    @JsonProperty("type")
-    private String type;
-
-    /** Directory containing skill folders (each with {@code SKILL.md}). Used when {@code type} is {@code filesystem}. */
-    @JsonProperty("path")
-    private String path;
-
-    @JsonProperty("remoteUrl")
-    private String remoteUrl;
-
-    @JsonProperty("branch")
-    private String branch;
-
-    /**
-     * Optional in-repo subdirectory containing skill folders. Used when {@code type} is {@code
-     * git}; ignored otherwise. When blank, defaults to {@code skills/} if present, else the repo
-     * root. Must be a path relative to the repo root; absolute paths or {@code ..} segments are
-     * rejected by the underlying repository implementation.
-     */
-    @JsonProperty("skillsRoot")
-    private String skillsRoot;
-
-    /**
-     * Local clone directory; when set, resolved relative to bootstrap {@code cwd}. Optional for
-     * {@code git} (otherwise a temp directory is used by {@code GitSkillRepository}).
-     */
-    @JsonProperty("localPath")
-    private String localPath;
-
-    @JsonProperty("source")
-    private String source;
-
-    @JsonProperty("autoSync")
-    private Boolean autoSync;
-
-    public String getType() {
-        return type;
-    }
-
-    public void setType(String type) {
-        this.type = type;
-    }
-
-    public String getPath() {
-        return path;
-    }
-
-    public void setPath(String path) {
-        this.path = path;
-    }
-
-    public String getRemoteUrl() {
-        return remoteUrl;
-    }
-
-    public void setRemoteUrl(String remoteUrl) {
-        this.remoteUrl = remoteUrl;
-    }
-
-    public String getBranch() {
-        return branch;
-    }
-
-    public void setBranch(String branch) {
-        this.branch = branch;
-    }
-
-    public String getSkillsRoot() {
-        return skillsRoot;
-    }
-
-    public void setSkillsRoot(String skillsRoot) {
-        this.skillsRoot = skillsRoot;
-    }
-
-    public String getLocalPath() {
-        return localPath;
-    }
-
-    public void setLocalPath(String localPath) {
-        this.localPath = localPath;
-    }
-
-    public String getSource() {
-        return source;
-    }
-
-    public void setSource(String source) {
-        this.source = source;
-    }
-
-    public Boolean getAutoSync() {
-        return autoSync;
-    }
-
-    public void setAutoSync(Boolean autoSync) {
-        this.autoSync = autoSync;
-    }
-}

+ 0 - 142
ai-server/src/main/java/com/zsjz/ai/module/agent/config/SkillRepositorySupport.java

@@ -1,142 +0,0 @@
-/*
- * Copyright 2024-2026 the original author or authors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package com.zsjz.ai.module.agent.config;
-
-import io.agentscope.core.skill.repository.AgentSkillRepository;
-import io.agentscope.core.skill.repository.FileSystemSkillRepository;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.util.ArrayList;
-import java.util.List;
-
-/** Builds an {@link AgentSkillRepository} from {@link SkillRepositoryConfigEntry}. */
-public final class SkillRepositorySupport {
-
-    private static final Logger log = LoggerFactory.getLogger(SkillRepositorySupport.class);
-
-    private static final String TYPE_FILESYSTEM = "filesystem";
-    private static final String TYPE_GIT = "git";
-
-    private SkillRepositorySupport() {}
-
-
-
-    /**
-     * Materialises every non-null entry in {@code entries} via {@link #create(Path,
-     * SkillRepositoryConfigEntry)} and returns the resulting list, preserving order. Entries that
-     * fail to instantiate (unknown type, missing optional Git dependency, …) are filtered out and
-     * logged at WARN. Never null; may be empty.
-     */
-    public static List<AgentSkillRepository> createAll(
-            Path cwd, List<SkillRepositoryConfigEntry> entries) {
-        if (entries == null || entries.isEmpty()) return List.of();
-        List<AgentSkillRepository> out = new ArrayList<>(entries.size());
-        for (SkillRepositoryConfigEntry entry : entries) {
-            AgentSkillRepository repo = create(cwd, entry);
-            if (repo != null) {
-                out.add(repo);
-            }
-        }
-        return out;
-    }
-
-    /**
-     * @param cwd   bootstrap working directory (used to resolve relative paths)
-     * @param entry non-null config entry
-     * @return repository instance, or {@code null} if configuration is invalid or optional Git
-     *     types are not on the classpath
-     */
-    public static AgentSkillRepository create(Path cwd, SkillRepositoryConfigEntry entry) {
-        if (entry == null || entry.getType() == null || entry.getType().isBlank()) {
-            return null;
-        }
-        String kind = entry.getType().trim().toLowerCase();
-        return switch (kind) {
-            case TYPE_FILESYSTEM -> createFilesystem(cwd, entry);
-            case TYPE_GIT -> createGit(cwd, entry);
-            default -> {
-                log.info(
-                        "Unknown skillRepository type '{}'; expected '{}' or '{}'",
-                        entry.getType(),
-                        TYPE_FILESYSTEM,
-                        TYPE_GIT);
-                yield null;
-            }
-        };
-    }
-
-    private static AgentSkillRepository createFilesystem(
-            Path cwd, SkillRepositoryConfigEntry entry) {
-        String pathStr = entry.getPath();
-        if (pathStr == null || pathStr.isBlank()) {
-            log.info("skillRepository type filesystem requires non-blank 'path'");
-            return null;
-        }
-        Path dir = cwd.resolve(pathStr).normalize();
-        if (!Files.isDirectory(dir)) {
-            log.info(
-                    "skillRepository path '{}' resolved to '{}' which is not a directory",
-                    pathStr,
-                    dir);
-            return null;
-        }
-        return new FileSystemSkillRepository(dir);
-    }
-
-    private static AgentSkillRepository createGit(Path cwd, SkillRepositoryConfigEntry entry) {
-        String remote = entry.getRemoteUrl();
-        if (remote == null || remote.isBlank()) {
-            log.info("skillRepository type git requires non-blank 'remoteUrl'");
-            return null;
-        }
-        Path local =
-                entry.getLocalPath() != null && !entry.getLocalPath().isBlank()
-                        ? cwd.resolve(entry.getLocalPath()).normalize()
-                        : null;
-        boolean auto = entry.getAutoSync() == null || Boolean.TRUE.equals(entry.getAutoSync());
-        try {
-            Class<?> gitRepo =
-                    Class.forName("io.agentscope.core.skill.repository.GitSkillRepository");
-            var ctor =
-                    gitRepo.getConstructor(
-                            String.class,
-                            String.class,
-                            Path.class,
-                            String.class,
-                            boolean.class,
-                            String.class);
-            return (AgentSkillRepository)
-                    ctor.newInstance(
-                            remote,
-                            entry.getBranch(),
-                            local,
-                            entry.getSource(),
-                            auto,
-                            entry.getSkillsRoot());
-        } catch (ClassNotFoundException e) {
-            log.error(
-                    "GitSkillRepository not on classpath; add dependency"
-                            + " agentscope-extensions-skill-git-repository");
-            return null;
-        } catch (ReflectiveOperationException e) {
-            log.error("Failed to construct GitSkillRepository: {}", e.getMessage());
-            return null;
-        }
-    }
-}

+ 16 - 21
ai-server/src/main/java/com/zsjz/ai/module/agent/controller/AgentCapabilityController.java

@@ -4,10 +4,10 @@ import com.zsjz.ai.common.config.Result;
 import com.zsjz.ai.module.agent.dto.SaveSkillDTO;
 import com.zsjz.ai.module.agent.service.AgentCapabilityService;
 import com.zsjz.ai.module.agent.service.AgentToolCatalogService;
-import com.zsjz.ai.module.agent.skill.SkillStore;
+import com.zsjz.ai.module.agent.skill.SkillFileEntry;
+import com.zsjz.ai.module.agent.vo.AgentToolVO;
 import com.zsjz.ai.module.agent.vo.SkillDetailVO;
 import com.zsjz.ai.module.agent.vo.SkillFileContentVO;
-import com.zsjz.ai.module.agent.vo.AgentToolVO;
 import com.zsjz.ai.module.agent.vo.SkillVO;
 import jakarta.validation.Valid;
 import lombok.RequiredArgsConstructor;
@@ -21,8 +21,7 @@ import java.util.List;
  * 服务端 agent 能力清单与技能管理(「插件与技能」页的数据源)。
  *
  * <p>与 {@code /models} 一样走 {@code Result<T>} 包装;技能按<b>登录用户</b>隔离,
- * 装在 {@code {cwd}/.agentscope/users/{userId}/skills},与案件无关,因此除「展示该案件配置的
- * 只读技能仓库」外都不需要 caseId。
+ * 存在官方 JDBC 技能仓库(按 namespace = 登录用户 ID 隔离),与案件无关,因此技能接口都不需要 caseId。
  */
 @Slf4j
 @RestController
@@ -44,22 +43,19 @@ public class AgentCapabilityController {
     }
 
     /**
-     * 技能清单:当前用户可管理的技能 + 该案件下 agent 配置的技能仓库(只读)。
-     *
-     * @param caseId 案件ID,可选;为空时只返回用户技能
+     * 技能清单:当前登录用户可管理的技能。
      */
     @GetMapping("/skills")
-    public Result<List<SkillVO>> listSkills(@RequestParam(required = false) Long caseId) {
-        return Result.succeed(agentCapabilityService.listSkills(caseId));
+    public Result<List<SkillVO>> listSkills() {
+        return Result.succeed(agentCapabilityService.listSkills());
     }
 
     /**
-     * 技能详情:SKILL.md 正文 + 附属文件清单。
+     * 技能详情:完整 SKILL.md + 附属文件清单。
      */
     @GetMapping("/skills/{name}")
-    public Result<SkillDetailVO> readSkill(@PathVariable String name,
-                                           @RequestParam(required = false) Long caseId) {
-        return Result.succeed(agentCapabilityService.readSkill(name, caseId));
+    public Result<SkillDetailVO> readSkill(@PathVariable String name) {
+        return Result.succeed(agentCapabilityService.readSkill(name));
     }
 
     /**
@@ -69,9 +65,8 @@ public class AgentCapabilityController {
      */
     @GetMapping("/skills/{name}/file")
     public Result<SkillFileContentVO> readSkillFile(@PathVariable String name,
-                                                    @RequestParam String path,
-                                                    @RequestParam(required = false) Long caseId) {
-        return Result.succeed(agentCapabilityService.readSkillFile(name, path, caseId));
+                                                    @RequestParam String path) {
+        return Result.succeed(agentCapabilityService.readSkillFile(name, path));
     }
 
     /**
@@ -95,17 +90,17 @@ public class AgentCapabilityController {
     }
 
     /** DTO 的附属文件 → 技能仓库的写入条目(DTO 只承载文本内容) */
-    private static List<SkillStore.FileEntry> toFileEntries(SaveSkillDTO dto) {
+    private static List<SkillFileEntry> toFileEntries(SaveSkillDTO dto) {
         if (dto.getFiles() == null || dto.getFiles().isEmpty()) {
             return List.of();
         }
         return dto.getFiles().stream()
-                .map(f -> new SkillStore.FileEntry(f.getPath(), f.getContent()))
+                .map(f -> new SkillFileEntry(f.getPath(), f.getContent()))
                 .toList();
     }
 
     /**
-     * 上传 zip 安装技能到当前用户的技能目录。
+     * 上传 zip 安装技能到当前用户名下。
      *
      * @param overwrite 同名技能是否覆盖(默认 false:已存在则报错,避免误覆盖)
      */
@@ -116,7 +111,7 @@ public class AgentCapabilityController {
     }
 
     /**
-     * 卸载技能(删除技能目录)。沿用本项目「删除用 GET」的既有风格,同 {@code AgentModelController}。
+     * 卸载技能。沿用本项目「删除用 GET」的既有风格,同 {@code AgentModelController}。
      */
     @GetMapping("/skills/{name}/delete")
     public Result<Void> deleteSkill(@PathVariable String name) {
@@ -125,7 +120,7 @@ public class AgentCapabilityController {
     }
 
     /**
-     * 启停技能:停用 = 写 {@code .disabled} 标记(下一轮对话即不生效),启用 = 删除标记。
+     * 启停技能:停用后下一轮对话即不生效,不需要重建 agent 实例。
      */
     @GetMapping("/skills/{name}/toggle")
     public Result<Void> toggleSkill(@PathVariable String name, @RequestParam boolean enabled) {

+ 4 - 4
ai-server/src/main/java/com/zsjz/ai/module/agent/dto/SaveSkillDTO.java

@@ -10,14 +10,14 @@ import java.util.List;
 /**
  * 新建/修改技能请求 DTO(「插件与技能 → 技能」页的新增/编辑,以及专家弹窗内的「修改技能」)。
  *
- * <p>技能按目录规范落地:{@code SKILL.md} 必需(本 DTO 的 description + content),
- * {@code scripts/} {@code references/} {@code assets/} {@code testcases/} 可选(本 DTO 的 files)。
- * 修改时技能名以路径参数为准(目录名不可变,避免留下孤儿目录)。
+ * <p>技能落地形态:SKILL.md 必需(本 DTO 的 description + content),
+ * {@code scripts/} {@code references/} 可选(本 DTO 的 files)。
+ * 修改时技能名以路径参数为准(技能名不可变)。
  */
 @Data
 public class SaveSkillDTO {
 
-    /** 技能名(中英文/数字/._-,1~64;修改时忽略,以后端路径参数为准) */
+    /** 技能名(中英文/数字/._-,1~64;全平台唯一;修改时忽略,以后端路径参数为准) */
     @NotBlank(message = "技能名不能为空")
     @Size(max = 64, message = "技能名长度不能超过64")
     private String name;

+ 46 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/entity/AgentSkillStateEntity.java

@@ -0,0 +1,46 @@
+package com.zsjz.ai.module.agent.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * 技能启停状态({@code agent_skill_state})。
+ *
+ * <p>技能本体存在官方 JDBC 技能仓库里,按 namespace 隔离(namespace = 登录用户 ID),
+ * 归属由官方表的 {@code UNIQUE(namespace, name)} 保证,不需要额外的归属表。
+ * 官方没有「启用/停用」概念,所以每个用户自己的开关记在这张表:
+ * {@code (user_id, skill_name)} 唯一,{@code enabled=false} 表示下一轮对话起该技能
+ * 不进入这个用户的技能目录(其他人不受影响)。
+ *
+ * <p>没有对应行的技能视为启用(例如直接写官方表导入的技能),列表里不会因此隐藏。
+ */
+@Data
+@TableName("agent_skill_state")
+public class AgentSkillStateEntity {
+
+    /** 雪花 ID(MyBatis-Plus ASSIGN_ID) */
+    @TableId(value = "id", type = IdType.ASSIGN_ID)
+    private Long id;
+
+    /** 登录用户 ID(同时也是技能所在的 namespace) */
+    @TableField("user_id")
+    private Long userId;
+
+    @TableField("skill_name")
+    private String skillName;
+
+    /** 是否启用 */
+    @TableField("enabled")
+    private Boolean enabled;
+
+    @TableField("create_at")
+    private LocalDateTime createAt;
+
+    @TableField("update_at")
+    private LocalDateTime updateAt;
+}

+ 9 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/mapper/AgentSkillStateMapper.java

@@ -0,0 +1,9 @@
+package com.zsjz.ai.module.agent.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.zsjz.ai.module.agent.entity.AgentSkillStateEntity;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface AgentSkillStateMapper extends BaseMapper<AgentSkillStateEntity> {
+}

+ 18 - 19
ai-server/src/main/java/com/zsjz/ai/module/agent/service/AgentCapabilityService.java

@@ -1,9 +1,9 @@
 package com.zsjz.ai.module.agent.service;
 
+import com.zsjz.ai.module.agent.skill.SkillFileEntry;
 import com.zsjz.ai.module.agent.vo.SkillDetailVO;
 import com.zsjz.ai.module.agent.vo.SkillFileContentVO;
 import com.zsjz.ai.module.agent.vo.SkillVO;
-import com.zsjz.ai.module.agent.skill.SkillStore;
 import io.agentscope.core.skill.SkillFilter;
 import org.springframework.web.multipart.MultipartFile;
 
@@ -12,32 +12,31 @@ import java.util.List;
 /**
  * 服务端 agent 的能力清单与技能管理。
  *
- * <p>「插件与技能」页的数据源:工具(agent 能调用什么)、技能(磁盘上的 SKILL.md 包)。
- * 技能存本地磁盘、按用户隔离、不落库。
+ * <p>「插件与技能」页的数据源:工具(agent 能调用什么)、技能(存在官方 JDBC 技能仓库里的
+ * SKILL.md 包)。技能按登录用户隔离(namespace = 用户 ID),启停记在 {@code agent_skill_state},
+ * 与案件无关,因此技能接口都不需要 caseId。
  */
 public interface AgentCapabilityService {
 
     /**
-     * 技能清单:用户技能目录(可管理)+ 该案件下 agent 配置的技能仓库(只读)。
-     *
-     * @param caseId 案件ID,可为空(为空时只返回用户技能)
+     * 当前登录用户可管理的技能清单。
      */
-    List<SkillVO> listSkills(Long caseId);
+    List<SkillVO> listSkills();
 
     /**
-     * 技能详情(SKILL.md 正文 + 附属文件)。
+     * 技能详情(完整 SKILL.md + 附属文件清单)。
      */
-    SkillDetailVO readSkill(String name, Long caseId);
+    SkillDetailVO readSkill(String name);
 
     /**
      * 读取技能内单个文件的内容(详情文件树点开节点时用)。
      *
-     * @param path 相对技能目录的路径(正斜杠),{@code SKILL.md} 也允许
+     * @param path 相对技能目录的路径(正斜杠),如 {@code references/style.md}
      */
-    SkillFileContentVO readSkillFile(String name, String path, Long caseId);
+    SkillFileContentVO readSkillFile(String name, String path);
 
     /**
-     * 上传 zip 安装技能到<b>当前登录用户</b>的技能目录。
+     * 上传 zip 安装技能到<b>当前登录用户</b>名下。
      *
      * @param overwrite 同名技能是否覆盖
      * @return 安装后的技能
@@ -45,32 +44,32 @@ public interface AgentCapabilityService {
     SkillVO installSkillZip(MultipartFile file, boolean overwrite);
 
     /**
-     * 新建技能:写 SKILL.md(frontmatter + 正文)与可选附属文件到当前用户技能目录,免打 zip 包。
+     * 新建技能:写入 SKILL.md(frontmatter + 正文)与可选附属文件,免打 zip 包。
      *
-     * @param files       附属文件(scripts/ references/ assets/ testcases/ 下的文本文件),可为空
+     * @param files       附属文件(scripts/ references/ 下的文本文件),可为空
      * @param deletePaths 要删除的附属文件路径,可为空(新建时通常为空)
      * @return 创建后的技能
      */
     SkillVO createSkill(String name, String description, String content,
-                        List<SkillStore.FileEntry> files, List<String> deletePaths);
+                        List<SkillFileEntry> files, List<String> deletePaths);
 
     /**
-     * 修改技能(仅用户技能):重写 SKILL.md,并写入/删除附属文件;技能名以入参为准(与目录一致)。
+     * 修改技能(仅自己名下的):重写 SKILL.md,并写入/删除附属文件;技能名不可变。
      *
      * @param files       要写入(或覆盖)的附属文件,可为空
      * @param deletePaths 要删除的附属文件路径,可为空;未提交的文件保持原样
      * @return 保存后的技能
      */
     SkillVO updateSkill(String name, String description, String content,
-                        List<SkillStore.FileEntry> files, List<String> deletePaths);
+                        List<SkillFileEntry> files, List<String> deletePaths);
 
     /**
-     * 卸载技能(删目录)。
+     * 卸载技能(技能本体 + 归属行一起删)。
      */
     void deleteSkill(String name);
 
     /**
-     * 启停技能(写/删 {@code .disabled} 标记)。
+     * 启停技能(只改归属行的 enabled,下一轮对话即生效)。
      */
     void toggleSkill(String name, boolean enabled);
 

+ 13 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/service/AgentInstancePool.java

@@ -101,6 +101,19 @@ public class AgentInstancePool<T> {
         return pool.size();
     }
 
+    /**
+     * 清空全部实例(技能这类全平台共享的数据变更后调用:任何专家的实例都可能引用了旧数据)
+     *
+     * @return 驱逐数量
+     */
+    public synchronized int evictAll() {
+        int removed = pool.size();
+        pool.clear();
+        accessSeq.clear();
+        accessWallMs.clear();
+        return removed;
+    }
+
     /**
      * 空闲超时回收(持锁调用)
      */

+ 27 - 42
ai-server/src/main/java/com/zsjz/ai/module/agent/service/AgentService.java

@@ -10,7 +10,6 @@ import com.zsjz.ai.common.utils.Json;
 import com.zsjz.ai.module.agent.WorkspaceManagerFactory;
 import com.zsjz.ai.module.agent.artifact.ArtifactService;
 import com.zsjz.ai.module.agent.config.EtlProperties;
-import com.zsjz.ai.module.agent.config.SkillRepositoryConfigEntry;
 import com.zsjz.ai.module.agent.dto.SaveExpertDTO;
 import com.zsjz.ai.module.agent.entity.AgentEntity;
 import com.zsjz.ai.module.agent.entity.AgentModel;
@@ -27,6 +26,7 @@ import com.zsjz.ai.module.agent.python.PythonExecutor;
 import com.zsjz.ai.module.agent.rag.EmbeddingProvider;
 import com.zsjz.ai.module.agent.rag.RagSchemaService;
 import com.zsjz.ai.module.agent.rag.WorkspaceSchemaCatalog;
+import com.zsjz.ai.module.agent.skill.UserSkillRepositoryProvider;
 import com.zsjz.ai.module.agent.tools.*;
 import com.zsjz.ai.module.knowledge.KnowledgeDocIndexer;
 import com.zsjz.ai.module.knowledge.entity.KnowledgeBase;
@@ -38,7 +38,7 @@ import com.zsjz.ai.module.plat.mapper.TableFieldMapper;
 import com.zsjz.ai.module.plat.mapper.TableInfoMapper;
 import io.agentscope.core.model.Model;
 import io.agentscope.core.skill.SkillFilter;
-import io.agentscope.core.skill.repository.FileSystemSkillRepository;
+import io.agentscope.core.skill.util.SkillUtil;
 import io.agentscope.core.state.AgentStateStore;
 import io.agentscope.core.state.InMemoryAgentStateStore;
 import io.agentscope.core.tool.Toolkit;
@@ -48,7 +48,6 @@ import io.agentscope.harness.agent.filesystem.spec.LocalFilesystemSpec;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 
-import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.Paths;
 import java.util.List;
@@ -98,6 +97,8 @@ public class AgentService extends ServiceImpl<AgentMapper, AgentEntity> {
     /** 知识库绑定:加载绑定库(校验存在性/可见性)+ 检索工具装配(只依赖 mapper/索引器,避免与 KnowledgeBaseService 循环依赖) */
     private final KnowledgeBaseMapper knowledgeBaseMapper;
     private final KnowledgeDocIndexer knowledgeDocIndexer;
+    /** 技能仓库视图:按登录用户过滤官方 JDBC 仓库里的技能 */
+    private final UserSkillRepositoryProvider userSkillRepositoryProvider;
     private final EmbeddingProvider embeddingProvider;
 
     /**
@@ -143,7 +144,8 @@ public class AgentService extends ServiceImpl<AgentMapper, AgentEntity> {
             WorkspaceSchemaCatalog schemaCatalog,
             KnowledgeBaseMapper knowledgeBaseMapper,
             KnowledgeDocIndexer knowledgeDocIndexer,
-            EmbeddingProvider embeddingProvider) {
+            EmbeddingProvider embeddingProvider,
+            UserSkillRepositoryProvider userSkillRepositoryProvider) {
         this.agentModelFactory = agentModelFactory;
         this.modelMapper = modelMapper;
         this.agentStateStore = stateStoreOpt.orElseGet(InMemoryAgentStateStore::new);
@@ -166,6 +168,7 @@ public class AgentService extends ServiceImpl<AgentMapper, AgentEntity> {
         this.knowledgeBaseMapper = knowledgeBaseMapper;
         this.knowledgeDocIndexer = knowledgeDocIndexer;
         this.embeddingProvider = embeddingProvider;
+        this.userSkillRepositoryProvider = userSkillRepositoryProvider;
     }
 
     /**
@@ -339,21 +342,10 @@ public class AgentService extends ServiceImpl<AgentMapper, AgentEntity> {
         //scaffoldWorkspace(workspace, entry.getSysPrompt());
         b.workspace(workspace);
 
-       /* var skillRepositories = readSkillRepositories(entry.getSkillRepositoriesJson());
-        if (skillRepositories != null && !skillRepositories.isEmpty()) {
-            var repos = SkillRepositorySupport.createAll(workspace, skillRepositories);
-            if (!repos.isEmpty()) {
-                b.skillRepositories(repos);
-            }
-        }*/
-
-        // 用户级技能目录(「插件与技能」页安装的技能):
-        // {cwd}/.agentscope/users/{userId}/skills,与案件无关、跨案件共享。
-        // 目录不存在时不挂载(FileSystemSkillRepository 构造会抛异常),安装第一个技能时自动创建。
-        Path userSkills = workspaceManagerFactory.resolveUserSkillsPath(cwd, userId);
-        if (Files.isDirectory(userSkills)) {
-            b.skillRepository(new FileSystemSkillRepository(userSkills));
-        }
+        // 用户级技能:官方 JDBC 技能仓库里归属于当前登录用户的那部分。
+        // 官方表没有 namespace、name 全局唯一,所以挂载时用只读视图按 userId 过滤
+        // (namespace = 用户 ID,见 UserSkillRepositoryProvider);没有技能时视图返回空列表,不需要判空。
+        b.skillRepository(userSkillRepositoryProvider.forUser(userId));
         // 技能白名单:专家配置了 skills_allow_json → 系统提示目录只列这些技能
         // (SkillFilter.only 作用于「所有技能仓库合并后」,与用户停用的 overlay 正确叠加)
         List<String> expertSkills = readStringList(entry.getSkillsAllowJson());
@@ -366,26 +358,9 @@ public class AgentService extends ServiceImpl<AgentMapper, AgentEntity> {
         return b.build();
     }
 
-    /**
-     * 解析 {@code agent.skill_repositories_json}(与 {@code agentscope.json} 的 skillRepositories 同构)。
-     * 坏 JSON 静默降级为 null(等同「没配技能仓库」)。
-     */
-    public static List<SkillRepositoryConfigEntry> readSkillRepositories(String json) {
-        if (json == null || json.isBlank()) return null;
-        try {
-            return Json.objectMapper().readValue(json, new TypeReference<>() {
-            });
-        } catch (JsonProcessingException ex) {
-            return null;
-        }
-    }
-
 
     // ==================== 专家(智能体)管理 ====================
 
-    /** 技能名合法字符(与 SkillStore 校验口径一致:中英文/数字/._-,1~64) */
-    private static final String SKILL_NAME_RE = "^[A-Za-z0-9一-龥._-]{1,64}$";
-
     /**
      * 专家列表:内置(全局可见)+ 当前用户自建,内置排前。
      *
@@ -497,6 +472,19 @@ public class AgentService extends ServiceImpl<AgentMapper, AgentEntity> {
         }
     }
 
+    /**
+     * 驱逐全部池化实例。
+     *
+     * <p>技能是用户级、且全平台共享的数据:任何一个专家都可能引用了刚被改动的技能,
+     * 所以技能增删改后不能只按 agentRowId 驱逐,必须全清。
+     */
+    public void evictAllAgentPools() {
+        int removed = agentPool.evictAll();
+        if (removed > 0) {
+            log.info("技能变更,驱逐全部 agent 实例池: removed={}", removed);
+        }
+    }
+
     /**
      * 知识库被删除后:解绑所有绑定它的方案(knowledge_base_id 置空)并驱逐池化实例。
      *
@@ -596,15 +584,12 @@ public class AgentService extends ServiceImpl<AgentMapper, AgentEntity> {
         if (skills == null || skills.isEmpty()) {
             return;
         }
-        Path cwd = Paths.get(System.getProperty("user.dir")).toAbsolutePath().normalize();
-        Path skillsRoot = workspaceManagerFactory.resolveUserSkillsPath(cwd, userId);
-        boolean rootExists = Files.isDirectory(skillsRoot);
         for (String name : skills) {
-            if (name == null || !name.matches(SKILL_NAME_RE)) {
+            if (!SkillUtil.isValidSkillName(name)) {
                 throw new ServerException(400, "非法技能名: " + name);
             }
-            // 未安装时拒绝写入白名单(SkillFilter.only 全不命中 = 该专家技能全被藏起来)
-            if (!rootExists || !Files.isDirectory(skillsRoot.resolve(name))) {
+            // 归属行 + 技能本体都在才放行(SkillFilter.only 全不命中 = 该专家技能全被藏起来)
+            if (!userSkillRepositoryProvider.existsForUser(userId, name)) {
                 throw new ServerException(400, "技能不存在或未安装: " + name);
             }
         }

+ 22 - 131
ai-server/src/main/java/com/zsjz/ai/module/agent/service/impl/AgentCapabilityServiceImpl.java

@@ -1,14 +1,10 @@
 package com.zsjz.ai.module.agent.service.impl;
 
 import cn.dev33.satoken.stp.StpUtil;
-import cn.hutool.core.util.StrUtil;
 import com.zsjz.ai.common.exception.ServerException;
-import com.zsjz.ai.module.agent.WorkspaceManagerFactory;
-import com.zsjz.ai.module.agent.config.SkillRepositoryConfigEntry;
-import com.zsjz.ai.module.agent.entity.AgentEntity;
 import com.zsjz.ai.module.agent.service.AgentCapabilityService;
-import com.zsjz.ai.module.agent.service.AgentService;
-import com.zsjz.ai.module.agent.skill.SkillStore;
+import com.zsjz.ai.module.agent.skill.SkillFileEntry;
+import com.zsjz.ai.module.agent.skill.SkillRegistryService;
 import com.zsjz.ai.module.agent.vo.SkillDetailVO;
 import com.zsjz.ai.module.agent.vo.SkillFileContentVO;
 import com.zsjz.ai.module.agent.vo.SkillVO;
@@ -19,73 +15,40 @@ import org.springframework.stereotype.Service;
 import org.springframework.web.multipart.MultipartFile;
 
 import java.io.IOException;
-import java.nio.file.Path;
-import java.nio.file.Paths;
-import java.util.ArrayList;
-import java.util.HashSet;
 import java.util.List;
-import java.util.Map;
-import java.util.Set;
 
 /**
  * 服务端 agent 能力清单与技能管理实现。
  *
  * <h3>技能存哪</h3>
- * {@code {user.dir}/.agentscope/users/{登录用户ID}/skills/} —— 用户级、与案件无关、纯磁盘,
- * 路径口径见 {@link WorkspaceManagerFactory#resolveUserSkillsPath}。与 harness 的工作区同根,
- * 因此写进去即被 agent 加载,不需要注册、不需要落库。
+ * 技能本体在官方 JDBC 技能仓库({@code agentscope_skills} / {@code agentscope_skill_resources}),
+ * 作用域由 namespace(= 登录用户 ID)隔离,启停在 {@code agent_skill_state};都由 {@link SkillRegistryService} 统一读写。
+ * 登录用户维度在这里解析,业务逻辑不感知 sa-token。
  */
 @Slf4j
 @Service
 @RequiredArgsConstructor
 public class AgentCapabilityServiceImpl implements AgentCapabilityService {
 
-    private final SkillStore skillStore;
-
-    private final AgentService agentService;
-
-    private final WorkspaceManagerFactory workspaceManagerFactory;
+    private final SkillRegistryService skillRegistryService;
 
     /* ------------------------------------------------------------------ *
      * 技能
      * ------------------------------------------------------------------ */
 
     @Override
-    public List<SkillVO> listSkills(Long caseId) {
-        List<SkillVO> skills = new ArrayList<>(
-                skillStore.list(userSkillsPath(), SkillStore.SOURCE_USER, true, true));
-        for (Path repoDir : configuredRepoPaths(caseId)) {
-            skills.addAll(skillStore.list(repoDir, SkillStore.SOURCE_REPO, false, false));
-        }
-        return skills;
+    public List<SkillVO> listSkills() {
+        return skillRegistryService.list(currentUserId());
     }
 
     @Override
-    public SkillDetailVO readSkill(String name, Long caseId) {
-        Path userRoot = userSkillsPath();
-        if (skillStore.resolveDir(userRoot, name).isPresent()) {
-            return skillStore.read(userRoot, name, SkillStore.SOURCE_USER, true, true);
-        }
-        for (Path repoDir : configuredRepoPaths(caseId)) {
-            if (skillStore.resolveDir(repoDir, name).isPresent()) {
-                return skillStore.read(repoDir, name, SkillStore.SOURCE_REPO, false, false);
-            }
-        }
-        throw new ServerException(404, "技能不存在: " + name);
+    public SkillDetailVO readSkill(String name) {
+        return skillRegistryService.detail(currentUserId(), name);
     }
 
     @Override
-    public SkillFileContentVO readSkillFile(String name, String relPath, Long caseId) {
-        Path userRoot = userSkillsPath();
-        if (skillStore.resolveDir(userRoot, name).isPresent()) {
-            return skillStore.readFile(userRoot, name, relPath);
-        }
-        for (Path repoDir : configuredRepoPaths(caseId)) {
-            if (skillStore.resolveDir(repoDir, name).isPresent()) {
-                return skillStore.readFile(repoDir, name, relPath);
-            }
-        }
-        throw new ServerException(404, "技能不存在: " + name);
+    public SkillFileContentVO readSkillFile(String name, String path) {
+        return skillRegistryService.readFile(currentUserId(), name, path);
     }
 
     @Override
@@ -99,113 +62,41 @@ public class AgentCapabilityServiceImpl implements AgentCapabilityService {
         } catch (IOException e) {
             throw new ServerException(500, "读取上传文件失败: " + e.getMessage());
         }
-        Path userRoot = userSkillsPath();
-        String name = skillStore.install(bytes, userRoot, overwrite);
-        return skillStore.list(userRoot, SkillStore.SOURCE_USER, true, true).stream()
-                .filter(s -> name.equals(s.getName()))
-                .findFirst()
-                .orElseThrow(() -> new ServerException(500, "技能已写入但读取失败: " + name));
+        return skillRegistryService.install(currentUserId(), bytes, overwrite);
     }
 
     @Override
     public SkillVO createSkill(String name, String description, String content,
-                               List<SkillStore.FileEntry> files, List<String> deletePaths) {
-        Path userRoot = userSkillsPath();
-        skillStore.save(userRoot, name, description, content, files, deletePaths, false);
-        return findUserSkill(userRoot, name);
+                               List<SkillFileEntry> files, List<String> deletePaths) {
+        return skillRegistryService.create(currentUserId(), name, description, content, files);
     }
 
     @Override
     public SkillVO updateSkill(String name, String description, String content,
-                               List<SkillStore.FileEntry> files, List<String> deletePaths) {
-        Path userRoot = userSkillsPath();
-        // 只允许改用户技能:仓库技能只读(与删除的口径一致)
-        if (skillStore.resolveDir(userRoot, name).isEmpty()) {
-            throw new ServerException(404, "用户技能目录里没有这个技能: " + name);
-        }
-        skillStore.save(userRoot, name, description, content, files, deletePaths, true);
-        return findUserSkill(userRoot, name);
-    }
-
-    /** 保存后回读(与 installSkillZip 同一姿势),保证页面拿到的是磁盘上的真实状态 */
-    private SkillVO findUserSkill(Path userRoot, String name) {
-        return skillStore.list(userRoot, SkillStore.SOURCE_USER, true, true).stream()
-                .filter(s -> name.equals(s.getName()))
-                .findFirst()
-                .orElseThrow(() -> new ServerException(500, "技能已保存但读取失败: " + name));
+                               List<SkillFileEntry> files, List<String> deletePaths) {
+        return skillRegistryService.update(currentUserId(), name, description, content, files, deletePaths);
     }
 
     @Override
     public void deleteSkill(String name) {
-        Path userRoot = userSkillsPath();
-        // 只允许删用户技能:配置仓库里的技能由仓库各自维护,页面也不提供卸载入口
-        if (skillStore.resolveDir(userRoot, name).isEmpty()) {
-            throw new ServerException(404, "用户技能目录里没有这个技能: " + name);
-        }
-        skillStore.delete(userRoot, name);
+        skillRegistryService.delete(currentUserId(), name);
     }
 
     @Override
     public void toggleSkill(String name, boolean enabled) {
-        Path userRoot = userSkillsPath();
-        if (skillStore.resolveDir(userRoot, name).isEmpty()) {
-            throw new ServerException(404, "用户技能目录里没有这个技能: " + name);
-        }
-        skillStore.toggle(userRoot, name, enabled);
+        skillRegistryService.toggle(currentUserId(), name, enabled);
     }
 
     @Override
     public SkillFilter disabledSkillFilter(Long userId) {
-        List<String> disabled = skillStore.disabledNames(
-                workspaceManagerFactory.resolveUserSkillsPath(cwd(), userId));
+        List<String> disabled = skillRegistryService.disabledNames(userId);
         if (disabled.isEmpty()) {
             return null;
         }
         return SkillFilter.disable(disabled.toArray(new String[0]));
     }
 
-    /* ------------------------------------------------------------------ *
-     * 路径与配置读取
-     * ------------------------------------------------------------------ */
-
-    /** 当前登录用户的技能目录(按需创建由写入方负责) */
-    private Path userSkillsPath() {
-        return workspaceManagerFactory.resolveUserSkillsPath(cwd(), StpUtil.getLoginIdAsLong());
-    }
-
-    /**
-     * 该案件下 agent 配置的技能仓库目录(只读展示用)。
-     *
-     * <p>只处理 {@code filesystem} 类型:{@code git} 类型会触发 clone/sync(有网络与落盘副作用),
-     * 且 {@code agentscope-extensions-skill-git-repository} 并不在本项目 pom 里,必然失败。
-     */
-    private List<Path> configuredRepoPaths(Long caseId) {
-        if (caseId == null) {
-            return List.of();
-        }
-        AgentEntity entry = agentService.getById(AgentService.DEFAULT_AGENT_ROW_ID);
-        if (entry == null) {
-            return List.of();
-        }
-        List<SkillRepositoryConfigEntry> entries =
-                AgentService.readSkillRepositories(entry.getSkillRepositoriesJson());
-        if (entries == null || entries.isEmpty()) {
-            return List.of();
-        }
-        Path workspace = workspaceManagerFactory.resolveAgentDataPath(
-                cwd(), StpUtil.getLoginIdAsLong(), caseId, AgentService.DEFAULT_AGENT_ROW_ID);
-        List<Path> dirs = new ArrayList<>();
-        for (SkillRepositoryConfigEntry e : entries) {
-            if (e == null || !"filesystem".equalsIgnoreCase(StrUtil.trimToEmpty(e.getType()))
-                    || StrUtil.isBlank(e.getPath())) {
-                continue;
-            }
-            dirs.add(workspace.resolve(e.getPath()).normalize());
-        }
-        return dirs;
-    }
-
-    private static Path cwd() {
-        return Paths.get(System.getProperty("user.dir")).toAbsolutePath().normalize();
+    private Long currentUserId() {
+        return StpUtil.getLoginIdAsLong();
     }
 }

+ 10 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/skill/SkillFileEntry.java

@@ -0,0 +1,10 @@
+package com.zsjz.ai.module.agent.skill;
+
+/**
+ * 技能附属文件写入条目:相对技能目录的路径 + 文本内容。
+ *
+ * <p>只支持文本(scripts/ 里的脚本、references/ 里的文档);
+ * 二进制资源请走 zip 安装,避免 JSON 请求体里塞 base64。
+ */
+public record SkillFileEntry(String path, String content) {
+}

+ 176 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/skill/SkillPackageImporter.java

@@ -0,0 +1,176 @@
+package com.zsjz.ai.module.agent.skill;
+
+import com.zsjz.ai.common.exception.ServerException;
+import io.agentscope.core.skill.AgentSkill;
+import io.agentscope.core.skill.util.SkillFileSystemHelper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.stream.Stream;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipInputStream;
+
+/**
+ * 技能包(zip)导入:安全解压 → 官方 {@link SkillFileSystemHelper} 解析成 {@link AgentSkill}。
+ *
+ * <p><b>为什么不用官方的 {@code SkillUtil.createFromZip}</b>:它逐条读到内存,没有条目数、
+ * 单文件体积、解压总量上限,也没有防 zip 炸弹的措施。这里保留项目原有的三道闸
+ * (条目数 ≤ {@value #MAX_UNZIP_ENTRIES}、解压总量 ≤ {@value #MAX_UNZIP_BYTES} 字节、
+ * 拒绝 {@code ../} 与绝对路径),解压到临时目录后再交给官方加载器 ——
+ * 这样 frontmatter 解析、附属文件收集、二进制 {@code base64:} 约定都与官方一致。
+ *
+ * <p>临时目录用完即删,技能本体由调用方写成 {@link AgentSkill} 存进数据库,不再落盘。
+ */
+@Slf4j
+@Component
+public class SkillPackageImporter {
+
+    /** 技能定义文件名(与 AgentScope 一致) */
+    public static final String SKILL_FILE = "SKILL.md";
+
+    /** 技能来源标识(写入 {@code agentscope_skills.source}) */
+    public static final String SOURCE_USER = "user";
+
+    /** 上传 zip 体积上限(与解压上限同量级,避免把超大包整个读进内存) */
+    private static final long MAX_ZIP_BYTES = 50L * 1024 * 1024;
+
+    /** 解压后总体积上限(防 zip 炸弹) */
+    private static final long MAX_UNZIP_BYTES = 50L * 1024 * 1024;
+
+    /** 条目数上限 */
+    private static final int MAX_UNZIP_ENTRIES = 2000;
+
+    /**
+     * 解析技能包。
+     *
+     * @param zipBytes 上传的 zip 内容
+     * @return 官方模型下的技能(正文 + 附属文件 + frontmatter metadata)
+     */
+    public AgentSkill importZip(byte[] zipBytes) {
+        if (zipBytes == null || zipBytes.length == 0) {
+            throw new ServerException(400, "上传的技能包为空");
+        }
+        if (zipBytes.length > MAX_ZIP_BYTES) {
+            throw new ServerException(400, "技能包体积过大(上限 " + (MAX_ZIP_BYTES / 1024 / 1024) + "MB)");
+        }
+        Path tempDir;
+        try {
+            tempDir = Files.createTempDirectory("skill-install-");
+        } catch (IOException e) {
+            throw new ServerException(500, "创建临时目录失败: " + e.getMessage());
+        }
+        try {
+            unzip(zipBytes, tempDir);
+            Path skillRoot = locateSkillRoot(tempDir);
+            return loadSkill(skillRoot);
+        } catch (IOException e) {
+            throw new ServerException(500, "解压技能包失败: " + e.getMessage());
+        } finally {
+            deleteQuietly(tempDir);
+        }
+    }
+
+    private AgentSkill loadSkill(Path skillRoot) {
+        try {
+            return SkillFileSystemHelper.loadSkillFromDirectory(skillRoot, SOURCE_USER);
+        } catch (IllegalArgumentException e) {
+            // 官方对缺 frontmatter / 缺 name / 缺 description / 空正文都抛 IllegalArgumentException
+            throw new ServerException(400, SKILL_FILE + " 不合法: " + e.getMessage());
+        } catch (RuntimeException e) {
+            throw new ServerException(500, "解析技能失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 定位技能根目录:允许包内直接是技能目录,或多套一层包装目录;嵌套技能只取最外层。
+     */
+    private Path locateSkillRoot(Path tempDir) throws IOException {
+        List<Path> roots = new ArrayList<>();
+        try (Stream<Path> walk = Files.walk(tempDir, 3)) {
+            for (Path p : (Iterable<Path>) walk::iterator) {
+                if (Files.isDirectory(p) && Files.isRegularFile(p.resolve(SKILL_FILE))) {
+                    roots.add(p);
+                }
+            }
+        }
+        List<Path> outer = new ArrayList<>();
+        for (Path p : roots) {
+            if (outer.stream().noneMatch(parent -> p.startsWith(parent))) {
+                outer.add(p);
+            }
+        }
+        if (outer.isEmpty()) {
+            throw new ServerException(400, "压缩包里没有 " + SKILL_FILE + ",不是有效的技能包");
+        }
+        if (outer.size() > 1) {
+            throw new ServerException(400,
+                    "一个压缩包只能包含一个技能(发现 " + outer.size() + " 个 " + SKILL_FILE + ")");
+        }
+        return outer.get(0);
+    }
+
+    /**
+     * 解压到目标目录:路径穿越必须在这里挡住(条目名里的 {@code ../} 或绝对路径会落到目录外)。
+     */
+    private void unzip(byte[] zipBytes, Path targetDir) throws IOException {
+        long total = 0;
+        int entries = 0;
+        byte[] buffer = new byte[8192];
+        try (InputStream in = new ByteArrayInputStream(zipBytes);
+             ZipInputStream zis = new ZipInputStream(in, StandardCharsets.UTF_8)) {
+            ZipEntry entry;
+            while ((entry = zis.getNextEntry()) != null) {
+                if (entry.isDirectory()) {
+                    continue;
+                }
+                entries++;
+                if (entries > MAX_UNZIP_ENTRIES) {
+                    throw new ServerException(400, "压缩包条目过多,已中止");
+                }
+                Path out = targetDir.resolve(entry.getName()).normalize();
+                if (!out.startsWith(targetDir.normalize())) {
+                    throw new ServerException(400, "压缩包包含非法路径: " + entry.getName());
+                }
+                Files.createDirectories(out.getParent());
+                long written = 0;
+                try (var os = Files.newOutputStream(out)) {
+                    int len;
+                    while ((len = zis.read(buffer)) > 0) {
+                        written += len;
+                        total += len;
+                        if (total > MAX_UNZIP_BYTES) {
+                            throw new ServerException(400, "压缩包解压后体积过大,已中止");
+                        }
+                        os.write(buffer, 0, len);
+                    }
+                }
+                if (written == 0) {
+                    Files.write(out, new byte[0]);
+                }
+            }
+        }
+    }
+
+    private static void deleteQuietly(Path dir) {
+        try (Stream<Path> walk = Files.walk(dir)) {
+            walk.sorted(Comparator.reverseOrder()).forEach(p -> {
+                try {
+                    Files.deleteIfExists(p);
+                } catch (IOException ignored) {
+                    // 临时目录清理失败不影响主流程
+                }
+            });
+        } catch (IOException e) {
+            log.debug("清理技能临时目录失败: {}", dir, e);
+        }
+    }
+}

+ 442 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/skill/SkillRegistryService.java

@@ -0,0 +1,442 @@
+package com.zsjz.ai.module.agent.skill;
+
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.zsjz.ai.common.exception.ServerException;
+import com.zsjz.ai.module.agent.entity.AgentSkillStateEntity;
+import com.zsjz.ai.module.agent.mapper.AgentSkillStateMapper;
+import com.zsjz.ai.module.agent.service.AgentService;
+import com.zsjz.ai.module.agent.vo.SkillDetailVO;
+import com.zsjz.ai.module.agent.vo.SkillFileContentVO;
+import com.zsjz.ai.module.agent.vo.SkillVO;
+import io.agentscope.core.skill.AgentSkill;
+import io.agentscope.core.skill.util.MarkdownSkillParser;
+import io.agentscope.core.skill.util.SkillUtil;
+import io.agentscope.extensions.jdbc.skill.JdbcAgentSkillRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+
+import java.nio.charset.StandardCharsets;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 技能管理:官方 JDBC 技能仓库(按 namespace 隔离)+ 自建启停表。
+ *
+ * <h3>作用域</h3>
+ * 官方表用 {@code namespace} 做作用域隔离,唯一键 {@code UNIQUE(namespace, name)}:
+ * 同一 namespace 内名字唯一,<b>跨 namespace 可以重名</b>。本项目一个用户一个 namespace
+ * ({@link UserSkillRepositoryProvider#namespaceOf} = 登录用户 ID 字符串),因此:
+ * <ul>
+ *   <li>两个用户可以各自拥有同名技能,互不影响;</li>
+ *   <li>列表/详情/文件读写只查自己的 namespace,别人的技能天然不可见;</li>
+ *   <li>agent 挂载用绑定同一 namespace 的只读实例,不需要再套过滤层。</li>
+ * </ul>
+ *
+ * <h3>启停</h3>
+ * 官方仓库没有启停概念,{@code enabled} 记在 {@code agent_skill_state}(按 user_id + skill_name 唯一)。
+ * 停用是每轮对话构造 {@code RuntimeContext} 时通过 {@code SkillFilter} overlay 生效的,
+ * 所以只有增删改/安装需要驱逐实例池,启停不需要。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class SkillRegistryService {
+
+    /** 单个附属文件的预览上限:超过只回体积,不下发内容 */
+    private static final long MAX_PREVIEW_BYTES = 2L * 1024 * 1024;
+
+    /** 官方约定的二进制资源前缀:{@code base64:<payload>} */
+    private static final String BASE64_PREFIX = "base64:";
+
+    /** 管理端用的可写仓库:绑定默认 namespace,实际调用都显式传 namespace */
+    private final JdbcAgentSkillRepository agentSkillRepository;
+
+    private final AgentSkillStateMapper skillStateMapper;
+
+    private final SkillPackageImporter packageImporter;
+
+    private final AgentService agentService;
+
+    /* ------------------------------------------------------------------ *
+     * 查询
+     * ------------------------------------------------------------------ */
+
+    /**
+     * 当前用户 namespace 下的技能清单(按名字排序)。
+     */
+    public List<SkillVO> list(Long userId) {
+        String namespace = namespaceOf(userId);
+        Map<String, Boolean> enabled = enabledMap(userId);
+        List<SkillVO> result = new ArrayList<>();
+        for (AgentSkill skill : agentSkillRepository.getAllSkills(namespace)) {
+            result.add(toVO(skill, enabled.getOrDefault(skill.getName(), Boolean.TRUE)));
+        }
+        result.sort(Comparator.comparing(SkillVO::getName, Comparator.nullsLast(String::compareTo)));
+        return result;
+    }
+
+    /**
+     * 技能详情:完整 SKILL.md(frontmatter + 正文)+ 附属文件清单。
+     */
+    public SkillDetailVO detail(Long userId, String name) {
+        AgentSkill skill = loadSkill(userId, name);
+
+        SkillDetailVO vo = new SkillDetailVO();
+        vo.setName(skill.getName());
+        vo.setDescription(skill.getDescription());
+        vo.setSource(SkillPackageImporter.SOURCE_USER);
+        vo.setManaged(true);
+        vo.setEnabled(enabledMap(userId).getOrDefault(name, Boolean.TRUE));
+        // 官方模型里正文与 frontmatter 分开存,回给编辑器时拼回完整 SKILL.md,
+        // 与「content 就是 SKILL.md 原文」的历史契约保持一致
+        vo.setContent(MarkdownSkillParser.generate(skill.getMetadata(), skill.getSkillContent()));
+
+        List<SkillDetailVO.FileItem> files = new ArrayList<>();
+        skill.getResources().forEach((path, raw) -> {
+            SkillDetailVO.FileItem item = new SkillDetailVO.FileItem();
+            item.setPath(path);
+            item.setSize(resourceSize(raw));
+            files.add(item);
+        });
+        files.sort(Comparator.comparing(SkillDetailVO.FileItem::getPath));
+        vo.setFiles(files);
+        return vo;
+    }
+
+    /**
+     * 技能内单个文件的内容:文本返回正文,二进制返回 base64,超过预览上限只回体积。
+     */
+    public SkillFileContentVO readFile(Long userId, String name, String relPath) {
+        AgentSkill skill = loadSkill(userId, name);
+        String rel = normalizeRel(relPath);
+        if (rel.isEmpty()) {
+            throw new ServerException(400, "文件路径不能为空");
+        }
+        String raw = skill.getResource(rel);
+        if (raw == null) {
+            throw new ServerException(404, "文件不存在: " + relPath);
+        }
+
+        SkillFileContentVO vo = new SkillFileContentVO();
+        vo.setPath(rel);
+        vo.setBinary(false);
+        vo.setTruncated(false);
+
+        if (raw.startsWith(BASE64_PREFIX)) {
+            String payload = raw.substring(BASE64_PREFIX.length());
+            byte[] bytes = decodeQuietly(payload);
+            vo.setSize(bytes == null ? (long) payload.length() : bytes.length);
+            vo.setBinary(true);
+            if (bytes != null && bytes.length > MAX_PREVIEW_BYTES) {
+                vo.setTruncated(true);
+            } else {
+                vo.setBase64(payload);
+            }
+            return vo;
+        }
+
+        long size = raw.getBytes(StandardCharsets.UTF_8).length;
+        vo.setSize(size);
+        if (size > MAX_PREVIEW_BYTES) {
+            vo.setBinary(true);
+            vo.setTruncated(true);
+        } else {
+            vo.setContent(raw);
+        }
+        return vo;
+    }
+
+    /**
+     * 该用户停用的技能名(喂给每轮的 {@code SkillFilter} overlay)。
+     */
+    public List<String> disabledNames(Long userId) {
+        if (userId == null) {
+            return List.of();
+        }
+        return skillStateMapper
+                .selectList(Wrappers.<AgentSkillStateEntity>lambdaQuery()
+                        .eq(AgentSkillStateEntity::getUserId, userId)
+                        .eq(AgentSkillStateEntity::getEnabled, false))
+                .stream()
+                .map(AgentSkillStateEntity::getSkillName)
+                .sorted()
+                .toList();
+    }
+
+    /* ------------------------------------------------------------------ *
+     * 写
+     * ------------------------------------------------------------------ */
+
+    /**
+     * 新建技能:名字在该用户的 namespace 内唯一(不同用户之间可以重名)。
+     */
+    public SkillVO create(Long userId, String name, String description, String content,
+                          List<SkillFileEntry> files) {
+        String namespace = namespaceOf(userId);
+        String skillName = requireValidName(name);
+        if (agentSkillRepository.skillExists(namespace, skillName)) {
+            throw new ServerException(400, "同名技能已存在: " + skillName);
+        }
+        AgentSkill skill = buildSkill(skillName, description, content, files, Map.of());
+        saveSkill(namespace, skill, false);
+        writeState(userId, skillName, true);
+        evictPools();
+        return toVO(skill, Boolean.TRUE);
+    }
+
+    /**
+     * 修改技能:技能名不可变(改名等于新建 + 删除,不在本方法职责内)。
+     */
+    public SkillVO update(Long userId, String name, String description, String content,
+                          List<SkillFileEntry> files, List<String> deletePaths) {
+        String namespace = namespaceOf(userId);
+        AgentSkill existing = loadSkill(userId, name);
+
+        // 未提交的文件保留(与历史行为一致),显式 deletePaths 才删
+        Map<String, String> resources = new LinkedHashMap<>(existing.getResources());
+        if (deletePaths != null) {
+            for (String path : deletePaths) {
+                resources.remove(requireSafeRelPath(path));
+            }
+        }
+        AgentSkill skill = buildSkill(name, description, content, files, resources);
+        saveSkill(namespace, skill, true);
+        boolean enabled = enabledMap(userId).getOrDefault(name, Boolean.TRUE);
+        // 状态行可能不存在(例如技能是直接写官方表导入的),补一条保持口径一致
+        writeState(userId, name, enabled);
+        evictPools();
+        return toVO(skill, enabled);
+    }
+
+    /**
+     * 安装技能包(zip):同名时只有 overwrite=true 才覆盖(同一 namespace 内)。
+     */
+    public SkillVO install(Long userId, byte[] zipBytes, boolean overwrite) {
+        String namespace = namespaceOf(userId);
+        AgentSkill skill = packageImporter.importZip(zipBytes);
+        String skillName = requireValidName(skill.getName());
+        boolean exists = agentSkillRepository.skillExists(namespace, skillName);
+        if (exists && !overwrite) {
+            throw new ServerException(400, "同名技能已存在: " + skillName + "(如需替换请勾选覆盖)");
+        }
+        saveSkill(namespace, skill, exists);
+        writeState(userId, skillName, true);
+        evictPools();
+        return toVO(skill, Boolean.TRUE);
+    }
+
+    /**
+     * 卸载技能:只在自己 namespace 内删,技能本体与启停行一起清。
+     */
+    public void delete(Long userId, String name) {
+        String namespace = namespaceOf(userId);
+        loadSkill(userId, name);
+        if (!agentSkillRepository.delete(namespace, name)) {
+            throw new ServerException(404, "技能不存在: " + name);
+        }
+        skillStateMapper.delete(Wrappers.<AgentSkillStateEntity>lambdaQuery()
+                .eq(AgentSkillStateEntity::getUserId, userId)
+                .eq(AgentSkillStateEntity::getSkillName, name));
+        evictPools();
+        log.info("技能已卸载: name={}, userId={}", name, userId);
+    }
+
+    /**
+     * 启停技能:只改自己的状态行,不驱逐实例池(每轮对话用 overlay 生效)。
+     */
+    public void toggle(Long userId, String name, boolean enabled) {
+        loadSkill(userId, name);
+        writeState(userId, name, enabled);
+        log.info("技能启停: name={}, userId={}, enabled={}", name, userId, enabled);
+    }
+
+    /* ------------------------------------------------------------------ *
+     * 内部
+     * ------------------------------------------------------------------ */
+
+    /**
+     * 组装 {@link AgentSkill}:正文里若自带 frontmatter 先解析出来,再用显式字段覆盖
+     * name/description(页面表单是权威来源),最后按官方格式拼回完整 SKILL.md。
+     */
+    private AgentSkill buildSkill(String name, String description, String content,
+                                  List<SkillFileEntry> files, Map<String, String> baseResources) {
+        Map<String, String> resources = new LinkedHashMap<>(baseResources);
+        if (files != null) {
+            for (SkillFileEntry file : files) {
+                if (file == null || !StringUtils.hasText(file.path())) {
+                    continue;
+                }
+                String rel = requireSafeRelPath(file.path());
+                if (SkillPackageImporter.SKILL_FILE.equalsIgnoreCase(rel)) {
+                    throw new ServerException(400, "SKILL.md 正文请用 content 字段提交,不能作为附属文件");
+                }
+                resources.put(rel, file.content() == null ? "" : file.content());
+            }
+        }
+
+        MarkdownSkillParser.ParsedMarkdown parsed =
+                MarkdownSkillParser.parse(content == null ? "" : content);
+        Map<String, Object> metadata = new LinkedHashMap<>(parsed.getMetadata());
+        metadata.put("name", name);
+        String desc = StringUtils.hasText(description)
+                ? description
+                : String.valueOf(metadata.getOrDefault("description", name));
+        metadata.put("description", desc);
+
+        String skillMd = MarkdownSkillParser.generate(metadata, parsed.getContent());
+        try {
+            return SkillUtil.createFrom(skillMd, resources, SkillPackageImporter.SOURCE_USER);
+        } catch (IllegalArgumentException e) {
+            throw new ServerException(400, "技能定义不合法: " + e.getMessage());
+        }
+    }
+
+    private void saveSkill(String namespace, AgentSkill skill, boolean force) {
+        try {
+            agentSkillRepository.save(namespace, List.of(skill), force);
+        } catch (RuntimeException e) {
+            // 官方实现在冲突/写库失败时抛运行时异常,统一转成业务错误
+            throw new ServerException(400, "保存技能失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 写/更新启停状态行:没有就插入,有就更新 enabled。
+     */
+    private void writeState(Long userId, String skillName, boolean enabled) {
+        AgentSkillStateEntity existing = findState(userId, skillName);
+        LocalDateTime now = LocalDateTime.now();
+        if (existing == null) {
+            AgentSkillStateEntity entity = new AgentSkillStateEntity();
+            entity.setUserId(userId);
+            entity.setSkillName(skillName);
+            entity.setEnabled(enabled);
+            entity.setCreateAt(now);
+            entity.setUpdateAt(now);
+            skillStateMapper.insert(entity);
+            return;
+        }
+        AgentSkillStateEntity update = new AgentSkillStateEntity();
+        update.setId(existing.getId());
+        update.setEnabled(enabled);
+        update.setUpdateAt(now);
+        skillStateMapper.updateById(update);
+    }
+
+    private AgentSkillStateEntity findState(Long userId, String skillName) {
+        return skillStateMapper.selectOne(Wrappers.<AgentSkillStateEntity>lambdaQuery()
+                .eq(AgentSkillStateEntity::getUserId, userId)
+                .eq(AgentSkillStateEntity::getSkillName, skillName)
+                .last("limit 1"));
+    }
+
+    /**
+     * 当前用户的技能名 → 是否启用。没有状态行的技能视为启用。
+     */
+    private Map<String, Boolean> enabledMap(Long userId) {
+        Map<String, Boolean> map = new LinkedHashMap<>();
+        if (userId == null) {
+            return map;
+        }
+        skillStateMapper
+                .selectList(Wrappers.<AgentSkillStateEntity>lambdaQuery()
+                        .eq(AgentSkillStateEntity::getUserId, userId))
+                .forEach(state -> map.put(state.getSkillName(),
+                        state.getEnabled() == null || Boolean.TRUE.equals(state.getEnabled())));
+        return map;
+    }
+
+    private AgentSkill loadSkill(Long userId, String name) {
+        if (name == null) {
+            throw new ServerException(404, "技能不存在: null");
+        }
+        try {
+            return agentSkillRepository.getSkill(namespaceOf(userId), name);
+        } catch (IllegalArgumentException e) {
+            throw new ServerException(404, "技能不存在: " + name);
+        } catch (RuntimeException e) {
+            throw new ServerException(500, "读取技能失败: " + e.getMessage());
+        }
+    }
+
+    private void evictPools() {
+        // 技能是用户级共享数据:任何一个专家的池化实例都可能加载了旧技能
+        agentService.evictAllAgentPools();
+    }
+
+    private SkillVO toVO(AgentSkill skill, boolean enabled) {
+        SkillVO vo = new SkillVO();
+        vo.setName(skill.getName());
+        vo.setDescription(skill.getDescription());
+        vo.setSource(SkillPackageImporter.SOURCE_USER);
+        vo.setManaged(true);
+        vo.setEnabled(enabled);
+        return vo;
+    }
+
+    private static String namespaceOf(Long userId) {
+        if (userId == null) {
+            throw new ServerException(401, "未登录");
+        }
+        return UserSkillRepositoryProvider.namespaceOf(userId);
+    }
+
+    private static long resourceSize(String raw) {
+        if (raw == null) {
+            return 0L;
+        }
+        if (raw.startsWith(BASE64_PREFIX)) {
+            byte[] bytes = decodeQuietly(raw.substring(BASE64_PREFIX.length()));
+            return bytes == null ? raw.length() - BASE64_PREFIX.length() : bytes.length;
+        }
+        return raw.getBytes(StandardCharsets.UTF_8).length;
+    }
+
+    private static byte[] decodeQuietly(String payload) {
+        try {
+            return Base64.getDecoder().decode(payload);
+        } catch (IllegalArgumentException e) {
+            return null;
+        }
+    }
+
+    /**
+     * 技能名校验直接用官方口径({@link SkillUtil#isValidSkillName}):
+     * 小写字母/数字开头,后面只能是小写字母、数字、短横线,最多 64 字符。
+     */
+    private static String requireValidName(String name) {
+        String trimmed = name == null ? "" : name.trim();
+        if (!SkillUtil.isValidSkillName(trimmed)) {
+            throw new ServerException(400,
+                    "技能名非法: " + name + "(只允许小写字母、数字和短横线,以字母或数字开头,长度 1~64)");
+        }
+        return trimmed;
+    }
+
+    /**
+     * 附属文件路径:统一正斜杠,去掉首尾空白,挡绝对路径与 {@code ..}(路径来自页面输入,按不可信处理)。
+     */
+    private static String requireSafeRelPath(String path) {
+        String rel = normalizeRel(path);
+        if (rel.isEmpty() || rel.startsWith("/") || rel.matches("^[A-Za-z]:.*")) {
+            throw new ServerException(400, "附属文件路径非法: " + path);
+        }
+        for (String seg : rel.split("/")) {
+            if ("..".equals(seg)) {
+                throw new ServerException(400, "附属文件路径非法: " + path);
+            }
+        }
+        return rel;
+    }
+
+    private static String normalizeRel(String path) {
+        return path == null ? "" : path.strip().replace('\\', '/');
+    }
+}

+ 0 - 687
ai-server/src/main/java/com/zsjz/ai/module/agent/skill/SkillStore.java

@@ -1,687 +0,0 @@
-package com.zsjz.ai.module.agent.skill;
-
-import com.zsjz.ai.common.exception.ServerException;
-import com.zsjz.ai.module.agent.vo.SkillDetailVO;
-import com.zsjz.ai.module.agent.vo.SkillFileContentVO;
-import com.zsjz.ai.module.agent.vo.SkillVO;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.stereotype.Component;
-
-import java.io.ByteArrayInputStream;
-import java.io.IOException;
-import java.io.InputStream;
-import java.nio.ByteBuffer;
-import java.nio.charset.CharacterCodingException;
-import java.nio.charset.CodingErrorAction;
-import java.nio.charset.StandardCharsets;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.util.ArrayList;
-import java.util.Base64;
-import java.util.Comparator;
-import java.util.List;
-import java.util.Optional;
-import java.util.stream.Stream;
-import java.util.zip.ZipEntry;
-import java.util.zip.ZipInputStream;
-
-/**
- * 技能文件仓库:技能在磁盘上的读写实现(<b>纯文件系统操作,不碰数据库</b>)。
- *
- * <h3>目录约定</h3>
- * <pre>
- * {skillsRoot}/
- * ├── 技能名/SKILL.md          必需:YAML frontmatter(name + description)+ 正文
- * ├── 技能名/references/…      可选:附属文件
- * ├── 技能名/.disabled         存在 = 该技能已停用(由本类维护)
- * └── 其他目录/                没有 SKILL.md 的目录一律不算技能
- * </pre>
- *
- * <p>与 AgentScope 的 {@code FileSystemSkillRepository} 同构(同样是「一层子目录 + SKILL.md」),
- * 因此把技能直接写进目录即可被 agent 加载,无需注册、无需落库。
- *
- * <h3>为什么单独成类</h3>
- * 这些方法只依赖一个 {@code skillsRoot} 路径,不依赖登录态、数据库与 Spring 上下文,
- * 因此可以直接用临时目录做单元测试(见 {@code SkillStoreTest})。
- */
-@Slf4j
-@Component
-public class SkillStore {
-
-    /** 技能定义文件名(与 AgentScope 一致) */
-    public static final String SKILL_FILE = "SKILL.md";
-
-    /** 停用标记文件名:存在即停用 */
-    public static final String DISABLED_MARKER = ".disabled";
-
-    /** 技能名允许的字符:中英文、数字、下划线、短横线、点(不允许路径分隔符,避免越界) */
-    private static final String NAME_PATTERN = "[\\p{IsHan}A-Za-z0-9._-]{1,64}";
-
-    /** 单个技能包解压后的体积上限(防 zip 炸弹) */
-    private static final long MAX_UNZIP_BYTES = 50L * 1024 * 1024;
-
-    /** 单个技能包的条目数上限 */
-    private static final int MAX_UNZIP_ENTRIES = 2000;
-
-    /** frontmatter 解析的读取上限:技能定义是说明文档,超过这个体积的多半不是技能 */
-    private static final long MAX_SKILL_MD_BYTES = 1024 * 1024;
-
-    /** 单个附属文件的预览上限:超过只回体积,不下发内容(图片预览的 base64 也受此约束) */
-    private static final long MAX_PREVIEW_BYTES = 2L * 1024 * 1024;
-
-    /** 技能来源:用户技能目录(页面可管理) */
-    public static final String SOURCE_USER = "user";
-
-    /** 技能来源:agent 配置的技能仓库(只读) */
-    public static final String SOURCE_REPO = "repo";
-
-    /**
-     * 附属文件写入条目:相对技能目录的路径 + 文本内容。
-     *
-     * <p>只支持文本(scripts/references/testcases 里的脚本与文档,以及文本类 assets);
-     * 二进制资源请走 zip 安装,避免 JSON 里塞 base64 把请求体撑爆。
-     */
-    public record FileEntry(String path, String content) {
-    }
-
-    /**
-     * 列出目录下的全部技能。
-     *
-     * @param skillsRoot  技能根目录(不存在时返回空列表,不抛异常)
-     * @param source      来源标签,写入返回的 VO
-     * @param managed     是否可管理(可卸载 / 可启停)
-     * @param markerIsOff 停用标记是否生效(配置仓库只读,标记不由我们维护)
-     */
-    public List<SkillVO> list(Path skillsRoot, String source, boolean managed, boolean markerIsOff) {
-        List<SkillVO> result = new ArrayList<>();
-        if (skillsRoot == null || !Files.isDirectory(skillsRoot)) {
-            return result;
-        }
-        try (Stream<Path> dirs = Files.list(skillsRoot)) {
-            for (Path dir : (Iterable<Path>) dirs.sorted()::iterator) {
-                Path skillMd = dir.resolve(SKILL_FILE);
-                if (!Files.isDirectory(dir) || !Files.isRegularFile(skillMd)) {
-                    continue;
-                }
-                Meta meta = readMeta(skillMd);
-                SkillVO vo = new SkillVO();
-                vo.setName(meta.name != null ? meta.name : dir.getFileName().toString());
-                vo.setDescription(meta.description);
-                vo.setSource(source);
-                vo.setManaged(managed);
-                vo.setEnabled(!(markerIsOff && Files.exists(dir.resolve(DISABLED_MARKER))));
-                vo.setPath(dir.toAbsolutePath().toString());
-                result.add(vo);
-            }
-        } catch (IOException e) {
-            throw new ServerException(500, "读取技能目录失败: " + e.getMessage());
-        }
-        result.sort(Comparator.comparing(SkillVO::getName, Comparator.nullsLast(String::compareTo)));
-        return result;
-    }
-
-    /**
-     * 读取技能详情(SKILL.md 正文 + 附属文件清单)。
-     */
-    public SkillDetailVO read(Path skillsRoot, String name, String source, boolean managed, boolean markerIsOff) {
-        Path dir = resolveDir(skillsRoot, name)
-                .orElseThrow(() -> new ServerException(404, "技能不存在: " + name));
-        Path skillMd = dir.resolve(SKILL_FILE);
-        Meta meta = readMeta(skillMd);
-
-        SkillDetailVO vo = new SkillDetailVO();
-        vo.setName(meta.name != null ? meta.name : dir.getFileName().toString());
-        vo.setDescription(meta.description);
-        vo.setSource(source);
-        vo.setManaged(managed);
-        vo.setEnabled(!(markerIsOff && Files.exists(dir.resolve(DISABLED_MARKER))));
-        vo.setPath(dir.toAbsolutePath().toString());
-        vo.setContent(readText(skillMd));
-        vo.setFiles(listFiles(dir));
-        return vo;
-    }
-
-    /**
-     * 读取技能内单个文件的内容 —— 技能详情文件树点开节点时用。
-     *
-     * <p>文本文件返回 {@code content};二进制(图片、压缩包等)返回 {@code base64} 供前端预览;
-     * 超过 {@value #MAX_PREVIEW_BYTES} 字节只回体积({@code truncated=true}),避免详情接口被大文件拖垮。
-     * 路径按不可信输入处理,挡住 {@code ..} 与绝对路径。
-     */
-    public SkillFileContentVO readFile(Path skillsRoot, String name, String relPath) {
-        Path dir = resolveDir(skillsRoot, name)
-                .orElseThrow(() -> new ServerException(404, "技能不存在: " + name));
-        if (relPath == null || relPath.isBlank()) {
-            throw new ServerException(400, "文件路径不能为空");
-        }
-        Path file = resolveInside(dir, relPath);
-        if (!Files.isRegularFile(file)) {
-            throw new ServerException(404, "文件不存在: " + relPath);
-        }
-
-        SkillFileContentVO vo = new SkillFileContentVO();
-        vo.setPath(dir.relativize(file).toString().replace('\\', '/'));
-        vo.setBinary(false);
-        vo.setTruncated(false);
-        try {
-            long size = Files.size(file);
-            vo.setSize(size);
-            if (size > MAX_PREVIEW_BYTES) {
-                vo.setBinary(true);
-                vo.setTruncated(true);
-                return vo;
-            }
-            byte[] bytes = Files.readAllBytes(file);
-            String text = decodeUtf8(bytes);
-            if (text != null) {
-                vo.setContent(text);
-            } else {
-                vo.setBinary(true);
-                vo.setBase64(Base64.getEncoder().encodeToString(bytes));
-            }
-            return vo;
-        } catch (IOException e) {
-            throw new ServerException(500, "读取文件失败: " + e.getMessage());
-        }
-    }
-
-    /**
-     * 安装一个技能包(zip 字节);返回安装后的技能名。
-     *
-     * <p>校验顺序:解压到临时目录 → 定位唯一技能根(含 SKILL.md 的目录)→ 校验技能名
-     * → 落到 {@code {skillsRoot}/{技能名}}。同名技能存在时,只有 {@code overwrite=true} 才覆盖。
-     *
-     * @param zipBytes  zip 包内容(由调用方从上传流读出,避免本类依赖 web 层)
-     * @param skillsRoot 用户技能根目录(按需创建)
-     * @param overwrite  同名技能是否覆盖
-     */
-    public String install(byte[] zipBytes, Path skillsRoot, boolean overwrite) {
-        if (zipBytes == null || zipBytes.length == 0) {
-            throw new ServerException(400, "上传的技能包为空");
-        }
-        Path tempDir;
-        try {
-            tempDir = Files.createTempDirectory("skill-install-");
-        } catch (IOException e) {
-            throw new ServerException(500, "创建临时目录失败: " + e.getMessage());
-        }
-        try {
-            unzip(zipBytes, tempDir);
-            List<Path> roots = findSkillRoots(tempDir);
-            if (roots.isEmpty()) {
-                throw new ServerException(400, "压缩包里没有 " + SKILL_FILE + ",不是有效的技能包");
-            }
-            if (roots.size() > 1) {
-                throw new ServerException(400,
-                        "一个压缩包只能包含一个技能(发现 " + roots.size() + " 个 " + SKILL_FILE + ")");
-            }
-            Path skillRoot = roots.get(0);
-            Path skillMd = skillRoot.resolve(SKILL_FILE);
-            Meta meta = readMeta(skillMd);
-            String name = meta.name != null ? meta.name : skillRoot.getFileName().toString();
-            validateName(name);
-
-            Files.createDirectories(skillsRoot);
-            Path target = skillsRoot.resolve(name).normalize();
-            if (!target.startsWith(skillsRoot.normalize())) {
-                throw new ServerException(400, "技能名非法: " + name);
-            }
-            if (Files.exists(target)) {
-                if (!overwrite) {
-                    throw new ServerException(400, "同名技能已存在: " + name + "(如需替换请勾选覆盖)");
-                }
-                deleteDirectory(target);
-            }
-            copyDirectory(skillRoot, target);
-            log.info("技能已安装: {} -> {}", name, target);
-            return name;
-        } catch (IOException e) {
-            throw new ServerException(500, "安装技能失败: " + e.getMessage());
-        } finally {
-            deleteQuietly(tempDir);
-        }
-    }
-
-    /**
-     * 新建/覆盖一个技能:直接写 {@code SKILL.md}(frontmatter name/description + 正文),
-     * 免去打 zip 包。供「专家弹窗 → 添加/修改技能」使用。
-     *
-     * @param overwrite false = 同名已存在则报错(新建);true = 覆盖(修改)
-     * @return 技能名
-     */
-    public String save(Path skillsRoot, String name, String description, String content, boolean overwrite) {
-        return save(skillsRoot, name, description, content, null, null, overwrite);
-    }
-
-    /**
-     * 新建/覆盖一个技能(含附属文件)—— 「插件与技能」页新增/编辑技能的落地方法。
-     *
-     * <p>覆盖({@code overwrite=true})时<b>不删除技能目录</b>:只重写 SKILL.md、写入本次提交的
-     * 附属文件、删除 {@code deletePaths} 指定的文件。这样页面没动过的文件(例如 assets 里的图片,
-     * 二进制不便在前端往返)会被原样保留 —— 旧实现整目录删重建,编辑一次就会把附属文件全丢掉。
-     *
-     * @param files       要写入(或覆盖)的附属文件,可为空
-     * @param deletePaths 要删除的附属文件相对路径,可为空
-     * @param overwrite   false = 同名已存在则报错(新建);true = 覆盖(编辑)
-     * @return 技能名
-     */
-    public String save(Path skillsRoot, String name, String description, String content,
-                       List<FileEntry> files, List<String> deletePaths, boolean overwrite) {
-        validateName(name);
-        if (content == null || content.isBlank()) {
-            throw new ServerException(400, "技能正文不能为空");
-        }
-        try {
-            Files.createDirectories(skillsRoot);
-            Path dir = skillsRoot.resolve(name).normalize();
-            if (!dir.startsWith(skillsRoot.normalize())) {
-                throw new ServerException(400, "技能名非法: " + name);
-            }
-            if (Files.exists(dir) && !overwrite) {
-                throw new ServerException(400, "同名技能已存在: " + name);
-            }
-            Files.createDirectories(dir);
-            Files.writeString(dir.resolve(SKILL_FILE),
-                    buildSkillMd(name, description, content), StandardCharsets.UTF_8);
-
-            // 先删后写:同一路径既在 deletePaths 又在 files 时以 files 为准
-            for (String rel : nullToEmpty(deletePaths)) {
-                if (rel == null || rel.isBlank() || SKILL_FILE.equals(normalizeRel(rel))) {
-                    continue;
-                }
-                Files.deleteIfExists(resolveInside(dir, rel));
-            }
-            for (FileEntry entry : nullToEmpty(files)) {
-                if (entry == null || entry.path() == null || entry.path().isBlank()) {
-                    continue;
-                }
-                if (SKILL_FILE.equals(normalizeRel(entry.path()))) {
-                    throw new ServerException(400, "SKILL.md 请用正文编辑,不能作为附属文件提交");
-                }
-                Path target = resolveInside(dir, entry.path());
-                Files.createDirectories(target.getParent());
-                Files.writeString(target, entry.content() == null ? "" : entry.content(),
-                        StandardCharsets.UTF_8);
-            }
-            pruneEmptyDirs(dir);
-            log.info("技能已{}: {} -> {}(附属文件 {} 个)", overwrite ? "保存" : "创建", name, dir,
-                    files == null ? 0 : files.size());
-            return name;
-        } catch (IOException e) {
-            throw new ServerException(500, "保存技能失败: " + e.getMessage());
-        }
-    }
-
-    /**
-     * 组装 SKILL.md:frontmatter(name + description)+ 正文。
-     * 正文若自带 frontmatter(用户粘贴完整 SKILL.md)则原样保留。
-     */
-    private static String buildSkillMd(String name, String description, String content) {
-        String body = content.strip();
-        if (body.startsWith("---")) {
-            return body;
-        }
-        String desc = description == null ? ""
-                : description.strip().replace("\r", " ").replace("\n", " ");
-        // description 统一用双引号包裹并转义,避免 YAML 特殊字符(: # 等)破坏 frontmatter
-        String quoted = "\"" + desc.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
-        return "---\nname: " + name + "\ndescription: " + quoted
-                + "\n---\n\n" + body + "\n";
-    }
-
-    /**
-     * 卸载技能(删除技能目录)。
-     */
-    public void delete(Path skillsRoot, String name) {
-        Path dir = resolveDir(skillsRoot, name)
-                .orElseThrow(() -> new ServerException(404, "技能不存在: " + name));
-        try {
-            deleteDirectory(dir);
-            log.info("技能已卸载: {} ({})", name, dir);
-        } catch (IOException e) {
-            throw new ServerException(500, "卸载技能失败: " + e.getMessage());
-        }
-    }
-
-    /**
-     * 启停技能:启用 = 删除 {@value #DISABLED_MARKER} 标记,停用 = 创建标记。
-     */
-    public void toggle(Path skillsRoot, String name, boolean enabled) {
-        Path dir = resolveDir(skillsRoot, name)
-                .orElseThrow(() -> new ServerException(404, "技能不存在: " + name));
-        Path marker = dir.resolve(DISABLED_MARKER);
-        try {
-            if (enabled) {
-                Files.deleteIfExists(marker);
-            } else if (!Files.exists(marker)) {
-                Files.createFile(marker);
-            }
-        } catch (IOException e) {
-            throw new ServerException(500, "更新技能状态失败: " + e.getMessage());
-        }
-    }
-
-    /**
-     * 已停用的技能名(取 frontmatter 的 name,缺失时退回目录名)——供对话链路挂技能过滤器。
-     */
-    public List<String> disabledNames(Path skillsRoot) {
-        List<String> names = new ArrayList<>();
-        if (skillsRoot == null || !Files.isDirectory(skillsRoot)) {
-            return names;
-        }
-        try (Stream<Path> dirs = Files.list(skillsRoot)) {
-            for (Path dir : (Iterable<Path>) dirs::iterator) {
-                Path skillMd = dir.resolve(SKILL_FILE);
-                if (!Files.isDirectory(dir) || !Files.isRegularFile(skillMd)) {
-                    continue;
-                }
-                if (!Files.exists(dir.resolve(DISABLED_MARKER))) {
-                    continue;
-                }
-                Meta meta = readMeta(skillMd);
-                names.add(meta.name != null ? meta.name : dir.getFileName().toString());
-            }
-        } catch (IOException e) {
-            log.error("读取停用技能失败: {}", e.getMessage());
-        }
-        return names;
-    }
-
-    /**
-     * 按技能名定位技能目录:先比目录名,再比 SKILL.md frontmatter 里的 name。
-     */
-    public Optional<Path> resolveDir(Path skillsRoot, String name) {
-        if (skillsRoot == null || name == null || name.isBlank() || !Files.isDirectory(skillsRoot)) {
-            return Optional.empty();
-        }
-        Path direct = skillsRoot.resolve(name).normalize();
-        if (direct.startsWith(skillsRoot.normalize())
-                && Files.isRegularFile(direct.resolve(SKILL_FILE))) {
-            return Optional.of(direct);
-        }
-        try (Stream<Path> dirs = Files.list(skillsRoot)) {
-            return dirs.filter(Files::isDirectory)
-                    .filter(dir -> Files.isRegularFile(dir.resolve(SKILL_FILE)))
-                    .filter(dir -> {
-                        Meta meta = readMeta(dir.resolve(SKILL_FILE));
-                        return name.equals(meta.name);
-                    })
-                    .findFirst();
-        } catch (IOException e) {
-            return Optional.empty();
-        }
-    }
-
-    /* ------------------------------------------------------------------ *
-     * 内部实现
-     * ------------------------------------------------------------------ */
-
-    /**
-     * 解压 zip 到目标目录。
-     *
-     * <p>自己实现而不是用工具类,是因为**路径穿越必须在这里挡住**:条目名里的 {@code ../}
-     * 或绝对路径会让文件落到技能目录之外。另外顺手挡一下 zip 炸弹(体积 / 条目数上限)。
-     */
-    private void unzip(byte[] zipBytes, Path targetDir) throws IOException {
-        long total = 0;
-        int entries = 0;
-        byte[] buffer = new byte[8192];
-        try (InputStream in = new ByteArrayInputStream(zipBytes);
-             ZipInputStream zis = new ZipInputStream(in, StandardCharsets.UTF_8)) {
-            ZipEntry entry;
-            while ((entry = zis.getNextEntry()) != null) {
-                if (entry.isDirectory()) {
-                    continue;
-                }
-                entries++;
-                if (entries > MAX_UNZIP_ENTRIES) {
-                    throw new ServerException(400, "压缩包条目过多,已中止");
-                }
-                Path out = targetDir.resolve(entry.getName()).normalize();
-                if (!out.startsWith(targetDir.normalize())) {
-                    throw new ServerException(400, "压缩包包含非法路径: " + entry.getName());
-                }
-                Files.createDirectories(out.getParent());
-                long written = 0;
-                try (var os = Files.newOutputStream(out)) {
-                    int len;
-                    while ((len = zis.read(buffer)) > 0) {
-                        written += len;
-                        total += len;
-                        if (total > MAX_UNZIP_BYTES) {
-                            throw new ServerException(400, "压缩包解压后体积过大,已中止");
-                        }
-                        os.write(buffer, 0, len);
-                    }
-                }
-                if (written == 0) {
-                    Files.write(out, new byte[0]);
-                }
-            }
-        }
-    }
-
-    /**
-     * 找出压缩包里含 SKILL.md 的目录(最多留意两层:包内直接是技能目录,或多套了一层包装目录)。
-     */
-    private List<Path> findSkillRoots(Path tempDir) throws IOException {
-        List<Path> roots = new ArrayList<>();
-        try (Stream<Path> walk = Files.walk(tempDir, 3)) {
-            for (Path p : (Iterable<Path>) walk::iterator) {
-                if (Files.isDirectory(p) && Files.isRegularFile(p.resolve(SKILL_FILE))) {
-                    roots.add(p);
-                }
-            }
-        }
-        // 嵌套情况(技能目录里还有技能)只保留最外层,避免被当成多个技能
-        List<Path> outer = new ArrayList<>();
-        for (Path p : roots) {
-            boolean nested = outer.stream().anyMatch(parent -> p.startsWith(parent));
-            if (!nested) {
-                outer.add(p);
-            }
-        }
-        return outer;
-    }
-
-    private void validateName(String name) {
-        if (name == null || name.isBlank() || !name.matches(NAME_PATTERN)) {
-            throw new ServerException(400,
-                    "技能名非法: " + name + "(仅允许中英文、数字、下划线、短横线与点,长度 1~64)");
-        }
-    }
-
-    /**
-     * 把「相对技能目录的路径」解析成绝对路径,并挡住路径穿越。
-     *
-     * <p>这些路径来自页面输入,必须当作不可信数据 —— 否则 {@code ../../x} 能写到技能目录之外。
-     */
-    private static Path resolveInside(Path baseDir, String relPath) {
-        String rel = normalizeRel(relPath);
-        if (rel.isEmpty() || rel.startsWith("/") || rel.matches("^[A-Za-z]:.*")) {
-            throw new ServerException(400, "附属文件路径非法: " + relPath);
-        }
-        for (String seg : rel.split("/")) {
-            if ("..".equals(seg)) {
-                throw new ServerException(400, "附属文件路径非法: " + relPath);
-            }
-        }
-        Path target = baseDir.resolve(rel).normalize();
-        if (!target.startsWith(baseDir.normalize())) {
-            throw new ServerException(400, "附属文件路径非法: " + relPath);
-        }
-        return target;
-    }
-
-    /** 相对路径统一成「正斜杠、无首尾空白」,便于与 SKILL.md 之类的常量比较 */
-    private static String normalizeRel(String relPath) {
-        return relPath == null ? "" : relPath.strip().replace('\\', '/');
-    }
-
-    private static <T> List<T> nullToEmpty(List<T> list) {
-        return list == null ? List.of() : list;
-    }
-
-    /** 删除技能目录下的空目录(删掉附属文件后可能残留空壳,留着会误导「该目录已使用」) */
-    private void pruneEmptyDirs(Path skillDir) throws IOException {
-        List<Path> dirs;
-        try (Stream<Path> walk = Files.walk(skillDir)) {
-            dirs = walk.filter(Files::isDirectory)
-                    .filter(p -> !p.equals(skillDir))
-                    .sorted(Comparator.reverseOrder())
-                    .toList();
-        }
-        for (Path dir : dirs) {
-            try (Stream<Path> children = Files.list(dir)) {
-                if (children.findAny().isEmpty()) {
-                    Files.deleteIfExists(dir);
-                }
-            }
-        }
-    }
-
-    /**
-     * 按 UTF-8 严格解码;含 NUL 字节或非法序列即判定为二进制,返回 null。
-     *
-     * <p>用「能不能解码」而不是扩展名白名单判断:技能包里的脚本没有固定后缀
-     * (.py/.js/.sh/.md/.csv…),扩展名白名单一定会漏。
-     */
-    private static String decodeUtf8(byte[] bytes) {
-        if (bytes.length == 0) {
-            return "";
-        }
-        for (byte b : bytes) {
-            if (b == 0) {
-                return null;
-            }
-        }
-        try {
-            return StandardCharsets.UTF_8.newDecoder()
-                    .onMalformedInput(CodingErrorAction.REPORT)
-                    .onUnmappableCharacter(CodingErrorAction.REPORT)
-                    .decode(ByteBuffer.wrap(bytes))
-                    .toString();
-        } catch (CharacterCodingException e) {
-            return null;
-        }
-    }
-
-    /**
-     * 读 SKILL.md 的 frontmatter(只取 name / description)。
-     *
-     * <p>技能定义的 frontmatter 就这两个必需字段(AgentScope 也是这么约定),
-     * 因此这里手写解析,不引入 YAML 依赖去做通用解析。
-     */
-    private Meta readMeta(Path skillMd) {
-        Meta meta = new Meta();
-        String text = readText(skillMd);
-        if (text == null || !text.startsWith("---")) {
-            return meta;
-        }
-        int end = text.indexOf("\n---", 3);
-        String frontmatter = end > 0 ? text.substring(3, end) : "";
-        for (String rawLine : frontmatter.split("\n")) {
-            String line = rawLine.strip();
-            if (line.startsWith("name:")) {
-                meta.name = unquote(line.substring("name:".length()).strip());
-            } else if (line.startsWith("description:")) {
-                meta.description = unquote(line.substring("description:".length()).strip());
-            }
-        }
-        return meta;
-    }
-
-    private static String unquote(String value) {
-        if (value == null || value.isEmpty()) {
-            return value;
-        }
-        if (value.startsWith("\"") && value.endsWith("\"") && value.length() > 1) {
-            // 双引号写入时做过转义(buildSkillMd),读回要还原,保证编辑往返一致
-            return value.substring(1, value.length() - 1).replace("\\\"", "\"").replace("\\\\", "\\");
-        }
-        if (value.startsWith("'") && value.endsWith("'") && value.length() > 1) {
-            return value.substring(1, value.length() - 1);
-        }
-        return value;
-    }
-
-    private String readText(Path file) {
-        try {
-            if (!Files.isRegularFile(file) || Files.size(file) > MAX_SKILL_MD_BYTES) {
-                return null;
-            }
-            return Files.readString(file, StandardCharsets.UTF_8);
-        } catch (IOException e) {
-            log.error("读取技能定义失败: {} ({})", file, e.getMessage());
-            return null;
-        }
-    }
-
-    private List<SkillDetailVO.FileItem> listFiles(Path skillDir) {
-        List<SkillDetailVO.FileItem> files = new ArrayList<>();
-        try (Stream<Path> walk = Files.walk(skillDir)) {
-            for (Path p : (Iterable<Path>) walk::iterator) {
-                if (!Files.isRegularFile(p)) {
-                    continue;
-                }
-                String relative = skillDir.relativize(p).toString().replace('\\', '/');
-                if (SKILL_FILE.equals(relative) || relative.startsWith(".") || relative.contains("/.")) {
-                    continue;
-                }
-                SkillDetailVO.FileItem item = new SkillDetailVO.FileItem();
-                item.setPath(relative);
-                try {
-                    item.setSize(Files.size(p));
-                } catch (IOException ignored) {
-                    item.setSize(null);
-                }
-                files.add(item);
-            }
-        } catch (IOException e) {
-            log.error("读取技能附属文件失败: {} ({})", skillDir, e.getMessage());
-        }
-        files.sort(Comparator.comparing(SkillDetailVO.FileItem::getPath,
-                Comparator.nullsLast(String::compareTo)));
-        return files;
-    }
-
-    private void copyDirectory(Path source, Path target) throws IOException {
-        try (Stream<Path> walk = Files.walk(source)) {
-            for (Path p : (Iterable<Path>) walk::iterator) {
-                Path dest = target.resolve(source.relativize(p).toString());
-                if (Files.isDirectory(p)) {
-                    Files.createDirectories(dest);
-                } else {
-                    Files.createDirectories(dest.getParent());
-                    Files.copy(p, dest, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
-                }
-            }
-        }
-    }
-
-    private void deleteDirectory(Path dir) throws IOException {
-        if (!Files.exists(dir)) {
-            return;
-        }
-        try (Stream<Path> walk = Files.walk(dir)) {
-            List<Path> paths = walk.sorted(Comparator.reverseOrder()).toList();
-            for (Path p : paths) {
-                Files.deleteIfExists(p);
-            }
-        }
-    }
-
-    private void deleteQuietly(Path dir) {
-        try {
-            deleteDirectory(dir);
-        } catch (IOException e) {
-            log.error("清理临时目录失败: {} ({})", dir, e.getMessage());
-        }
-    }
-
-    /** SKILL.md frontmatter 里的关键字段 */
-    private static final class Meta {
-        private String name;
-        private String description;
-    }
-}

+ 78 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/skill/UserSkillRepositoryProvider.java

@@ -0,0 +1,78 @@
+package com.zsjz.ai.module.agent.skill;
+
+import io.agentscope.core.skill.repository.AgentSkillRepository;
+import io.agentscope.extensions.jdbc.dialect.AbstractJdbcDialect;
+import io.agentscope.extensions.jdbc.skill.JdbcAgentSkillRepository;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import javax.sql.DataSource;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * 按用户取技能仓库:一个用户一个 namespace。
+ *
+ * <p>官方 {@code JdbcAgentSkillRepository} 的作用域契约是「一个实例绑定一个 namespace」,
+ * {@code UNIQUE(namespace, name)} 保证同一 namespace 内名字唯一、跨 namespace 可重名。
+ * 本项目把 namespace 定为登录用户 ID({@link #namespaceOf}),因此:
+ * <ul>
+ *   <li>不同用户可以有同名技能,不再需要自建归属表;</li>
+ *   <li>agent 挂载时拿到的实例天然只包含当前用户的技能,不需要过滤包装。</li>
+ * </ul>
+ *
+ * <p>实例是不可变、无状态的(只持有 DataSource/方言/namespace),按用户缓存即可,
+ * 避免每次构建 agent 都新建并刷一条初始化日志;条目数量上界就是系统用户数。
+ */
+@Slf4j
+@Component
+public class UserSkillRepositoryProvider {
+
+    private final DataSource dataSource;
+
+    private final AbstractJdbcDialect skillJdbcDialect;
+
+    private final Map<Long, JdbcAgentSkillRepository> cache = new ConcurrentHashMap<>();
+
+    public UserSkillRepositoryProvider(DataSource dataSource, AbstractJdbcDialect skillJdbcDialect) {
+        this.dataSource = dataSource;
+        this.skillJdbcDialect = skillJdbcDialect;
+    }
+
+    /**
+     * 当前用户的只读技能仓库(agent 挂载用)。
+     */
+    public AgentSkillRepository forUser(Long userId) {
+        return repository(userId, false);
+    }
+
+    /**
+     * 该用户是否拥有这个技能(用于专家白名单校验)。
+     */
+    public boolean existsForUser(Long userId, String skillName) {
+        if (userId == null || skillName == null) {
+            return false;
+        }
+        return repository(userId, false).skillExists(skillName);
+    }
+
+    /**
+     * 用户 ID → namespace。数字 ID 满足官方字符集 {@code [A-Za-z0-9._-]}。
+     */
+    public static String namespaceOf(Long userId) {
+        return String.valueOf(userId);
+    }
+
+    private JdbcAgentSkillRepository repository(Long userId, boolean writeable) {
+        if (userId == null) {
+            throw new IllegalArgumentException("userId 不能为空");
+        }
+        // 只读实例缓存复用;可写实例目前没有调用方,不走缓存
+        if (writeable) {
+            return new JdbcAgentSkillRepository(dataSource, skillJdbcDialect,
+                    namespaceOf(userId), true);
+        }
+        return cache.computeIfAbsent(userId, id ->
+                new JdbcAgentSkillRepository(dataSource, skillJdbcDialect, namespaceOf(id), false));
+    }
+}

+ 0 - 3
ai-server/src/main/java/com/zsjz/ai/module/agent/vo/SkillDetailVO.java

@@ -23,9 +23,6 @@ public class SkillDetailVO {
     /** 是否启用(未停用) */
     private Boolean enabled;
 
-    /** 技能所在目录的绝对路径 */
-    private String path;
-
     /** SKILL.md 原文 */
     private String content;
 

+ 3 - 5
ai-server/src/main/java/com/zsjz/ai/module/agent/vo/SkillVO.java

@@ -3,18 +3,18 @@ package com.zsjz.ai.module.agent.vo;
 import lombok.Data;
 
 /**
- * 技能响应VO:一条技能(磁盘上一个含 SKILL.md 的目录)。
+ * 技能响应VO:一条技能(存在服务端技能仓库里,按登录用户隔离)。
  */
 @Data
 public class SkillVO {
 
-    /** 技能名(SKILL.md frontmatter 的 name,缺失时退回目录名) */
+    /** 技能名(SKILL.md frontmatter 的 name,全平台唯一) */
     private String name;
 
     /** 技能描述(frontmatter 的 description) */
     private String description;
 
-    /** 来源:{@code user}=用户技能目录(可管理);{@code repo}=agent 配置的技能仓库(只读) */
+    /** 来源:固定 {@code user}(当前只有用户自建技能) */
     private String source;
 
     /** 是否可管理(可卸载 / 可启停) */
@@ -23,6 +23,4 @@ public class SkillVO {
     /** 是否启用(未停用) */
     private Boolean enabled;
 
-    /** 技能所在目录的绝对路径(页面展示,方便运维对照磁盘) */
-    private String path;
 }

+ 2 - 1
ai-server/src/test/java/com/zsjz/ai/module/agent/service/AgentCapabilityToolkitTest.java

@@ -111,7 +111,8 @@ class AgentCapabilityToolkitTest {
                 mock(WorkspaceSchemaCatalog.class),
                 mock(com.zsjz.ai.module.knowledge.mapper.KnowledgeBaseMapper.class),
                 mock(com.zsjz.ai.module.knowledge.KnowledgeDocIndexer.class),
-                mock(com.zsjz.ai.module.agent.rag.EmbeddingProvider.class));
+                mock(com.zsjz.ai.module.agent.rag.EmbeddingProvider.class),
+                mock(com.zsjz.ai.module.agent.skill.UserSkillRepositoryProvider.class));
     }
 
     @Test

+ 16 - 44
ai-server/src/test/java/com/zsjz/ai/module/agent/service/AgentExpertTest.java

@@ -47,14 +47,11 @@ import com.zsjz.ai.module.trans.service.TransFundFlowService;
 import com.zsjz.ai.module.trans.service.TransRecordService;
 import com.zsjz.ai.module.trans.service.TransSensitiveService;
 import io.agentscope.harness.agent.HarnessAgent;
-import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.DisplayName;
 import org.junit.jupiter.api.Test;
 import org.springframework.test.util.ReflectionTestUtils;
 
-import java.nio.file.Files;
-import java.nio.file.Path;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
@@ -74,6 +71,7 @@ import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.spy;
 import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
 
 /**
  * 专家(智能体)管理:用户隔离 / 内置不可改删 / 配置校验 / 实例池驱逐。
@@ -93,12 +91,16 @@ class AgentExpertTest {
     /** save/updateById 捕获的实体(断言配置是否落对) */
     private AgentEntity captured;
 
+    /** 技能归属查询缝(技能已不在磁盘,白名单校验走它) */
+    private com.zsjz.ai.module.agent.skill.UserSkillRepositoryProvider skillRepositoryProvider;
+
     /** 实例池(反射取真实池,验证驱逐) */
     @SuppressWarnings("unchecked")
     private com.zsjz.ai.module.agent.service.AgentInstancePool<HarnessAgent> pool;
 
     @BeforeEach
     void setUp() {
+        skillRepositoryProvider = mock(com.zsjz.ai.module.agent.skill.UserSkillRepositoryProvider.class);
         service = spy(new AgentService(
                 mock(AgentModelFactory.class),
                 mock(AgentModelMapper.class),
@@ -137,7 +139,8 @@ class AgentExpertTest {
                 mock(WorkspaceSchemaCatalog.class),
                 mock(com.zsjz.ai.module.knowledge.mapper.KnowledgeBaseMapper.class),
                 mock(com.zsjz.ai.module.knowledge.KnowledgeDocIndexer.class),
-                mock(com.zsjz.ai.module.agent.rag.EmbeddingProvider.class)));
+                mock(com.zsjz.ai.module.agent.rag.EmbeddingProvider.class),
+                skillRepositoryProvider));
         pool = (com.zsjz.ai.module.agent.service.AgentInstancePool<HarnessAgent>) ReflectionTestUtils.getField(service, "agentPool");
 
         // create 路径:库里那行还没建,回读刚 save 的实体(rowId 已被 save 桩回填)
@@ -159,23 +162,6 @@ class AgentExpertTest {
         doReturn(true).when(service).removeById(any(java.io.Serializable.class));
     }
 
-    @AfterEach
-    void tearDown() throws Exception {
-        // 专家技能验证会临时建目录,清掉不留垃圾
-        Path skillsRoot = Path.of(System.getProperty("user.dir"), ".agentscope", "users",
-                String.valueOf(ME), "skills");
-        if (Files.isDirectory(skillsRoot)) {
-            try (var stream = Files.list(skillsRoot)) {
-                for (Path p : stream.toList()) {
-                    try (var inner = Files.list(p)) {
-                        inner.forEach(x -> deleteQuietly(x));
-                    }
-                    deleteQuietly(p);
-                }
-            }
-        }
-    }
-
     // ==================== 列表隔离 ====================
 
     @Test
@@ -311,7 +297,7 @@ class AgentExpertTest {
         assertTrue(nameEx.getMsg().contains("非法技能名"), nameEx.getMsg());
 
         SaveExpertDTO missingSkill = dto("x", "y");
-        missingSkill.setSkillNames(List.of("不存在的技能abc"));
+        missingSkill.setSkillNames(List.of("missing-skill-abc"));
         ServerException missEx = assertThrows(ServerException.class,
                 () -> service.createExpert(ME, missingSkill));
         assertTrue(missEx.getMsg().contains("技能不存在"), missEx.getMsg());
@@ -319,20 +305,14 @@ class AgentExpertTest {
 
     @Test
     @DisplayName("校验:已安装的技能可以通过白名单校验")
-    void acceptsInstalledSkill() throws Exception {
-        Path skillsRoot = Path.of(System.getProperty("user.dir"), ".agentscope", "users",
-                String.valueOf(ME), "skills");
-        Path skillDir = skillsRoot.resolve("测试技能");
-        Files.createDirectories(skillDir);
-        try {
-            SaveExpertDTO dto = dto("x", "y");
-            dto.setSkillNames(List.of("测试技能"));
-            ExpertVO vo = service.createExpert(ME, dto);
-            assertEquals("[\"测试技能\"]", vo.getSkillNames() == null ? null
-                    : com.zsjz.ai.common.utils.Json.toStr(vo.getSkillNames()));
-        } finally {
-            Files.deleteIfExists(skillDir);
-        }
+    void acceptsInstalledSkill() {
+        when(skillRepositoryProvider.existsForUser(ME, "test-skill")).thenReturn(true);
+
+        SaveExpertDTO dto = dto("x", "y");
+        dto.setSkillNames(List.of("test-skill"));
+        ExpertVO vo = service.createExpert(ME, dto);
+        assertEquals("[\"test-skill\"]", vo.getSkillNames() == null ? null
+                : com.zsjz.ai.common.utils.Json.toStr(vo.getSkillNames()));
     }
 
     // ==================== JSON 工具 ====================
@@ -365,12 +345,4 @@ class AgentExpertTest {
         d.setSysPrompt(sysPrompt);
         return d;
     }
-
-    private static void deleteQuietly(Path path) {
-        try {
-            Files.deleteIfExists(path);
-        } catch (Exception ignored) {
-            /* 测试清理尽力而为 */
-        }
-    }
 }

+ 2 - 1
ai-server/src/test/java/com/zsjz/ai/module/agent/service/impl/AgentToolCatalogServiceImplTest.java

@@ -127,7 +127,8 @@ class AgentToolCatalogServiceImplTest {
                 mock(WorkspaceSchemaCatalog.class),
                 mock(com.zsjz.ai.module.knowledge.mapper.KnowledgeBaseMapper.class),
                 mock(com.zsjz.ai.module.knowledge.KnowledgeDocIndexer.class),
-                mock(com.zsjz.ai.module.agent.rag.EmbeddingProvider.class));
+                mock(com.zsjz.ai.module.agent.rag.EmbeddingProvider.class),
+                mock(com.zsjz.ai.module.agent.skill.UserSkillRepositoryProvider.class));
     }
 
     @Test

+ 153 - 0
ai-server/src/test/java/com/zsjz/ai/module/agent/skill/SkillPackageImporterTest.java

@@ -0,0 +1,153 @@
+package com.zsjz.ai.module.agent.skill;
+
+import com.zsjz.ai.common.exception.ServerException;
+import io.agentscope.core.skill.AgentSkill;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayOutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 技能包导入的安全边界与解析契约(纯单测,不连数据库)。
+ *
+ * <p>官方 {@code SkillUtil.createFromZip} 没有 zip 炸弹防护,本类自己解压,
+ * 这里专门守这几条:条目数、解压体积、路径穿越、缺少 SKILL.md、一包多技能。
+ */
+class SkillPackageImporterTest {
+
+    private final SkillPackageImporter importer = new SkillPackageImporter();
+
+    @Test
+    @DisplayName("正常技能包:frontmatter 解析成 name/description,正文去掉 frontmatter")
+    void importsValidPackage() {
+        byte[] zip = zip(Map.of(
+                "SKILL.md", """
+                        ---
+                        name: night-call
+                        description: 夜间通话分析
+                        ---
+                        # 夜间通话
+                        按 steps 执行。
+                        """,
+                "references/tips.md", "看 22:00-06:00"));
+
+        AgentSkill skill = importer.importZip(zip);
+
+        assertEquals("night-call", skill.getName());
+        assertEquals("夜间通话分析", skill.getDescription());
+        assertTrue(skill.getSkillContent().contains("# 夜间通话"), skill.getSkillContent());
+        assertTrue(!skill.getSkillContent().contains("description:"), "正文不应保留 frontmatter");
+        assertEquals("看 22:00-06:00", skill.getResource("references/tips.md"));
+    }
+
+    @Test
+    @DisplayName("二进制资源按官方约定存成 base64: 前缀")
+    void binaryResourceUsesBase64Prefix() {
+        byte[] png = new byte[]{(byte) 0x89, 'P', 'N', 'G', 0, 1, 2, (byte) 0xFF};
+        Map<String, byte[]> entries = new LinkedHashMap<>();
+        entries.put("SKILL.md", ("---\nname: with-image\ndescription: 带图\n---\n正文\n").getBytes(StandardCharsets.UTF_8));
+        entries.put("assets/icon.png", png);
+
+        AgentSkill skill = importer.importZip(zipBytes(entries));
+
+        String raw = skill.getResource("assets/icon.png");
+        assertNotNull(raw);
+        assertTrue(raw.startsWith("base64:"), raw);
+    }
+
+    @Test
+    @DisplayName("包装目录:多套一层也能定位技能根")
+    void wrapperDirectoryIsAccepted() {
+        byte[] zip = zip(Map.of(
+                "wrapper/SKILL.md", "---\nname: wrapped\ndescription: 包了一层\n---\n正文",
+                "wrapper/scripts/run.py", "print(1)"));
+        assertEquals("wrapped", importer.importZip(zip).getName());
+    }
+
+    @Test
+    @DisplayName("没有 SKILL.md → 400")
+    void rejectsPackageWithoutSkillMd() {
+        ServerException ex = assertThrows(ServerException.class,
+                () -> importer.importZip(zip(Map.of("readme.md", "不是技能"))));
+        assertEquals(400, ex.getCode());
+        assertTrue(ex.getMsg().contains("SKILL.md"), ex.getMsg());
+    }
+
+    @Test
+    @DisplayName("一包多个技能 → 400")
+    void rejectsMultipleSkills() {
+        ServerException ex = assertThrows(ServerException.class,
+                () -> importer.importZip(zip(Map.of(
+                        "a/SKILL.md", "---\nname: a\ndescription: d\n---\n正文",
+                        "b/SKILL.md", "---\nname: b\ndescription: d\n---\n正文"))));
+        assertEquals(400, ex.getCode());
+    }
+
+    @Test
+    @DisplayName("路径穿越 → 400(条目名里的 ../ 一律拒绝)")
+    void rejectsTraversal() {
+        ServerException ex = assertThrows(ServerException.class,
+                () -> importer.importZip(zip(Map.of(
+                        "../escaped/SKILL.md", "---\nname: evil\ndescription: d\n---\n正文"))));
+        assertEquals(400, ex.getCode());
+    }
+
+    @Test
+    @DisplayName("条目数超过上限 → 400(防 zip 炸弹)")
+    void rejectsTooManyEntries() {
+        Map<String, byte[]> entries = new LinkedHashMap<>();
+        entries.put("SKILL.md", ("---\nname: bomb\ndescription: d\n---\n正文").getBytes(StandardCharsets.UTF_8));
+        for (int i = 0; i < 2100; i++) {
+            entries.put("references/f" + i + ".txt", new byte[]{1});
+        }
+        ServerException ex = assertThrows(ServerException.class, () -> importer.importZip(zipBytes(entries)));
+        assertEquals(400, ex.getCode());
+        assertTrue(ex.getMsg().contains("条目过多"), ex.getMsg());
+    }
+
+    @Test
+    @DisplayName("缺 frontmatter 的 SKILL.md → 400(官方要求 name/description)")
+    void rejectsSkillMdWithoutFrontmatter() {
+        ServerException ex = assertThrows(ServerException.class,
+                () -> importer.importZip(zip(Map.of("SKILL.md", "只有正文没有 frontmatter"))));
+        assertEquals(400, ex.getCode());
+    }
+
+    @Test
+    @DisplayName("空包 → 400")
+    void rejectsEmptyUpload() {
+        assertEquals(400, assertThrows(ServerException.class, () -> importer.importZip(new byte[0])).getCode());
+    }
+
+    /* ------------------------------------------------------------------ */
+
+    private static byte[] zip(Map<String, String> entries) {
+        Map<String, byte[]> raw = new LinkedHashMap<>();
+        entries.forEach((path, content) -> raw.put(path, content.getBytes(StandardCharsets.UTF_8)));
+        return zipBytes(raw);
+    }
+
+    private static byte[] zipBytes(Map<String, byte[]> entries) {
+        ByteArrayOutputStream out = new ByteArrayOutputStream();
+        try (ZipOutputStream zos = new ZipOutputStream(out, StandardCharsets.UTF_8)) {
+            for (Map.Entry<String, byte[]> e : entries.entrySet()) {
+                zos.putNextEntry(new ZipEntry(e.getKey()));
+                zos.write(e.getValue());
+                zos.closeEntry();
+            }
+        } catch (Exception e) {
+            throw new IllegalStateException(e);
+        }
+        return out.toByteArray();
+    }
+}

+ 154 - 0
ai-server/src/test/java/com/zsjz/ai/module/agent/skill/SkillRegistryServiceLiveTest.java

@@ -0,0 +1,154 @@
+package com.zsjz.ai.module.agent.skill;
+
+import com.zsjz.ai.common.exception.ServerException;
+import com.zsjz.ai.module.agent.vo.SkillDetailVO;
+import com.zsjz.ai.module.agent.vo.SkillFileContentVO;
+import com.zsjz.ai.module.agent.vo.SkillVO;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+
+import java.io.ByteArrayOutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 技能仓库的真库往返:官方 JDBC 仓库(namespace 隔离)+ 自建启停表({@code agent_skill_state})。
+ *
+ * <p><b>默认跳过</b>:需要能连 dev PG,且已执行 {@code sql/agent_skill.sql}。手工验证:
+ * <pre>
+ * mvn -pl ai-server test -Dtest=SkillRegistryServiceLiveTest -Dskill.pg.it=true
+ * </pre>
+ *
+ * <p>覆盖「线上会踩到」的几条:创建 → 列表/详情 → 附属文件读写 → 修改(含删除文件)→ 启停 →
+ * <b>跨用户同名</b>(namespace 隔离的核心语义)→ 同一 namespace 内同名被拒 → zip 安装(含二进制附件)
+ * → 卸载清状态行。用例自己清干净,不在库里留残渣。
+ */
+@SpringBootTest
+@EnabledIfSystemProperty(named = "skill.pg.it", matches = "true")
+class SkillRegistryServiceLiveTest {
+
+    /** 哨兵用户:真实用户 ID 不会是这个量级,避免误伤 */
+    private static final Long USER_A = 990001L;
+    private static final Long USER_B = 990002L;
+
+    @Autowired
+    private SkillRegistryService service;
+
+    @Test
+    @DisplayName("真库往返:CRUD/启停/namespace 隔离与跨用户同名/zip 安装")
+    void roundTrip() {
+        String suffix = UUID.randomUUID().toString().substring(0, 8);
+        String name = "live-skill-" + suffix;
+        String zipName = "live-zip-" + suffix;
+        try {
+            // 1) 创建:正文 + 一个附属文件
+            SkillVO created = service.create(USER_A, name, "A 的技能", "# 正文\n按步骤执行。",
+                    List.of(new SkillFileEntry("references/a.md", "参考内容")));
+            assertEquals(name, created.getName());
+            assertTrue(created.getEnabled());
+
+            // 2) 列表 / 详情
+            assertTrue(named(service.list(USER_A), name), "A 的列表里应有刚创建的技能");
+            SkillDetailVO detail = service.detail(USER_A, name);
+            assertTrue(detail.getContent().contains("按步骤执行"), detail.getContent());
+            assertTrue(detail.getFiles().stream().anyMatch(f -> "references/a.md".equals(f.getPath())));
+
+            // 3) 附属文件读取
+            assertEquals("参考内容", service.readFile(USER_A, name, "references/a.md").getContent());
+
+            // 4) 同一 namespace 内同名被拒
+            assertEquals(400, assertThrows(ServerException.class,
+                    () -> service.create(USER_A, name, "重复", "正文", List.of())).getCode());
+
+            // 5) 跨用户同名:namespace 隔离的核心语义 —— B 能建同名技能,两边内容互不干扰
+            SkillVO bCreated = service.create(USER_B, name, "B 的技能", "# B 的正文", List.of());
+            assertEquals(name, bCreated.getName());
+            assertTrue(named(service.list(USER_B), name));
+            assertTrue(service.detail(USER_B, name).getContent().contains("B 的正文"),
+                    "B 读到的必须是自己的那份");
+            assertTrue(service.detail(USER_A, name).getContent().contains("按步骤执行"),
+                    "A 的那份不能被 B 覆盖");
+
+            // 6) 修改:换正文、加文件、删旧文件(只影响 A 的 namespace)
+            service.update(USER_A, name, "改后的描述", "# 新正文",
+                    List.of(new SkillFileEntry("scripts/run.sh", "echo 1")),
+                    List.of("references/a.md"));
+            detail = service.detail(USER_A, name);
+            assertTrue(detail.getContent().contains("新正文"), detail.getContent());
+            assertTrue(detail.getFiles().stream().anyMatch(f -> "scripts/run.sh".equals(f.getPath())));
+            assertTrue(detail.getFiles().stream().noneMatch(f -> "references/a.md".equals(f.getPath())),
+                    "deletePaths 里的文件应被删掉");
+            assertTrue(service.detail(USER_B, name).getContent().contains("B 的正文"),
+                    "改 A 的技能不能影响 B");
+
+            // 7) 启停:只影响该用户自己的开关
+            service.toggle(USER_A, name, false);
+            assertTrue(service.disabledNames(USER_A).contains(name));
+            assertFalse(service.disabledNames(USER_B).contains(name), "A 的停用不应影响 B");
+            assertFalse(service.list(USER_A).stream()
+                    .filter(s -> name.equals(s.getName())).findFirst().orElseThrow().getEnabled());
+            service.toggle(USER_A, name, true);
+            assertFalse(service.disabledNames(USER_A).contains(name));
+
+            // 8) zip 安装:frontmatter 名字优先,二进制附件按 base64: 存
+            byte[] zip = zipBytes(Map.of(
+                    "SKILL.md", ("---\nname: " + zipName + "\ndescription: 上传包\n---\n包内正文").getBytes(StandardCharsets.UTF_8),
+                    "assets/icon.png", new byte[]{(byte) 0x89, 'P', 'N', 'G', 0, 1, 2}));
+            SkillVO installed = service.install(USER_A, zip, false);
+            assertEquals(zipName, installed.getName());
+
+            SkillFileContentVO png = service.readFile(USER_A, zipName, "assets/icon.png");
+            assertTrue(png.getBinary(), "二进制附件应标记 binary");
+            assertNotNull(png.getBase64(), "二进制附件应回 base64");
+
+            // 9) 卸载:技能本体与启停行一起没了
+            service.delete(USER_A, zipName);
+            assertFalse(named(service.list(USER_A), zipName));
+            assertEquals(404, assertThrows(ServerException.class, () -> service.detail(USER_A, zipName)).getCode());
+        } finally {
+            deleteQuietly(USER_A, name);
+            deleteQuietly(USER_B, name);
+            deleteQuietly(USER_A, zipName);
+        }
+    }
+
+    private void deleteQuietly(Long userId, String name) {
+        try {
+            service.delete(userId, name);
+        } catch (Exception ignored) {
+            // 断言失败时尽力清理,不覆盖原始失败
+        }
+    }
+
+    private static boolean named(List<SkillVO> skills, String name) {
+        return skills.stream().anyMatch(s -> name.equals(s.getName()));
+    }
+
+    private static byte[] zipBytes(Map<String, byte[]> entries) {
+        ByteArrayOutputStream out = new ByteArrayOutputStream();
+        try (ZipOutputStream zos = new ZipOutputStream(out, StandardCharsets.UTF_8)) {
+            for (Map.Entry<String, byte[]> e : new LinkedHashMap<>(entries).entrySet()) {
+                zos.putNextEntry(new ZipEntry(e.getKey()));
+                zos.write(e.getValue());
+                zos.closeEntry();
+            }
+        } catch (Exception e) {
+            throw new IllegalStateException(e);
+        }
+        return out.toByteArray();
+    }
+}

+ 0 - 293
ai-server/src/test/java/com/zsjz/ai/module/agent/skill/SkillStoreTest.java

@@ -1,293 +0,0 @@
-package com.zsjz.ai.module.agent.skill;
-
-import com.zsjz.ai.common.exception.ServerException;
-import com.zsjz.ai.module.agent.vo.SkillDetailVO;
-import com.zsjz.ai.module.agent.vo.SkillFileContentVO;
-import com.zsjz.ai.module.agent.vo.SkillVO;
-import org.junit.jupiter.api.DisplayName;
-import org.junit.jupiter.api.Test;
-
-import java.io.ByteArrayOutputStream;
-import java.io.IOException;
-import java.nio.charset.StandardCharsets;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.util.Base64;
-import java.util.List;
-import java.util.zip.ZipEntry;
-import java.util.zip.ZipOutputStream;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertFalse;
-import static org.junit.jupiter.api.Assertions.assertNull;
-import static org.junit.jupiter.api.Assertions.assertThrows;
-import static org.junit.jupiter.api.Assertions.assertTrue;
-
-/**
- * 技能文件仓库测试:安装 / 卸载 / 启停 / 列表。
- *
- * <p>全部跑在临时目录上,不依赖 Spring 上下文、数据库与登录态。
- */
-class SkillStoreTest {
-
-    private final SkillStore skillStore = new SkillStore();
-
-    private static final String SKILL_MD = """
-            ---
-            name: night-call
-            description: 夜间通话分析套路
-            ---
-
-            # 夜间通话分析
-
-            1. 先按人员筛出夜间通话
-            2. 再看对方号码分布
-            """;
-
-    private static byte[] zip(String entryName, String content) throws IOException {
-        ByteArrayOutputStream out = new ByteArrayOutputStream();
-        try (ZipOutputStream zos = new ZipOutputStream(out, StandardCharsets.UTF_8)) {
-            zos.putNextEntry(new ZipEntry(entryName));
-            zos.write(content.getBytes(StandardCharsets.UTF_8));
-            zos.closeEntry();
-        }
-        return out.toByteArray();
-    }
-
-    @Test
-    @DisplayName("安装 zip:解压到技能目录,名称取 frontmatter 的 name")
-    void installFromZip() throws IOException {
-        Path root = Files.createTempDirectory("skills-root-");
-        String name = skillStore.install(zip("night-call/SKILL.md", SKILL_MD), root, false);
-
-        assertEquals("night-call", name);
-        assertTrue(Files.isRegularFile(root.resolve("night-call/SKILL.md")));
-
-        List<SkillVO> skills = skillStore.list(root, SkillStore.SOURCE_USER, true, true);
-        assertEquals(1, skills.size());
-        assertEquals("night-call", skills.get(0).getName());
-        assertEquals("夜间通话分析套路", skills.get(0).getDescription());
-        assertTrue(skills.get(0).getEnabled());
-    }
-
-    @Test
-    @DisplayName("安装 zip:包内多套一层包装目录也能识别到技能根")
-    void installFromZipWithWrapperDir() throws IOException {
-        Path root = Files.createTempDirectory("skills-root-");
-        String name = skillStore.install(zip("night-call-v2/night-call/SKILL.md", SKILL_MD), root, false);
-
-        assertEquals("night-call", name);
-        assertTrue(Files.isRegularFile(root.resolve("night-call/SKILL.md")));
-    }
-
-    @Test
-    @DisplayName("安装 zip:没有 SKILL.md 或路径穿越都要被拒")
-    void installRejectsInvalidPackage() throws IOException {
-        Path root = Files.createTempDirectory("skills-root-");
-        byte[] noSkill = zip("readme.txt", "hello");
-        assertEquals(400, assertThrows(ServerException.class,
-                () -> skillStore.install(noSkill, root, false)).getCode());
-
-        byte[] traversal = zip("../escaped/SKILL.md", SKILL_MD);
-        assertEquals(400, assertThrows(ServerException.class,
-                () -> skillStore.install(traversal, root, false)).getCode());
-        assertFalse(Files.exists(root.getParent().resolve("escaped")), "越界文件不应被写出技能目录");
-    }
-
-    @Test
-    @DisplayName("安装 zip:同名技能默认不覆盖,勾选覆盖后才替换")
-    void installOverwriteSemantics() throws IOException {
-        Path root = Files.createTempDirectory("skills-root-");
-        skillStore.install(zip("night-call/SKILL.md", SKILL_MD), root, false);
-
-        String changed = SKILL_MD.replace("夜间通话分析套路", "改过的描述");
-        assertEquals(400, assertThrows(ServerException.class,
-                () -> skillStore.install(zip("night-call/SKILL.md", changed), root, false)).getCode());
-
-        skillStore.install(zip("night-call/SKILL.md", changed), root, true);
-        List<SkillVO> skills = skillStore.list(root, SkillStore.SOURCE_USER, true, true);
-        assertEquals(1, skills.size());
-        assertEquals("改过的描述", skills.get(0).getDescription());
-    }
-
-    @Test
-    @DisplayName("启停:停用写 .disabled 标记,启用删掉;停用清单供过滤器使用")
-    void toggleAndDisabledNames() throws IOException {
-        Path root = Files.createTempDirectory("skills-root-");
-        skillStore.install(zip("night-call/SKILL.md", SKILL_MD), root, false);
-
-        skillStore.toggle(root, "night-call", false);
-        assertTrue(Files.exists(root.resolve("night-call/" + SkillStore.DISABLED_MARKER)));
-        assertEquals(List.of("night-call"), skillStore.disabledNames(root));
-        assertFalse(skillStore.list(root, SkillStore.SOURCE_USER, true, true).get(0).getEnabled());
-
-        skillStore.toggle(root, "night-call", true);
-        assertFalse(Files.exists(root.resolve("night-call/" + SkillStore.DISABLED_MARKER)));
-        assertTrue(skillStore.disabledNames(root).isEmpty());
-        assertTrue(skillStore.list(root, SkillStore.SOURCE_USER, true, true).get(0).getEnabled());
-    }
-
-    @Test
-    @DisplayName("详情:返回 SKILL.md 正文与附属文件(不含停用标记)")
-    void readDetail() throws IOException {
-        Path root = Files.createTempDirectory("skills-root-");
-        skillStore.install(zip("night-call/SKILL.md", SKILL_MD), root, false);
-        Files.createDirectories(root.resolve("night-call/references"));
-        Files.writeString(root.resolve("night-call/references/tips.md"), "tips");
-        skillStore.toggle(root, "night-call", false);
-
-        SkillDetailVO detail = skillStore.read(root, "night-call", SkillStore.SOURCE_USER, true, true);
-        assertEquals("night-call", detail.getName());
-        assertTrue(detail.getContent().contains("夜间通话分析"));
-        assertFalse(detail.getEnabled());
-        assertEquals(List.of("references/tips.md"),
-                detail.getFiles().stream().map(SkillDetailVO.FileItem::getPath).toList());
-    }
-
-    @Test
-    @DisplayName("卸载:删除技能目录;不存在的技能报 404")
-    void deleteSkill() throws IOException {
-        Path root = Files.createTempDirectory("skills-root-");
-        skillStore.install(zip("night-call/SKILL.md", SKILL_MD), root, false);
-
-        skillStore.delete(root, "night-call");
-        assertFalse(Files.exists(root.resolve("night-call")));
-        assertTrue(skillStore.list(root, SkillStore.SOURCE_USER, true, true).isEmpty());
-
-        assertEquals(404, assertThrows(ServerException.class,
-                () -> skillStore.delete(root, "night-call")).getCode());
-    }
-
-    @Test
-    @DisplayName("新建技能:自动拼 frontmatter,描述里的 YAML 特殊字符读回不走样")
-    void saveCreatesSkillMd() throws IOException {
-        Path root = Files.createTempDirectory("skills-root-");
-        String desc = "含:冒号 # 井号 \"引号\" 反斜杠\\结尾";
-        String name = skillStore.save(root, "夜间通话", desc, "# 正文\n\n1. 第一步\n2. 第二步", false);
-
-        assertEquals("夜间通话", name);
-        Path skillMd = root.resolve("夜间通话/SKILL.md");
-        assertTrue(Files.isRegularFile(skillMd));
-        String text = Files.readString(skillMd, StandardCharsets.UTF_8);
-        assertTrue(text.startsWith("---\nname: 夜间通话\ndescription: \""));
-        assertTrue(text.contains("\n---\n\n# 正文"));
-
-        // 读回:描述与正文和写入时一致(编辑弹窗预填就靠这次往返)
-        SkillDetailVO detail = skillStore.read(root, "夜间通话", SkillStore.SOURCE_USER, true, true);
-        assertEquals(desc, detail.getDescription());
-        assertTrue(detail.getContent().contains("1. 第一步\n2. 第二步"));
-        assertEquals(1, skillStore.list(root, SkillStore.SOURCE_USER, true, true).size());
-    }
-
-    @Test
-    @DisplayName("保存技能:同名默认拒绝,overwrite 才覆盖;正文空/名非法都报 400")
-    void saveOverwriteAndValidation() throws IOException {
-        Path root = Files.createTempDirectory("skills-root-");
-        skillStore.save(root, "night-call", "第一版", "# v1", false);
-
-        assertEquals(400, assertThrows(ServerException.class,
-                () -> skillStore.save(root, "night-call", "第二版", "# v2", false)).getCode());
-        skillStore.save(root, "night-call", "第二版", "# v2", true);
-        assertEquals("第二版",
-                skillStore.read(root, "night-call", SkillStore.SOURCE_USER, true, true).getDescription());
-
-        assertEquals(400, assertThrows(ServerException.class,
-                () -> skillStore.save(root, "blank-body", "", "   ", false)).getCode());
-        assertEquals(400, assertThrows(ServerException.class,
-                () -> skillStore.save(root, "../escaped", "", "# x", false)).getCode());
-        assertFalse(Files.exists(root.getParent().resolve("escaped")), "越界目录不应被写出技能目录");
-    }
-
-    @Test
-    @DisplayName("保存技能:正文自带 frontmatter 时原样保留(用户粘贴完整 SKILL.md)")
-    void saveKeepsUserProvidedFrontmatter() throws IOException {
-        Path root = Files.createTempDirectory("skills-root-");
-        skillStore.save(root, "night-call", "ignored", SKILL_MD, false);
-
-        SkillDetailVO detail = skillStore.read(root, "night-call", SkillStore.SOURCE_USER, true, true);
-        assertEquals("夜间通话分析套路", detail.getDescription());
-        assertTrue(detail.getContent().startsWith("---\nname: night-call\n"));
-    }
-
-    @Test
-    @DisplayName("目录不存在时列表为空而不是报错(新用户还没装过技能)")
-    void listOnMissingRoot() {
-        Path missing = Path.of(System.getProperty("java.io.tmpdir"), "not-exists-" + System.nanoTime());
-        assertTrue(skillStore.list(missing, SkillStore.SOURCE_USER, true, true).isEmpty());
-        assertTrue(skillStore.disabledNames(missing).isEmpty());
-    }
-
-    @Test
-    @DisplayName("保存技能:附属文件按规范写进 scripts/references/testcases,并能逐个读回")
-    void saveWithAttachmentFiles() throws IOException {
-        Path root = Files.createTempDirectory("skills-root-");
-        skillStore.save(root, "with-files", "带附属文件", "# 正文",
-                List.of(new SkillStore.FileEntry("scripts/run.py", "print('hi')"),
-                        new SkillStore.FileEntry("references/spec.md", "# 规范"),
-                        new SkillStore.FileEntry("testcases/case1.json", "{}")),
-                null, false);
-
-        assertTrue(Files.isRegularFile(root.resolve("with-files/scripts/run.py")));
-
-        SkillFileContentVO file = skillStore.readFile(root, "with-files", "scripts/run.py");
-        assertFalse(file.getBinary());
-        assertEquals("scripts/run.py", file.getPath());
-        assertEquals("print('hi')", file.getContent());
-
-        SkillDetailVO detail = skillStore.read(root, "with-files", SkillStore.SOURCE_USER, true, true);
-        assertEquals(List.of("references/spec.md", "scripts/run.py", "testcases/case1.json"),
-                detail.getFiles().stream().map(SkillDetailVO.FileItem::getPath).toList());
-    }
-
-    @Test
-    @DisplayName("保存技能:附属文件路径穿越被拒,SKILL.md 不允许当附属文件提交")
-    void saveRejectsIllegalAttachmentPath() throws IOException {
-        Path root = Files.createTempDirectory("skills-root-");
-        assertEquals(400, assertThrows(ServerException.class,
-                () -> skillStore.save(root, "evil", "", "# x",
-                        List.of(new SkillStore.FileEntry("../escaped.py", "x")), null, false)).getCode());
-        assertFalse(Files.exists(root.getParent().resolve("escaped.py")), "越界文件不应被写出技能目录");
-
-        assertEquals(400, assertThrows(ServerException.class,
-                () -> skillStore.save(root, "evil2", "", "# x",
-                        List.of(new SkillStore.FileEntry("SKILL.md", "x")), null, false)).getCode());
-    }
-
-    @Test
-    @DisplayName("编辑技能:只增删本次提交的附属文件,未提交的保持原样(旧实现会整目录删重建)")
-    void updateKeepsUnsubmittedFiles() throws IOException {
-        Path root = Files.createTempDirectory("skills-root-");
-        skillStore.save(root, "keep", "v1", "# v1",
-                List.of(new SkillStore.FileEntry("scripts/a.py", "a"),
-                        new SkillStore.FileEntry("assets/logo.png", "pseudo-binary")),
-                null, false);
-
-        skillStore.save(root, "keep", "v2", "# v2",
-                List.of(new SkillStore.FileEntry("scripts/a.py", "a2")),
-                List.of("assets/logo.png"), true);
-
-        assertEquals("a2", skillStore.readFile(root, "keep", "scripts/a.py").getContent());
-        assertFalse(Files.exists(root.resolve("keep/assets/logo.png")));
-        assertFalse(Files.exists(root.resolve("keep/assets")), "删空后不应残留空目录");
-        assertEquals("v2",
-                skillStore.read(root, "keep", SkillStore.SOURCE_USER, true, true).getDescription());
-    }
-
-    @Test
-    @DisplayName("读取附属文件:二进制返回 base64;不存在的文件报 404")
-    void readFileBinaryAndMissing() throws IOException {
-        Path root = Files.createTempDirectory("skills-root-");
-        skillStore.save(root, "bin", "d", "# x", null, null, false);
-        byte[] png = new byte[] {(byte) 0x89, 'P', 'N', 'G', 0x00, 0x01, 0x02};
-        Files.createDirectories(root.resolve("bin/assets"));
-        Files.write(root.resolve("bin/assets/logo.png"), png);
-
-        SkillFileContentVO vo = skillStore.readFile(root, "bin", "assets/logo.png");
-        assertTrue(vo.getBinary());
-        assertEquals(Base64.getEncoder().encodeToString(png), vo.getBase64());
-        assertNull(vo.getContent());
-
-        assertEquals(404, assertThrows(ServerException.class,
-                () -> skillStore.readFile(root, "bin", "assets/missing.png")).getCode());
-    }
-}

+ 75 - 0
sql/agent_skill.sql

@@ -0,0 +1,75 @@
+-- ============================================================================
+-- 技能表(agentscope_skills / agentscope_skill_resources)+ 启停表(agent_skill_state)
+--
+-- 设计要点:
+--   1) 技能本体交给官方 JDBC 技能仓库(io.agentscope.extensions.jdbc.skill.JdbcAgentSkillRepository),
+--      两张表的列名/类型/约束必须与方言实现(PostgresDialect)完全一致,
+--      官方仓库初始化时会做结构校验,改列会导致启动失败;
+--   2) 官方用 namespace 做作用域隔离:唯一键 UNIQUE(namespace, name),同一 namespace 内名字唯一、
+--      跨 namespace 可重名。本项目一个用户一个 namespace(namespace = 登录用户 ID 字符串),
+--      所以「不同用户拥有同名技能」是允许的,也不需要额外的归属表;
+--   3) 官方没有启停概念,{@code enabled} 记在本项目自建的 agent_skill_state 里,按
+--      (user_id, skill_name) 唯一 —— 启停是每个用户自己的开关,不影响别人;
+--   4) 技能正文存 skill_content,frontmatter 解析出的 metadata 存 metadata_json;
+--      附属文件存 agentscope_skill_resources(resource_path 为相对路径,二进制按 "base64:" 前缀存文本)。
+--
+-- 执行库:PostgreSQL(master)
+--
+-- ⚠️ 建表由**人工执行本脚本**完成,应用启动时不做 DDL(与 agent_tool_pack.sql 同约定)。
+--    方言配置为 autoCreateTable(false),表不存在时应用启动即报错,属预期行为。
+--
+-- ⚠️ 本脚本是「开发阶段重建」:会先删掉旧结构的两张技能表和旧的 agent_skill_meta。
+--    早期版本(2.1.0-BETA1)建的是无 namespace 的表,结构不兼容,必须重建。
+--    如果库里已有需要保留的技能数据,先导出再执行。
+-- ============================================================================
+
+DROP TABLE IF EXISTS agentscope_skill_resources;
+DROP TABLE IF EXISTS agentscope_skills;
+DROP TABLE IF EXISTS agent_skill_meta;
+
+-- ---------------------------------------------------------------------------
+-- 官方表 1:技能本体(按 namespace 分区)
+-- ---------------------------------------------------------------------------
+CREATE TABLE agentscope_skills (
+  id            BIGSERIAL    PRIMARY KEY,
+  namespace     VARCHAR(64)  NOT NULL DEFAULT 'default',
+  name          VARCHAR(255) NOT NULL,
+  description   TEXT         NOT NULL,
+  skill_content TEXT         NOT NULL,
+  source        VARCHAR(255) NOT NULL,
+  metadata_json TEXT         NULL,
+  created_at    TIMESTAMP    DEFAULT CURRENT_TIMESTAMP,
+  updated_at    TIMESTAMP    DEFAULT CURRENT_TIMESTAMP,
+  CONSTRAINT uk_namespace_name UNIQUE (namespace, name)
+);
+
+-- ---------------------------------------------------------------------------
+-- 官方表 2:技能附属文件(复合主键 + 级联删除 + namespace 索引)
+-- ---------------------------------------------------------------------------
+CREATE TABLE agentscope_skill_resources (
+  id               BIGINT       NOT NULL,
+  namespace        VARCHAR(64)  NOT NULL DEFAULT 'default',
+  resource_path    VARCHAR(500) NOT NULL,
+  resource_content TEXT         NOT NULL,
+  created_at       TIMESTAMP    DEFAULT CURRENT_TIMESTAMP,
+  updated_at       TIMESTAMP    DEFAULT CURRENT_TIMESTAMP,
+  PRIMARY KEY (id, resource_path),
+  FOREIGN KEY (id) REFERENCES agentscope_skills(id) ON DELETE CASCADE
+);
+
+CREATE INDEX agentscope_skill_resources_namespace_idx
+  ON agentscope_skill_resources (namespace);
+
+-- ---------------------------------------------------------------------------
+-- 启停(本项目自建;一个用户的技能开关,不影响其他用户)
+-- ---------------------------------------------------------------------------
+CREATE TABLE agent_skill_state (
+  id         BIGINT       PRIMARY KEY,               -- 应用侧生成(雪花 ID)
+  user_id    BIGINT       NOT NULL,                  -- 登录用户 ID(= 技能所在的 namespace)
+  skill_name VARCHAR(255) NOT NULL,
+  enabled    BOOLEAN      NOT NULL DEFAULT TRUE,     -- false = 不进入该用户的技能目录
+  create_at  TIMESTAMP    DEFAULT now(),
+  update_at  TIMESTAMP    DEFAULT now()
+);
+
+CREATE UNIQUE INDEX uk_agent_skill_state_user_name ON agent_skill_state(user_id, skill_name);