1. 概述

MysqlSkillRepositoryAgentSkillRepositoryMySQL 持久化实现。

整体定位:

  1. 分两张表存储:技能主表 + 资源文件表;
  2. 兼容新旧表结构:自动检测是否存在 metadata_json 字段,做新旧版本平滑兼容;
  3. 支持事务、批量插入、防 SQL 注入、读写权限控制;
  4. 负责把 Skill 完整元数据 + 所有资源文件持久入库,作为技能市场的后端存储。

在这里插入图片描述


2. 表结构设计

2.1 主表 agentscope_skills

存储 Skill 核心元信息与完整元 JSON

字段 作用
id 自增主键
name 唯一技能名,作为业务主键
description 简介(注入到system prompt的available_skills)
skill_content SKILL.md正文内容
source 来源标识,用来计算缓存路径<source>
metadata_json 完整元数据树(新版本扩展字段)
created_at/updated_at 自动时间戳

建表语句:

CREATE TABLE IF NOT EXISTS agentscope_skills (
    id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(255) NOT NULL UNIQUE,
    description TEXT NOT NULL,
    skill_content LONGTEXT NOT NULL,
    source VARCHAR(255) NOT NULL,
    metadata_json LONGTEXT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

2.2 资源附表 agentscope_skill_resources

存储 skill 目录下所有资源(style-guide.md、脚本文件等):

字段名 数据类型 约束 说明
id BIGINT NOT NULL 关联技能主表主键ID,外键
resource_path VARCHAR(500) NOT NULL 资源文件相对路径,如 scripts/run.shSKILL.md
resource_content LONGTEXT NOT NULL 文件完整文本内容
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 创建时间
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP 更新时间

建表语句:

CREATE TABLE IF NOT EXISTS agentscope_skills (
    id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(255) NOT NULL UNIQUE,
    description TEXT NOT NULL,
    skill_content LONGTEXT NOT NULL,
    source VARCHAR(255) NOT NULL,
    metadata_json LONGTEXT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

索引与外键:

  1. 联合主键:PRIMARY KEY (id, resource_path)
  2. 外键约束:
FOREIGN KEY (id) REFERENCES agentscope_skills(id) ON DELETE CASCADE

删除技能主数据时,自动清理全部关联资源记录。

3. 构造函数

核心初始化流程:

  1. 入参校验
    • 非空校验 DataSource
    • 用正则校验库名、表名,防止标识符 SQL 注入(表名无法预编译,只能白名单正则)。
  2. 库&表自动初始化 / 存在性校验
    • createIfNotExist=true:执行建库、建表语句,字符集强制 utf8mb4
    • createIfNotExist=false:去 INFORMATION_SCHEMA 校验库和表必须已存在,否则抛异常。
  3. 探测表结构版本
    执行元信息查询,判断是否有 metadata_json 列,结果缓存到成员变量。
  4. 输出初始化日志,标记当前库、两张表名称。

构造函数源码:

    private MysqlSkillRepository(
            DataSource dataSource,
            String databaseName,
            String skillsTableName,
            String resourcesTableName,
            boolean createIfNotExist,
            boolean writeable) {
        // 数据源非空校验
        if (dataSource == null) {
            throw new IllegalArgumentException("DataSource cannot be null");
        }

        this.dataSource = dataSource;
        this.writeable = writeable;

        // 为空则使用默认库名、表名,并去除首尾空格
        this.databaseName =
                (databaseName == null || databaseName.trim().isEmpty())
                        ? DEFAULT_DATABASE_NAME
                        : databaseName.trim();
        this.skillsTableName =
                (skillsTableName == null || skillsTableName.trim().isEmpty())
                        ? DEFAULT_SKILLS_TABLE_NAME
                        : skillsTableName.trim();
        this.resourcesTableName =
                (resourcesTableName == null || resourcesTableName.trim().isEmpty())
                        ? DEFAULT_RESOURCES_TABLE_NAME
                        : resourcesTableName.trim();

        // 校验库名、表名字符串,防止标识符SQL注入
        validateIdentifier(this.databaseName, "Database name");
        validateIdentifier(this.skillsTableName, "Skills table name");
        validateIdentifier(this.resourcesTableName, "Resources table name");

        if (createIfNotExist) {
            // 自动创建库与数据表
            createDatabaseIfNotExist();
            createTablesIfNotExist();
        } else {
            // 校验库和数据表必须已经存在
            verifyDatabaseExists();
            verifyTablesExist();
        }

        // 自动探测是否包含metadata_json扩展字段,用于新旧表结构兼容
        this.metadataJsonColumnSupported = detectMetadataJsonColumnSupport();

        logger.info(
                "MysqlSkillRepository initialized with database: {}, skills table: {},"
                        + " resources table: {}",
                this.databaseName,
                this.skillsTableName,
                this.resourcesTableName);
    }

4. 方法介绍

4.1 查询能力

4.1.1 getSkill(name):单条技能加载

执行两步查询:

  1. 根据技能 name 查主表,拿到自增 id + 内容 + metadata_json
  2. skill.id 关联查询资源表,把所有 resource_path -> content 读到 Map
  3. 调用 buildSkill 组装对象:
    • metadata_jsonJSON 反序列化还原完整元数据,再强制用数据库内的 namedescription 覆盖(以库内字段为准,防止 JSON 脏数据);
    • 无此字段:只保留 name+description 核心字段。

源码说明:

    /**
     * 根据技能名称查询单个Skill完整信息,包含主配置与所有附属资源文件。
     * <p>
     * 执行逻辑:
     * 1. 校验技能名称合法性;
     * 2. 动态拼接SQL,表结构支持metadata_json字段则查询该扩展列;
     * 3. 先查询技能主表,获取主键ID、基础信息与元数据JSON;
     * 4. 通过技能ID关联查询资源附表,把文件路径与文件内容加载到Map;
     * 5. 组装为完整AgentSkill对象返回。
     *
     * @param name 技能唯一名称
     * @return 封装完成的AgentSkill实例
     * @throws IllegalArgumentException 技能名称非法 / 技能不存在时抛出
     * @throws RuntimeException       数据库查询异常统一包装抛出
     */
    @Override
    public AgentSkill getSkill(String name) {
        // 校验技能名称格式,拦截非法字符与超长内容
        validateSkillName(name);

        // 动态SQL:存在metadata_json字段则追加该列,兼容新旧表结构
        String selectSkillSql =
                "SELECT id, name, description, skill_content, source"
                        + (metadataJsonColumnSupported ? ", metadata_json" : "")
                        + " FROM "
                        + getFullTableName(skillsTableName)
                        + " WHERE name = ?";

        // 根据技能主键ID查询所有附属资源文件
        String selectResourcesSql =
                "SELECT resource_path, resource_content FROM "
                        + getFullTableName(resourcesTableName)
                        + " WHERE id = ?";

        // try-with-resources自动关闭连接、语句、结果集,避免资源泄漏
        try (Connection conn = dataSource.getConnection()) {
            long skillId;
            String description;
            String skillContent;
            String source;
            String metadataJson = null;

            // 查询技能主记录
            try (PreparedStatement stmt = conn.prepareStatement(selectSkillSql)) {
                stmt.setString(1, name);
                try (ResultSet rs = stmt.executeQuery()) {
                    if (!rs.next()) {
                        throw new IllegalArgumentException("Skill not found: " + name);
                    }
                    skillId = rs.getLong("id");
                    description = rs.getString("description");
                    skillContent = rs.getString("skill_content");
                    source = rs.getString("source");
                    // 仅在新版表结构下读取扩展元数据
                    if (metadataJsonColumnSupported) {
                        metadataJson = rs.getString("metadata_json");
                    }
                }
            }

            // 加载当前Skill下全部资源文件(脚本、文档、SKILL.md等)
            Map<String, String> resources = new HashMap<>();
            try (PreparedStatement stmt = conn.prepareStatement(selectResourcesSql)) {
                stmt.setLong(1, skillId);
                try (ResultSet rs = stmt.executeQuery()) {
                    while (rs.next()) {
                        String path = rs.getString("resource_path");
                        String content = rs.getString("resource_content");
                        resources.put(path, content);
                    }
                }
            }

            // 把数据库记录组装为业务Skill对象
            return buildSkill(name, description, skillContent, source, metadataJson, resources);

        } catch (SQLException e) {
            throw new RuntimeException("Failed to load skill: " + name, e);
        }
    }

4.1.2 getAllSkills():全量拉取所有技能

做分两次全量查询,避免 N+1

  1. 一次性查出全部主表数据,存入 Map<skillId, 临时记录>
  2. 一次性查出全部资源记录,根据 skillId 回填到对应记录的 resources
  3. 循环批量构建 AgentSkill 对象。

源码说明:

    /**
     * 一次性查询库中全部技能,包含技能主体与所有资源文件,避免N+1查询性能问题。
     * <p>
     * 优化方案:分两次全表查询
     * <ol>
     * <li>批量查询所有技能主表数据,存入Map,以技能ID作为Key;</li>
     * <li>批量查询全部资源附表数据,通过技能ID回填到对应技能的资源集合;</li>
     * <li>遍历临时记录,统一组装成AgentSkill对象集合;</li>
     * <li>对于不存在主记录的孤立资源,打印告警日志。</li>
     * </ol>
     * 自动兼容新旧表结构,存在metadata_json字段时读取扩展元数据。
     *
     * @return 全部技能对象列表
     * @throws RuntimeException 数据库异常统一包装抛出
     */
    @Override
    public List<AgentSkill> getAllSkills() {
        // 动态拼接查询列,新版表追加metadata_json字段
        String selectAllSkillsSql =
                "SELECT id, name, description, skill_content, source"
                        + (metadataJsonColumnSupported ? ", metadata_json" : "")
                        + " FROM "
                        + getFullTableName(skillsTableName)
                        + " ORDER BY name";

        // 一次性查出所有资源文件,后续按ID关联
        String selectAllResourcesSql =
                "SELECT id, resource_path, resource_content FROM "
                        + getFullTableName(resourcesTableName);

        try (Connection conn = dataSource.getConnection()) {
            // 临时存储所有技能主记录,key = 技能主键ID
            Map<Long, LoadedSkillRecord> skillRecords = new HashMap<>();

            // 第一步:加载全部技能主体信息
            try (PreparedStatement stmt = conn.prepareStatement(selectAllSkillsSql);
                 ResultSet rs = stmt.executeQuery()) {
                while (rs.next()) {
                    long skillId = rs.getLong("id");
                    String name = rs.getString("name");
                    String description = rs.getString("description");
                    String skillContent = rs.getString("skill_content");
                    String source = rs.getString("source");
                    String metadataJson =
                            metadataJsonColumnSupported ? rs.getString("metadata_json") : null;

                    skillRecords.put(
                            skillId,
                            new LoadedSkillRecord(
                                    name, description, skillContent, source, metadataJson));
                }
            }

            // 第二步:加载全部资源,根据skillId回填到对应技能
            try (PreparedStatement stmt = conn.prepareStatement(selectAllResourcesSql);
                 ResultSet rs = stmt.executeQuery()) {
                while (rs.next()) {
                    long skillId = rs.getLong("id");
                    String resourcePath = rs.getString("resource_path");
                    String resourceContent = rs.getString("resource_content");

                    LoadedSkillRecord record = skillRecords.get(skillId);
                    if (record != null) {
                        record.resources.put(resourcePath, resourceContent);
                    } else {
                        // 存在无主资源,打印警告
                        logger.warn("Found orphaned resource for non-existent id: {}", skillId);
                    }
                }
            }

            // 统一构建AgentSkill实例
            List<AgentSkill> skills = new ArrayList<>(skillRecords.size());
            for (LoadedSkillRecord record : skillRecords.values()) {
                try {
                    skills.add(
                            buildSkill(
                                    record.name,
                                    record.description,
                                    record.skillContent,
                                    record.source,
                                    record.metadataJson,
                                    record.resources));
                } catch (Exception e) {
                    logger.warn("Failed to build skill: {}", e.getMessage(), e);
                }
            }

            return skills;

        } catch (SQLException e) {
            throw new RuntimeException("Failed to load all skills", e);
        }
    }

4.1.3 getAllSkillNames()

只查询 name 字段,用于生成 <available_skills> 精简元列表,轻量化获取技能目录。

源码说明:

    /**
     * 查询所有技能名称,仅拉取name字段,用于组装可用技能清单,轻量化加载。
     * <p>
     * 该接口主要用于生成System Prompt中的available_skills元数据列表,只查询名称不加载完整内容,减少IO开销。
     *
     * @return 按名称升序排列的技能名称集合
     * @throws RuntimeException 数据库查询异常时抛出
     */
    @Override
    public List<String> getAllSkillNames() {
        // 只查询技能名称,按名称排序
        String selectSql =
                "SELECT name FROM " + getFullTableName(skillsTableName) + " ORDER BY name";

        List<String> skillNames = new ArrayList<>();

        // try-with-resources自动释放数据库资源
        try (Connection conn = dataSource.getConnection();
             PreparedStatement stmt = conn.prepareStatement(selectSql);
             ResultSet rs = stmt.executeQuery()) {

            while (rs.next()) {
                skillNames.add(rs.getString("name"));
            }

        } catch (SQLException e) {
            throw new RuntimeException("Failed to list skill names", e);
        }

        return skillNames;
    }

4.1.4 skillExists()

预校验技能是否存在,用于 save 时冲突判断。

源码说明:

    /**
     * 校验指定名称的技能是否已存在。
     * <p>
     * 仅做轻量级主键查询,用于保存技能前的冲突前置校验。
     *
     * @param skillName 技能名称
     * @return 存在返回true,不存在或入参为空直接返回false
     * @throws RuntimeException 数据库执行异常时包装抛出
     */
    @Override
    public boolean skillExists(String skillName) {
        // 空字符串直接判定不存在,避免无效查询
        if (skillName == null || skillName.isEmpty()) {
            return false;
        }

        try (Connection conn = dataSource.getConnection()) {
            // 复用内部方法,支持复用现有连接
            return skillExistsInternal(conn, skillName);
        } catch (SQLException e) {
            throw new RuntimeException("Failed to check skill existence: " + skillName, e);
        }
    }

    /**
     * 在已有数据库连接上执行技能存在性校验,支持事务内复用连接。
     * <p>
     * SQL增加LIMIT 1命中索引,查到第一条立即终止扫描,提升查询性能。
     *
     * @param conn      已打开的数据库连接
     * @param skillName 待校验的技能名称
     * @return 技能存在返回true
     * @throws SQLException SQL执行异常
     */
    private boolean skillExistsInternal(Connection conn, String skillName) throws SQLException {
        // 只查询常量1,减少数据传输;LIMIT 1避免全表扫描
        String checkSql =
                "SELECT 1 FROM " + getFullTableName(skillsTableName) + " WHERE name = ? LIMIT 1";

        try (PreparedStatement stmt = conn.prepareStatement(checkSql)) {
            stmt.setString(1, skillName);
            try (ResultSet rs = stmt.executeQuery()) {
                // 有结果行即代表技能已存在
                return rs.next();
            }
        }
    }

4.2 写入能力

前置校验(事务外提前拦截,避免事务回滚开销):

  1. 校验只读标识,只读库直接返回 false
  2. 批量校验所有 skill 名称、资源路径长度与非法字符;
  3. force=false 时提前批量查重,一旦存在冲突直接抛出异常,不开启事务。

事务原子保障:

  1. 关闭自动提交,开启事务;
  2. 循环处理每条 Skill
    • 已存在 + force=true:先执行删除(级联清空资源);
    • 插入主表,拿到自增主键skillId;
    • 使用 JDBC 批量 Batch 插入全部资源文件,减少网络 IO
  3. 全部成功则 commit,任意异常 rollback
  4. finally 恢复 autoCommit 状态。

save 方法源码:

    /**
     * 批量持久化多个技能,支持覆盖写入与事务原子提交。
     *
     * <p>【前置校验:全部放在事务外部,减少事务回滚带来的性能损耗】
     * <ol>
     * <li>先判断读写权限:只读仓库直接返回false,不执行后续逻辑;</li>
     * <li>批量校验所有Skill名称、资源路径的长度与非法字符,提前拦截脏数据;</li>
     * <li>当force=false时,提前批量查重,一旦发现重名技能直接抛出异常,不开启数据库事务。</li>
     * </ol>
     *
     * <p>【事务原子保障】
     * <ol>
     * <li>关闭连接自动提交,开启事务;</li>
     * <li>遍历每一条Skill:
     *     <ul>
     *         <li>如果技能已存在并且force=true,先删除原有记录,依靠外键级联自动清理资源数据;</li>
     *         <li>插入技能主记录,获取数据库自增主键skillId;</li>
     *         <li>使用JDBC Batch批量插入该技能下全部资源文件,减少多次网络IO开销;</li>
     *     </ul>
     * </li>
     * <li>全部写入无异常执行commit;任意异常执行rollback,保证主表与资源附表写入原子性;</li>
     * <li>finally块强制恢复autoCommit连接属性,避免连接池连接状态异常。</li>
     * </ol>
     *
     * <p>【新旧表版本写入分支兼容】
     * <ol>
     * <li>新版本表(存在metadata_json字段):将完整的元数据Map序列化为JSON字符串存入扩展字段;</li>
     * <li>旧版本表(无metadata_json字段):如果检测到存在name、description之外的扩展元数据,则打印降级告警,仅持久化基础字段,丢弃扩展元数据。</li>
     * </ol>
     *
     * @param skills 待保存的技能集合
     * @param force  true:覆盖已存在的同名技能;false:重名直接报错禁止覆盖
     * @return 写入成功返回true;入参为空/仓库只读返回false
     * @throws IllegalStateException  force=false时存在重名技能抛出冲突异常
     * @throws RuntimeException      SQL执行异常统一包装抛出
     */
    @Override
    public boolean save(List<AgentSkill> skills, boolean force) {
        if (skills == null || skills.isEmpty()) {
            return false;
        }

        // 前置校验1:只读仓库直接拦截写入
        if (!writeable) {
            logger.warn("Cannot save skills: repository is read-only");
            return false;
        }

        try (Connection conn = dataSource.getConnection()) {
            // 前置校验2:事务开启前完成全部参数合法性校验,避免事务开启后回滚
            for (AgentSkill skill : skills) {
                validateSkillName(skill.getName());
                Map<String, String> resources = skill.getResources();
                if (resources != null && !resources.isEmpty()) {
                    for (String path : resources.keySet()) {
                        validateResourcePath(path);
                    }
                }
            }

            // 前置校验3:非强制覆盖模式下,提前批量检查冲突,事务外快速失败
            if (!force) {
                List<String> existingSkills = new ArrayList<>();
                for (AgentSkill skill : skills) {
                    if (skillExistsInternal(conn, skill.getName())) {
                        existingSkills.add(skill.getName());
                    }
                }
                if (!existingSkills.isEmpty()) {
                    String conflictingSkills = String.join(", ", existingSkills);
                    throw new IllegalStateException(
                            "Cannot save skills: the following skills already exist and"
                                    + " force=false: "
                                    + conflictingSkills
                                    + ". Use force=true to overwrite existing skills.");
                }
            }

            // 开启事务,保证主表+资源附表写入原子性
            conn.setAutoCommit(false);

            try {
                for (AgentSkill skill : skills) {
                    String skillName = skill.getName();
                    boolean exists = skillExistsInternal(conn, skillName);

                    // 强制覆盖:先删除旧数据,资源通过外键ON DELETE CASCADE自动清空
                    if (exists) {
                        deleteSkillInternal(conn, skillName);
                        logger.debug("Deleted existing skill for overwrite: {}", skillName);
                    }

                    // 插入主表记录,拿到自增主键
                    long skillId = insertSkill(conn, skill);
                    // Batch批量插入全部资源文件
                    insertResources(conn, skillId, skill.getResources());

                    logger.info("Successfully saved skill: {} (id={})", skillName, skillId);
                }

                conn.commit();
                return true;

            } catch (Exception e) {
                // 任意异常回滚事务
                conn.rollback();
                throw e;
            } finally {
                // 强制恢复连接自动提交状态,归还连接池时保持连接属性正常
                restoreAutoCommit(conn);
            }

        } catch (SQLException e) {
            logger.error("Failed to save skills", e);
            throw new RuntimeException("Failed to save skills", e);
        }
    }

insertSkill 方法源码:


    /**
     * 插入技能主记录,自动区分新旧表结构做兼容写入。
     * <p>版本分支:
     * 1. 表包含metadata_json:序列化完整元数据存入扩展字段;
     * 2. 表无metadata_json:若存在扩展元数据则打印警告,仅持久化name、description基础字段。
     *
     * @param conn 数据库事务连接
     * @param skill 待写入技能对象
     * @return 数据库自增主键ID
     * @throws SQLException SQL执行异常
     */
    private long insertSkill(Connection conn, AgentSkill skill) throws SQLException {
        String insertSql =
                "INSERT INTO "
                        + getFullTableName(skillsTableName)
                        + (metadataJsonColumnSupported
                                ? " (name, description, skill_content, source, metadata_json)"
                                        + " VALUES (?, ?, ?, ?, ?)"
                                : " (name, description, skill_content, source) VALUES (?, ?, ?,"
                                        + " ?)");

        try (PreparedStatement stmt =
                conn.prepareStatement(insertSql, Statement.RETURN_GENERATED_KEYS)) {
            stmt.setString(1, skill.getName());
            stmt.setString(2, skill.getDescription());
            stmt.setString(3, skill.getSkillContent());
            stmt.setString(4, skill.getSource());
            // 新版表写入完整元JSON
            if (metadataJsonColumnSupported) {
                stmt.setString(5, serializeMetadata(skill.getMetadata()));
            } else if (hasExtendedMetadata(skill.getMetadata())) {
                // 旧表无扩展字段,额外元数据无法持久化,输出告警日志
                logger.warn(
                        "metadata_json column not found in {}.{}; extended metadata for skill '{}'"
                                + " will not be persisted",
                        databaseName,
                        skillsTableName,
                        skill.getName());
            }
            stmt.executeUpdate();

            try (ResultSet generatedKeys = stmt.getGeneratedKeys()) {
                if (generatedKeys.next()) {
                    return generatedKeys.getLong(1);
                } else {
                    throw new SQLException(
                            "Failed to get generated id for skill: " + skill.getName());
                }
            }
        }
    }

批量插入所有资源文件方法源码:

    /**
     * 使用JDBC批量Batch一次性插入所有资源文件,减少多次网络往返。
     *
     * @param conn 事务连接
     * @param skillId 技能主表主键
     * @param resources 资源文件路径+内容Map
     * @throws SQLException 批量写入失败抛出异常触发事务回滚
     */
    private void insertResources(Connection conn, long skillId, Map<String, String> resources)
            throws SQLException {
        if (resources == null || resources.isEmpty()) {
            logger.debug("No resources to insert for id: {}", skillId);
            return;
        }

        String insertSql =
                "INSERT INTO "
                        + getFullTableName(resourcesTableName)
                        + " (id, resource_path, resource_content) VALUES (?, ?, ?)";

        try (PreparedStatement stmt = conn.prepareStatement(insertSql)) {
            for (Map.Entry<String, String> entry : resources.entrySet()) {
                String path = entry.getKey();
                String content = entry.getValue();
                stmt.setLong(1, skillId);
                stmt.setString(2, path);
                stmt.setString(3, content);
                stmt.addBatch();
            }

            int[] results = stmt.executeBatch();
            int insertedCount = 0;
            for (int i = 0; i < results.length; i++) {
                if (results[i] > 0 || results[i] == Statement.SUCCESS_NO_INFO) {
                    insertedCount++;
                } else if (results[i] == Statement.EXECUTE_FAILED) {
                    logger.error("Failed to insert resource at batch index {}", i);
                }
            }

            logger.debug(
                    "Batch inserted {} resources for id '{}' (total: {})",
                    insertedCount,
                    skillId,
                    resources.size());

            if (insertedCount != resources.size()) {
                throw new SQLException(
                        "Failed to insert all resources for id '"
                                + skillId
                                + "'. Expected: "
                                + resources.size()
                                + ", Inserted: "
                                + insertedCount);
            }
        }
    }

写入分支(版本兼容,老版本以前建的表,没有 metadata_json 这一列):

  • 新版本(有 metadata_json):把完整 Metadata 序列化为 JSON 存入字段;
  • 旧版本:检测是否存在扩展字段,如果有则打印告警,只保存基础字段。

4.3 删除能力

执行逻辑:

  1. 开启事务;
  2. 直接删除主表记录,资源表依靠外键 ON DELETE CASCADE 自动清空,不需要手动删子表;
  3. 成功提交,失败回滚。

源码说明:

    /**
     * 根据技能名称删除技能。
     * <p>
     * 1. 只读仓库直接拒绝删除操作;
     * 2. 开启事务保证操作原子性;
     * 3. 仅删除主表记录,资源附表依靠外键 ON DELETE CASCADE 自动级联删除,无需手动删资源;
     * 4. 成功提交,失败回滚,最后恢复连接自动提交属性。
     *
     * @param skillName 待删除的技能名称
     * @return true 删除成功;false 技能不存在或仓库只读
     * @throws RuntimeException 数据库异常包装抛出
     */
    @Override
    public boolean delete(String skillName) {
        // 前置判断:只读仓库禁止删除
        if (!writeable) {
            logger.warn("Cannot delete skill: repository is read-only");
            return false;
        }

        // 校验技能名称合法性
        validateSkillName(skillName);

        try (Connection conn = dataSource.getConnection()) {
            // 先判断技能是否存在
            if (!skillExistsInternal(conn, skillName)) {
                logger.warn("Skill does not exist: {}", skillName);
                return false;
            }

            // 开启事务
            conn.setAutoCommit(false);
            try {
                // 删除主表数据,资源表自动级联清空
                deleteSkillInternal(conn, skillName);
                conn.commit();
                logger.info("Successfully deleted skill: {}", skillName);
                return true;
            } catch (Exception e) {
                // 异常回滚
                conn.rollback();
                throw e;
            } finally {
                // 恢复连接自动提交,防止连接池连接状态异常
                restoreAutoCommit(conn);
            }

        } catch (SQLException e) {
            logger.error("Failed to delete skill: {}", skillName, e);
            throw new RuntimeException("Failed to delete skill: " + skillName, e);
        }
    }

    /**
     * 内部执行删除SQL,只删除技能主表记录。
     * 资源附表会通过外键约束 ON DELETE CASCADE 自动删除,不用额外执行DELETE。
     *
     * @param conn      事务数据库连接
     * @param skillName 技能名称
     * @throws SQLException SQL执行异常
     */
    private void deleteSkillInternal(Connection conn, String skillName) throws SQLException {
        String deleteSkillSql =
                "DELETE FROM " + getFullTableName(skillsTableName) + " WHERE name = ?";

        try (PreparedStatement stmt = conn.prepareStatement(deleteSkillSql)) {
            stmt.setString(1, skillName);
            stmt.executeUpdate();
        }
    }

4.4 辅助能力

除了增删改查核心业务,代码里封装了一整套辅助工具方法,用来做安全校验:

  1. clearAllSkills():清空全表,测试使用;
  2. getSource():生成字符串标识 mysql_库名_表名,用来区分不同数据源,自动处理重名仓库后缀,对应前文 <source> 字段;
  3. getRepositoryInfo:对外暴露存储类型与读写状态。

Logo

小龙虾开发者社区是 CSDN 旗下专注 OpenClaw 生态的官方阵地,聚焦技能开发、插件实践与部署教程,为开发者提供可直接落地的方案、工具与交流平台,助力高效构建与落地 AI 应用

更多推荐