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

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

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

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

View File

@@ -44,7 +44,8 @@ spring:
servlet:
multipart:
max-file-size: 100MB
max-request-size: 100MB
# 为 multipart 边界和请求头预留空间,文件本身仍由 M18 的 100 MiB 硬上限约束。
max-request-size: 105MB
web:
resources:
# 示例windows【file: C:\easyflow\attachment】 linux【file: /www/easyflow/attachment】
@@ -276,7 +277,7 @@ jetcache:
keyConvertor: fastjson2
broadcastChannel: easyflow-cache
valueEncoder: java
valueDecoder: java
valueDecoder: bean:easyFlowJetCacheValueDecoder
poolConfig:
minIdle: 1
maxIdle: 12

View File

@@ -0,0 +1,520 @@
SET NAMES utf8mb4;
-- 所有不可逆 DDL 前先检查旧分类同租户同父级重名;命中时用固定主键冲突显式终止迁移。
CREATE TEMPORARY TABLE `tmp_skill_category_migration_guard` (
`guard_key` TINYINT NOT NULL,
PRIMARY KEY (`guard_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
INSERT INTO `tmp_skill_category_migration_guard` (`guard_key`) VALUES (1);
INSERT INTO `tmp_skill_category_migration_guard` (`guard_key`)
SELECT 1
FROM `tb_skill_category`
WHERE `tenant_id` IS NULL
LIMIT 1;
INSERT INTO `tmp_skill_category_migration_guard` (`guard_key`)
SELECT 1
FROM `tb_skill`
WHERE `tenant_id` IS NULL
LIMIT 1;
INSERT INTO `tmp_skill_category_migration_guard` (`guard_key`)
SELECT 1
FROM `tb_skill_category`
GROUP BY `tenant_id`, IFNULL(`parent_id`, 0), `category_name`
HAVING COUNT(1) > 1
LIMIT 1;
DROP TEMPORARY TABLE `tmp_skill_category_migration_guard`;
-- 旧资源必须归属于有效租户 Skill孤儿记录、空租户或跨租户记录直接终止迁移。
CREATE TEMPORARY TABLE `tmp_skill_resource_owner_guard` (
`guard_key` TINYINT NOT NULL,
PRIMARY KEY (`guard_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
INSERT INTO `tmp_skill_resource_owner_guard` (`guard_key`) VALUES (1);
INSERT INTO `tmp_skill_resource_owner_guard` (`guard_key`)
SELECT 1
FROM (
SELECT reference.`skill_id`, reference.`tenant_id`
FROM `tb_skill_reference` reference
UNION ALL
SELECT script.`skill_id`, script.`tenant_id`
FROM `tb_skill_script` script
UNION ALL
SELECT asset.`skill_id`, asset.`tenant_id`
FROM `tb_skill_asset` asset
) legacy_resource
LEFT JOIN `tb_skill` skill ON skill.`id` = legacy_resource.`skill_id`
WHERE skill.`id` IS NULL
OR skill.`tenant_id` IS NULL
OR (legacy_resource.`tenant_id` IS NOT NULL AND legacy_resource.`tenant_id` <> skill.`tenant_id`)
LIMIT 1;
DROP TEMPORARY TABLE `tmp_skill_resource_owner_guard`;
-- V24 发布快照和迁移前尚未结束的发布审批使用 assets[] 保存二进制引用。
-- 新结构优先使用 resources[];仅在 resources 不是数组时回退 assets[],与运行时兼容读取规则一致。
CREATE TEMPORARY TABLE `tmp_skill_snapshot_content_ref` (
`content_ref` VARCHAR(128) NOT NULL,
`ref_count` INT NOT NULL,
PRIMARY KEY (`content_ref`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
INSERT INTO `tmp_skill_snapshot_content_ref` (`content_ref`, `ref_count`)
SELECT snapshot_ref.`content_ref`, COUNT(1)
FROM (
SELECT legacy_asset.`content_ref`
FROM `tb_skill` skill
JOIN JSON_TABLE(
COALESCE(skill.`published_snapshot_json`, JSON_OBJECT()),
'$.assets[*]' COLUMNS (
`content_ref` VARCHAR(128) PATH '$.contentRef' NULL ON EMPTY NULL ON ERROR
)
) legacy_asset ON TRUE
WHERE COALESCE(JSON_TYPE(JSON_EXTRACT(skill.`published_snapshot_json`, '$.resources')), '') <> 'ARRAY'
UNION ALL
SELECT resource.`content_ref`
FROM `tb_skill` skill
JOIN JSON_TABLE(
COALESCE(skill.`published_snapshot_json`, JSON_OBJECT()),
'$.resources[*]' COLUMNS (
`content_ref` VARCHAR(128) PATH '$.contentRef' NULL ON EMPTY NULL ON ERROR
)
) resource ON TRUE
WHERE JSON_TYPE(JSON_EXTRACT(skill.`published_snapshot_json`, '$.resources')) = 'ARRAY'
UNION ALL
SELECT legacy_asset.`content_ref`
FROM `tb_approval_instance` approval
JOIN JSON_TABLE(
COALESCE(approval.`snapshot_json`, JSON_OBJECT()),
'$.resourceSnapshot.assets[*]' COLUMNS (
`content_ref` VARCHAR(128) PATH '$.contentRef' NULL ON EMPTY NULL ON ERROR
)
) legacy_asset ON TRUE
WHERE approval.`resource_type` = 'SKILL'
AND approval.`action_type` = 'PUBLISH'
AND approval.`status` IN ('PENDING', 'PROCESSING')
AND COALESCE(JSON_TYPE(JSON_EXTRACT(approval.`snapshot_json`, '$.resourceSnapshot.resources')), '') <> 'ARRAY'
UNION ALL
SELECT resource.`content_ref`
FROM `tb_approval_instance` approval
JOIN JSON_TABLE(
COALESCE(approval.`snapshot_json`, JSON_OBJECT()),
'$.resourceSnapshot.resources[*]' COLUMNS (
`content_ref` VARCHAR(128) PATH '$.contentRef' NULL ON EMPTY NULL ON ERROR
)
) resource ON TRUE
WHERE approval.`resource_type` = 'SKILL'
AND approval.`action_type` = 'PUBLISH'
AND approval.`status` IN ('PENDING', 'PROCESSING')
AND JSON_TYPE(JSON_EXTRACT(approval.`snapshot_json`, '$.resourceSnapshot.resources')) = 'ARRAY'
) snapshot_ref
WHERE snapshot_ref.`content_ref` IS NOT NULL
AND snapshot_ref.`content_ref` <> ''
GROUP BY snapshot_ref.`content_ref`;
-- 二进制资源必须有可验证的 sha256 内容索引,否则无法安全修复物理文件关系。
CREATE TEMPORARY TABLE `tmp_skill_content_migration_guard` (
`guard_key` TINYINT NOT NULL,
PRIMARY KEY (`guard_key`)
) ENGINE=InnoDB;
INSERT INTO `tmp_skill_content_migration_guard` (`guard_key`) VALUES (1);
INSERT INTO `tmp_skill_content_migration_guard` (`guard_key`)
SELECT 1
FROM `tb_skill_asset` asset
LEFT JOIN `tb_skill_asset_content` content ON content.`content_ref` = asset.`content_ref`
WHERE content.`content_ref` IS NULL
OR content.`content_ref` NOT REGEXP '^sha256:[0-9a-f]{64}$'
OR content.`content_hash` NOT REGEXP '^[0-9a-fA-F]{64}$'
OR content.`content_ref` <> CONCAT('sha256:', LOWER(content.`content_hash`))
LIMIT 1;
INSERT INTO `tmp_skill_content_migration_guard` (`guard_key`)
SELECT 1
FROM `tb_skill_asset_content` content
WHERE content.`content_ref` NOT REGEXP '^sha256:[0-9a-f]{64}$'
OR content.`content_hash` NOT REGEXP '^[0-9a-fA-F]{64}$'
OR content.`content_ref` <> CONCAT('sha256:', LOWER(content.`content_hash`))
LIMIT 1;
INSERT INTO `tmp_skill_content_migration_guard` (`guard_key`)
SELECT 1
FROM `tmp_skill_snapshot_content_ref` snapshot_ref
LEFT JOIN `tb_skill_asset_content` content ON content.`content_ref` = snapshot_ref.`content_ref`
WHERE content.`content_ref` IS NULL
LIMIT 1;
DROP TEMPORARY TABLE `tmp_skill_content_migration_guard`;
-- 在永久 DDL 前构建已修复的内容迁移源;引用数不得低于真实资源数。
CREATE TEMPORARY TABLE `tmp_skill_content_migration_source` (
`content_ref` VARCHAR(128) NOT NULL,
`content_hash` VARCHAR(128) NOT NULL,
`file_path` VARCHAR(1024) NOT NULL,
`media_type` VARCHAR(128) NULL,
`size` BIGINT NOT NULL,
`ref_count` INT NOT NULL,
`created` DATETIME NULL,
`modified` DATETIME NULL,
PRIMARY KEY (`content_ref`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
INSERT INTO `tmp_skill_content_migration_source` (
`content_ref`, `content_hash`, `file_path`, `media_type`, `size`, `ref_count`, `created`, `modified`
)
SELECT content.`content_ref`, LOWER(content.`content_hash`), content.`file_path`, content.`media_type`,
GREATEST(COALESCE(content.`size`, 0),
COALESCE((SELECT MAX(asset.`size`) FROM `tb_skill_asset` asset
WHERE asset.`content_ref` = content.`content_ref`), 0),
0),
(SELECT COUNT(1) FROM `tb_skill_asset` asset WHERE asset.`content_ref` = content.`content_ref`)
+ COALESCE((SELECT snapshot_ref.`ref_count` FROM `tmp_skill_snapshot_content_ref` snapshot_ref
WHERE snapshot_ref.`content_ref` = content.`content_ref`), 0),
content.`created`, content.`modified`
FROM `tb_skill_asset_content` content;
-- 合并旧资源并修复可确定的 hash/size/租户;跨表路径、大小写或 ID 冲突会显式终止。
CREATE TEMPORARY TABLE `tmp_skill_resource_migration_source` (
`id` BIGINT NOT NULL,
`tenant_id` BIGINT NOT NULL,
`skill_id` BIGINT NOT NULL,
`path` VARCHAR(512) NOT NULL,
`normalized_path` VARCHAR(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
`kind` VARCHAR(32) NOT NULL,
`language` VARCHAR(32) NULL,
`media_type` VARCHAR(128) NULL,
`is_text` TINYINT(1) NOT NULL,
`text_content` MEDIUMTEXT NULL,
`content_ref` VARCHAR(128) NULL,
`content_hash` VARCHAR(128) NOT NULL,
`size` BIGINT NOT NULL,
`metadata_json` JSON NULL,
`sort_no` INT NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_tmp_skill_resource_path` (`skill_id`, `normalized_path`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
INSERT INTO `tmp_skill_resource_migration_source` (
`id`, `tenant_id`, `skill_id`, `path`, `normalized_path`, `kind`, `language`, `media_type`,
`is_text`, `text_content`, `content_ref`, `content_hash`, `size`, `metadata_json`, `sort_no`
)
SELECT reference.`id`, COALESCE(reference.`tenant_id`, skill.`tenant_id`), reference.`skill_id`,
reference.`path`, reference.`path`, 'REFERENCE', 'MARKDOWN', 'text/markdown', 1,
COALESCE(reference.`content`, ''), NULL,
LOWER(SHA2(COALESCE(reference.`content`, ''), 256)),
OCTET_LENGTH(COALESCE(reference.`content`, '')), reference.`metadata_json`, 0
FROM `tb_skill_reference` reference
JOIN `tb_skill` skill ON skill.`id` = reference.`skill_id`
UNION ALL
SELECT script.`id`, COALESCE(script.`tenant_id`, skill.`tenant_id`), script.`skill_id`,
script.`path`, script.`path`, 'SCRIPT', script.`language`, 'text/plain', 1,
COALESCE(script.`content`, ''), NULL,
LOWER(SHA2(COALESCE(script.`content`, ''), 256)),
OCTET_LENGTH(COALESCE(script.`content`, '')), script.`metadata_json`, 0
FROM `tb_skill_script` script
JOIN `tb_skill` skill ON skill.`id` = script.`skill_id`
UNION ALL
SELECT asset.`id`, COALESCE(asset.`tenant_id`, skill.`tenant_id`), asset.`skill_id`,
asset.`path`, asset.`path`, 'ASSET', NULL, asset.`media_type`, 0, NULL, asset.`content_ref`,
content.`content_hash`,
content.`size`, asset.`metadata_json`, 0
FROM `tb_skill_asset` asset
JOIN `tb_skill` skill ON skill.`id` = asset.`skill_id`
JOIN `tmp_skill_content_migration_source` content ON content.`content_ref` = asset.`content_ref`;
SET @skill_summary_columns_ddl = (
SELECT CASE
WHEN COUNT(1) = 0 THEN 'SELECT 1'
ELSE CONCAT('ALTER TABLE `tb_skill` ', GROUP_CONCAT(stmt ORDER BY ord SEPARATOR ', '))
END
FROM (
SELECT 1 AS ord,
'ADD COLUMN `capability_hash` VARCHAR(128) NULL COMMENT ''能力配置 hash'' AFTER `package_hash`' AS stmt
WHERE NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'tb_skill' AND column_name = 'capability_hash'
)
UNION ALL
SELECT 2 AS ord,
'ADD COLUMN `snapshot_hash` VARCHAR(128) NULL COMMENT ''发布快照 hash'' AFTER `capability_hash`' AS stmt
WHERE NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'tb_skill' AND column_name = 'snapshot_hash'
)
UNION ALL
SELECT 3 AS ord,
'ADD COLUMN `resource_count` INT NOT NULL DEFAULT 0 COMMENT ''资源数量'' AFTER `snapshot_hash`' AS stmt
WHERE NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'tb_skill' AND column_name = 'resource_count'
)
UNION ALL
SELECT 4 AS ord,
'ADD COLUMN `capability_count` INT NOT NULL DEFAULT 0 COMMENT ''能力数量'' AFTER `resource_count`' AS stmt
WHERE NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'tb_skill' AND column_name = 'capability_count'
)
) changes
);
PREPARE skill_summary_columns_stmt FROM @skill_summary_columns_ddl;
EXECUTE skill_summary_columns_stmt;
DEALLOCATE PREPARE skill_summary_columns_stmt;
SET @skill_category_parent_scope_ddl = (
SELECT IF(COUNT(1) = 0,
'ALTER TABLE `tb_skill_category` ADD COLUMN `parent_scope_id` BIGINT GENERATED ALWAYS AS (IFNULL(`parent_id`, 0)) STORED COMMENT ''同级唯一约束辅助列''',
'SELECT 1')
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'tb_skill_category'
AND column_name = 'parent_scope_id'
);
PREPARE skill_category_parent_scope_stmt FROM @skill_category_parent_scope_ddl;
EXECUTE skill_category_parent_scope_stmt;
DEALLOCATE PREPARE skill_category_parent_scope_stmt;
-- V24 旧数据可能以 0 表示根分类,统一归一为 NULL与服务层父级语义保持一致。
UPDATE `tb_skill_category`
SET `parent_id` = NULL, `modified` = `modified`, `modified_by` = `modified_by`
WHERE `parent_id` = 0;
-- MySQL DDL 会自动提交;修复 Flyway 失败记录后重跑时不得因索引已创建再次失败。
SET @skill_category_unique_ddl = (
SELECT IF(COUNT(1) = 0,
'ALTER TABLE `tb_skill_category` ADD UNIQUE KEY `uk_skill_category_tenant_parent_name` (`tenant_id`, `parent_scope_id`, `category_name`)',
'SELECT 1')
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'tb_skill_category'
AND index_name = 'uk_skill_category_tenant_parent_name'
);
PREPARE skill_category_unique_stmt FROM @skill_category_unique_ddl;
EXECUTE skill_category_unique_stmt;
DEALLOCATE PREPARE skill_category_unique_stmt;
CREATE TABLE IF NOT EXISTS `tb_skill_resource` (
`id` BIGINT NOT NULL COMMENT 'ID',
`tenant_id` BIGINT NOT NULL COMMENT '租户ID',
`skill_id` BIGINT NOT NULL COMMENT 'Skill ID',
`path` VARCHAR(512) NOT NULL COMMENT '原始逻辑路径',
`normalized_path` VARCHAR(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '规范化逻辑路径',
`kind` VARCHAR(32) NOT NULL COMMENT 'REFERENCE/SCRIPT/ASSET/EXAMPLE/OTHER',
`language` VARCHAR(32) NULL COMMENT '文本或脚本语言',
`media_type` VARCHAR(128) NULL COMMENT '媒体类型',
`is_text` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否为UTF-8文本',
`text_content` MEDIUMTEXT NULL COMMENT '文本内容',
`content_ref` VARCHAR(128) NULL COMMENT '二进制内容引用',
`content_hash` VARCHAR(128) NOT NULL COMMENT '内容hash',
`size` BIGINT NOT NULL DEFAULT 0 COMMENT '字节数',
`metadata_json` JSON NULL COMMENT '资源元数据',
`sort_no` INT NOT NULL DEFAULT 0 COMMENT '排序',
`created` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`created_by` BIGINT NULL COMMENT '创建人',
`modified` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
`modified_by` BIGINT NULL COMMENT '修改人',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_skill_resource_path` (`skill_id`, `normalized_path`),
KEY `idx_skill_resource_tenant_skill` (`tenant_id`, `skill_id`, `sort_no`),
KEY `idx_skill_resource_content_ref` (`content_ref`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Skill 通用资源';
CREATE TABLE IF NOT EXISTS `tb_skill_content` (
`content_ref` VARCHAR(128) NOT NULL COMMENT '内容引用',
`content_hash` VARCHAR(128) NOT NULL COMMENT '内容hash',
`file_path` VARCHAR(1024) NOT NULL COMMENT '存储路径',
`media_type` VARCHAR(128) NULL COMMENT '媒体类型',
`size` BIGINT NOT NULL DEFAULT 0 COMMENT '字节数',
`ref_count` INT NOT NULL DEFAULT 0 COMMENT '引用数',
`created` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`modified` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
PRIMARY KEY (`content_ref`),
KEY `idx_skill_content_hash` (`content_hash`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Skill 二进制内容索引';
CREATE TABLE IF NOT EXISTS `tb_skill_capability_binding` (
`id` BIGINT NOT NULL COMMENT 'ID',
`tenant_id` BIGINT NOT NULL COMMENT '租户ID',
`skill_id` BIGINT NOT NULL COMMENT 'Skill ID',
`capability_type` VARCHAR(32) NOT NULL COMMENT 'WORKFLOW/PLUGIN_ITEM/MCP',
`target_id` BIGINT NULL COMMENT '目标资源ID未解析导入项可为空',
`target_logical_ref` VARCHAR(512) NULL COMMENT '跨环境目标逻辑引用',
`runtime_name` VARCHAR(128) NOT NULL COMMENT '运行时名称或MCP命名空间',
`enabled` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用',
`selection_mode` VARCHAR(16) NULL COMMENT 'MCP工具选择模式',
`selected_tool_names_json` JSON NULL COMMENT '选择的MCP工具名',
`execution_mode` VARCHAR(16) NULL COMMENT 'SYNC/ASYNC',
`hitl_enabled` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否启用人工确认',
`hitl_config_json` JSON NULL COMMENT '人工确认非敏感配置',
`options_json` JSON NULL COMMENT '非敏感扩展配置',
`sort_no` INT NOT NULL DEFAULT 0 COMMENT '排序',
`created` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`created_by` BIGINT NULL COMMENT '创建人',
`modified` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
`modified_by` BIGINT NULL COMMENT '修改人',
PRIMARY KEY (`id`),
KEY `idx_skill_capability_tenant_skill` (`tenant_id`, `skill_id`, `sort_no`),
KEY `idx_skill_capability_target` (`capability_type`, `target_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Skill 平台能力绑定';
CREATE TABLE IF NOT EXISTS `tb_skill_import_stage` (
`import_token` VARCHAR(64) NOT NULL COMMENT '单次导入令牌',
`tenant_id` BIGINT NOT NULL COMMENT '租户ID',
`account_id` BIGINT NOT NULL COMMENT '创建用户ID',
`file_path` VARCHAR(1024) NOT NULL COMMENT '受控临时包路径',
`original_name` VARCHAR(255) NULL COMMENT '原始文件名',
`format` VARCHAR(16) NOT NULL COMMENT 'STANDARD/EASYFLOW',
`status` VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/PROCESSING',
`expires_at` DATETIME NOT NULL COMMENT '过期时间',
`created` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`import_token`),
KEY `idx_skill_import_stage_expire` (`status`, `expires_at`),
KEY `idx_skill_import_stage_owner` (`tenant_id`, `account_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Skill 导入临时包';
-- 兼容 DDL 已部分提交的旧 V27 重跑,强制新表租户边界列不可为空。
UPDATE `tb_skill_resource` target
JOIN `tmp_skill_resource_migration_source` source
ON source.`id` = target.`id`
AND source.`skill_id` = target.`skill_id`
AND source.`normalized_path` = target.`normalized_path`
SET target.`tenant_id` = source.`tenant_id`,
target.`modified` = target.`modified`,
target.`modified_by` = target.`modified_by`
WHERE target.`tenant_id` IS NULL;
UPDATE `tb_skill_capability_binding` binding
JOIN `tb_skill` skill ON skill.`id` = binding.`skill_id`
SET binding.`tenant_id` = skill.`tenant_id`,
binding.`modified` = binding.`modified`,
binding.`modified_by` = binding.`modified_by`
WHERE binding.`tenant_id` IS NULL AND skill.`tenant_id` IS NOT NULL;
CREATE TEMPORARY TABLE `tmp_skill_new_table_tenant_guard` (
`guard_key` TINYINT NOT NULL,
PRIMARY KEY (`guard_key`)
) ENGINE=InnoDB;
INSERT INTO `tmp_skill_new_table_tenant_guard` (`guard_key`) VALUES (1);
INSERT INTO `tmp_skill_new_table_tenant_guard` (`guard_key`)
SELECT 1
FROM `tb_skill_resource` resource
LEFT JOIN `tb_skill` skill ON skill.`id` = resource.`skill_id`
WHERE resource.`tenant_id` IS NULL
OR skill.`id` IS NULL
OR skill.`tenant_id` IS NULL
OR resource.`tenant_id` <> skill.`tenant_id`
LIMIT 1;
INSERT INTO `tmp_skill_new_table_tenant_guard` (`guard_key`)
SELECT 1
FROM `tb_skill_capability_binding` binding
LEFT JOIN `tb_skill` skill ON skill.`id` = binding.`skill_id`
WHERE binding.`tenant_id` IS NULL
OR skill.`id` IS NULL
OR skill.`tenant_id` IS NULL
OR binding.`tenant_id` <> skill.`tenant_id`
LIMIT 1;
DROP TEMPORARY TABLE `tmp_skill_new_table_tenant_guard`;
ALTER TABLE `tb_skill_resource` MODIFY COLUMN `tenant_id` BIGINT NOT NULL COMMENT '租户ID';
ALTER TABLE `tb_skill_resource`
MODIFY COLUMN `normalized_path` VARCHAR(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '规范化逻辑路径';
ALTER TABLE `tb_skill_capability_binding` MODIFY COLUMN `tenant_id` BIGINT NOT NULL COMMENT '租户ID';
-- 如果上一次迁移在 DDL 自动提交后失败,只允许复用与迁移源完全一致的已写入行。
CREATE TEMPORARY TABLE `tmp_skill_target_migration_guard` (
`guard_key` TINYINT NOT NULL,
PRIMARY KEY (`guard_key`)
) ENGINE=InnoDB;
INSERT INTO `tmp_skill_target_migration_guard` (`guard_key`) VALUES (1);
INSERT INTO `tmp_skill_target_migration_guard` (`guard_key`)
SELECT 1
FROM `tb_skill_content` target
JOIN `tmp_skill_content_migration_source` source ON source.`content_ref` = target.`content_ref`
WHERE NOT (
target.`content_hash` <=> source.`content_hash`
AND target.`file_path` <=> source.`file_path`
AND target.`media_type` <=> source.`media_type`
AND target.`size` <=> source.`size`
AND target.`ref_count` <=> source.`ref_count`
AND target.`created` <=> source.`created`
AND target.`modified` <=> source.`modified`
)
LIMIT 1;
INSERT INTO `tmp_skill_target_migration_guard` (`guard_key`)
SELECT 1
FROM `tb_skill_resource` target
JOIN `tmp_skill_resource_migration_source` source
ON source.`id` = target.`id`
OR (source.`skill_id` = target.`skill_id` AND source.`normalized_path` = target.`normalized_path`)
WHERE NOT (
target.`id` <=> source.`id`
AND target.`tenant_id` <=> source.`tenant_id`
AND target.`skill_id` <=> source.`skill_id`
AND target.`path` <=> source.`path`
AND target.`normalized_path` <=> source.`normalized_path`
AND target.`kind` <=> source.`kind`
AND target.`language` <=> source.`language`
AND target.`media_type` <=> source.`media_type`
AND target.`is_text` <=> source.`is_text`
AND target.`text_content` <=> source.`text_content`
AND target.`content_ref` <=> source.`content_ref`
AND target.`content_hash` <=> source.`content_hash`
AND target.`size` <=> source.`size`
AND target.`metadata_json` <=> source.`metadata_json`
AND target.`sort_no` <=> source.`sort_no`
)
LIMIT 1;
DROP TEMPORARY TABLE `tmp_skill_target_migration_guard`;
INSERT INTO `tb_skill_content` (
`content_ref`, `content_hash`, `file_path`, `media_type`, `size`, `ref_count`, `created`, `modified`
)
SELECT source.`content_ref`, source.`content_hash`, source.`file_path`, source.`media_type`,
source.`size`, source.`ref_count`, source.`created`, source.`modified`
FROM `tmp_skill_content_migration_source` source
WHERE NOT EXISTS (
SELECT 1 FROM `tb_skill_content` target WHERE target.`content_ref` = source.`content_ref`
);
INSERT INTO `tb_skill_resource` (
`id`, `tenant_id`, `skill_id`, `path`, `normalized_path`, `kind`, `language`, `media_type`,
`is_text`, `text_content`, `content_ref`, `content_hash`, `size`, `metadata_json`, `sort_no`
)
SELECT source.`id`, source.`tenant_id`, source.`skill_id`, source.`path`, source.`normalized_path`,
source.`kind`, source.`language`, source.`media_type`, source.`is_text`, source.`text_content`,
source.`content_ref`, source.`content_hash`, source.`size`, source.`metadata_json`, source.`sort_no`
FROM `tmp_skill_resource_migration_source` source
WHERE NOT EXISTS (
SELECT 1 FROM `tb_skill_resource` target WHERE target.`id` = source.`id`
);
DROP TEMPORARY TABLE `tmp_skill_resource_migration_source`;
DROP TEMPORARY TABLE `tmp_skill_content_migration_source`;
DROP TEMPORARY TABLE `tmp_skill_snapshot_content_ref`;
UPDATE `tb_skill` skill
SET `resource_count` = (SELECT COUNT(1) FROM `tb_skill_resource` resource WHERE resource.`skill_id` = skill.`id`),
`capability_count` = (SELECT COUNT(1) FROM `tb_skill_capability_binding` binding WHERE binding.`skill_id` = skill.`id`),
`reference_count` = (SELECT COUNT(1) FROM `tb_skill_resource` resource
WHERE resource.`skill_id` = skill.`id` AND resource.`kind` = 'REFERENCE'),
`script_count` = (SELECT COUNT(1) FROM `tb_skill_resource` resource
WHERE resource.`skill_id` = skill.`id` AND resource.`kind` = 'SCRIPT'),
`asset_count` = (SELECT COUNT(1) FROM `tb_skill_resource` resource
WHERE resource.`skill_id` = skill.`id`
AND resource.`kind` NOT IN ('REFERENCE', 'SCRIPT')
AND resource.`is_text` = 0),
`package_hash` = NULL,
`capability_hash` = '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945',
`modified` = `modified`,
`modified_by` = `modified_by`;

View File

@@ -0,0 +1,37 @@
SET NAMES utf8mb4;
INSERT INTO `tb_sys_menu` (`id`, `parent_id`, `menu_type`, `menu_title`, `menu_url`, `component`, `menu_icon`, `is_show`, `permission_tag`, `sort_no`, `status`, `created`, `created_by`, `modified`, `modified_by`, `remark`)
SELECT 367400000000000019, 367400000000000001, 1, '分类管理', '', '', '', 0, '/api/v1/skill/category', 9, 0, CURRENT_TIMESTAMP, 1, CURRENT_TIMESTAMP, 1, 'Skill-分类管理'
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `tb_sys_menu` WHERE `id` = 367400000000000019);
INSERT INTO `tb_sys_menu` (`id`, `parent_id`, `menu_type`, `menu_title`, `menu_url`, `component`, `menu_icon`, `is_show`, `permission_tag`, `sort_no`, `status`, `created`, `created_by`, `modified`, `modified_by`, `remark`)
SELECT 367400000000000020, 367400000000000001, 1, '文件管理', '', '', '', 0, '/api/v1/skill/file', 10, 0, CURRENT_TIMESTAMP, 1, CURRENT_TIMESTAMP, 1, 'Skill-文件管理'
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `tb_sys_menu` WHERE `id` = 367400000000000020);
INSERT INTO `tb_sys_menu` (`id`, `parent_id`, `menu_type`, `menu_title`, `menu_url`, `component`, `menu_icon`, `is_show`, `permission_tag`, `sort_no`, `status`, `created`, `created_by`, `modified`, `modified_by`, `remark`)
SELECT 367400000000000021, 367400000000000001, 1, '导入', '', '', '', 0, '/api/v1/skill/import', 11, 0, CURRENT_TIMESTAMP, 1, CURRENT_TIMESTAMP, 1, 'Skill-导入'
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `tb_sys_menu` WHERE `id` = 367400000000000021);
INSERT INTO `tb_sys_menu` (`id`, `parent_id`, `menu_type`, `menu_title`, `menu_url`, `component`, `menu_icon`, `is_show`, `permission_tag`, `sort_no`, `status`, `created`, `created_by`, `modified`, `modified_by`, `remark`)
SELECT 367400000000000022, 367400000000000001, 1, '导出', '', '', '', 0, '/api/v1/skill/export', 12, 0, CURRENT_TIMESTAMP, 1, CURRENT_TIMESTAMP, 1, 'Skill-导出'
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `tb_sys_menu` WHERE `id` = 367400000000000022);
INSERT INTO `tb_sys_menu` (`id`, `parent_id`, `menu_type`, `menu_title`, `menu_url`, `component`, `menu_icon`, `is_show`, `permission_tag`, `sort_no`, `status`, `created`, `created_by`, `modified`, `modified_by`, `remark`)
SELECT 367400000000000024, 367400000000000001, 1, '能力绑定', '', '', '', 0, '/api/v1/skill/capability', 14, 0, CURRENT_TIMESTAMP, 1, CURRENT_TIMESTAMP, 1, 'Skill-能力绑定'
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `tb_sys_menu` WHERE `id` = 367400000000000024);
INSERT INTO `tb_sys_role_menu` (`id`, `role_id`, `menu_id`)
SELECT 367400000000000119, 1, 367400000000000019 FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM `tb_sys_role_menu` WHERE `role_id` = 1 AND `menu_id` = 367400000000000019);
INSERT INTO `tb_sys_role_menu` (`id`, `role_id`, `menu_id`)
SELECT 367400000000000120, 1, 367400000000000020 FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM `tb_sys_role_menu` WHERE `role_id` = 1 AND `menu_id` = 367400000000000020);
INSERT INTO `tb_sys_role_menu` (`id`, `role_id`, `menu_id`)
SELECT 367400000000000121, 1, 367400000000000021 FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM `tb_sys_role_menu` WHERE `role_id` = 1 AND `menu_id` = 367400000000000021);
INSERT INTO `tb_sys_role_menu` (`id`, `role_id`, `menu_id`)
SELECT 367400000000000122, 1, 367400000000000022 FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM `tb_sys_role_menu` WHERE `role_id` = 1 AND `menu_id` = 367400000000000022);
INSERT INTO `tb_sys_role_menu` (`id`, `role_id`, `menu_id`)
SELECT 367400000000000124, 1, 367400000000000024 FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM `tb_sys_role_menu` WHERE `role_id` = 1 AND `menu_id` = 367400000000000024);

View File

@@ -0,0 +1,29 @@
SET NAMES utf8mb4;
-- 已同时持有正式删除权限的角色先移除重复旧映射,避免唯一索引冲突。
DELETE legacy_mapping
FROM `tb_sys_role_menu` legacy_mapping
INNER JOIN `tb_sys_role_menu` canonical_mapping
ON canonical_mapping.`role_id` = legacy_mapping.`role_id`
AND canonical_mapping.`menu_id` = 367400000000000018
WHERE legacy_mapping.`menu_id` = 367400000000000015;
-- 保留已部署环境中对旧删除入口的显式角色授权,并迁移到真实审批/删除入口。
UPDATE `tb_sys_role_menu`
SET `menu_id` = 367400000000000018
WHERE `menu_id` = 367400000000000015;
-- 删除没有对应 Controller 的历史权限菜单。
DELETE FROM `tb_sys_menu`
WHERE `id` = 367400000000000015
AND `permission_tag` = '/api/v1/skill/remove';
-- 真实入口会根据审批配置提交审批或直接删除,统一使用一个操作权限。
UPDATE `tb_sys_menu`
SET `menu_title` = '删除',
`sort_no` = 5,
`modified` = CURRENT_TIMESTAMP,
`modified_by` = 1,
`remark` = 'Skill-删除(审批或直接执行)'
WHERE `id` = 367400000000000018
AND `permission_tag` = '/api/v1/skill/submitDeleteApproval';

View File

@@ -0,0 +1,31 @@
SET @skill_content_storage_locator_ddl = (
SELECT IF(
EXISTS(
SELECT 1
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'tb_skill_content'
AND column_name = 'storage_locator'
),
'SELECT 1',
'ALTER TABLE `tb_skill_content` ADD COLUMN `storage_locator` VARCHAR(2048) NULL COMMENT ''稳定存储定位符'' AFTER `file_path`'
)
);
PREPARE skill_content_storage_locator_stmt FROM @skill_content_storage_locator_ddl;
EXECUTE skill_content_storage_locator_stmt;
DEALLOCATE PREPARE skill_content_storage_locator_stmt;
CREATE TABLE IF NOT EXISTS `tb_skill_content_write_intent` (
`content_ref` VARCHAR(128) NOT NULL COMMENT '内容引用',
`reservation_token` VARCHAR(128) NOT NULL COMMENT '写入预留令牌',
`content_hash` VARCHAR(128) NOT NULL COMMENT '内容hash',
`storage_locator` VARCHAR(2048) NOT NULL COMMENT '稳定存储定位符',
`media_type` VARCHAR(128) NULL COMMENT '媒体类型',
`size` BIGINT NOT NULL DEFAULT 0 COMMENT '字节数',
`state` VARCHAR(16) NOT NULL COMMENT 'PENDING/WRITING/CLEANING',
`created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`modified` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
PRIMARY KEY (`content_ref`),
KEY `idx_skill_content_write_intent_state_modified` (`state`, `modified`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Skill 内容写入意图';

View File

@@ -0,0 +1,32 @@
SET NAMES utf8mb4;
-- 在任何 DDL 之前阻断无法回填租户的历史实例,避免 MySQL DDL 自动提交留下半迁移结构。
CREATE TEMPORARY TABLE `tmp_approval_instance_tenant_guard` (
`guard_key` TINYINT NOT NULL,
PRIMARY KEY (`guard_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT INTO `tmp_approval_instance_tenant_guard` (`guard_key`) VALUES (1);
INSERT INTO `tmp_approval_instance_tenant_guard` (`guard_key`)
SELECT 1
FROM `tb_approval_instance` approval
LEFT JOIN `tb_sys_account` applicant ON applicant.`id` = approval.`applicant_id`
WHERE applicant.`id` IS NULL OR applicant.`tenant_id` IS NULL
LIMIT 1;
DROP TEMPORARY TABLE `tmp_approval_instance_tenant_guard`;
ALTER TABLE `tb_approval_instance`
ADD COLUMN `tenant_id` BIGINT UNSIGNED NULL COMMENT '租户ID' AFTER `id`;
-- 账号使用逻辑删除,历史申请人仍保留在账号表中,可无歧义回填审批实例租户。
UPDATE `tb_approval_instance` approval
JOIN `tb_sys_account` applicant ON applicant.`id` = approval.`applicant_id`
SET approval.`tenant_id` = applicant.`tenant_id`;
ALTER TABLE `tb_approval_instance`
MODIFY COLUMN `tenant_id` BIGINT UNSIGNED NOT NULL COMMENT '租户ID';
CREATE INDEX `idx_approval_instance_tenant_status`
ON `tb_approval_instance` (`tenant_id`, `status`, `submitted_at`);