From 5b3aeadb5fadfd6617cdb858b2c14412e5f45f06 Mon Sep 17 00:00:00 2001 From: let5sne Date: Sun, 12 Jul 2026 01:54:10 +0800 Subject: [PATCH] fix(personal): align schema with phase two contract --- .../personal/PersonalSchemaContractTest.java | 99 ++++++++++++++++--- .../sql/aihr_personal_knowledge_mysql8.sql | 64 ++++++------ 2 files changed, 118 insertions(+), 45 deletions(-) diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalSchemaContractTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalSchemaContractTest.java index 8b82c89c..3f50f13f 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalSchemaContractTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalSchemaContractTest.java @@ -18,33 +18,106 @@ class PersonalSchemaContractTest { @Test void personalKnowledgeSchemaIsOwnerScopedAndIndependent() throws IOException { - Path sqlPath = locateSqlDirectory().resolve(SCHEMA_FILE); + Path projectRoot = locateProjectRoot(); + Path sqlPath = projectRoot.resolve("backend/script/sql").resolve(SCHEMA_FILE); assertTrue(Files.isRegularFile(sqlPath), "Missing personal knowledge schema: " + sqlPath); String sql = Files.readString(sqlPath).toLowerCase(Locale.ROOT); - assertTrue(sql.contains("create table if not exists `aihr_personal_space`")); - assertTrue(sql.contains("create table if not exists `aihr_personal_item`")); - assertTrue(sql.contains("create table if not exists `aihr_personal_fragment`")); - assertTrue(sql.contains("create table if not exists `aihr_personal_chat_session`")); - assertTrue(sql.contains("create table if not exists `aihr_personal_chat_message`")); - assertTrue(sql.contains("`owner_user_id`"), "Personal records must be owner scoped"); - assertTrue(sql.contains("fulltext key"), "Personal fragments must provide full-text search"); + String space = tableDefinition(sql, "aihr_personal_space"); + String item = tableDefinition(sql, "aihr_personal_item"); + String fragment = tableDefinition(sql, "aihr_personal_fragment"); + String session = tableDefinition(sql, "aihr_personal_chat_session"); + String message = tableDefinition(sql, "aihr_personal_chat_message"); + + for (String definition : new String[] {space, item, fragment, session, message}) { + assertTrue(definition.contains("`owner_user_id` bigint not null"), + "Every personal table must carry a non-null owner_user_id"); + } + + assertTrue(space.contains("`quota_bytes` bigint not null default 524288000"), + "Personal space must default to a 500 MiB quota"); + assertTrue(space.contains("`status` varchar(20) not null default 'active'")); + assertTrue(space.contains("`used_bytes` bigint not null default 0")); + assertTrue(space.contains("`item_count` int not null default 0")); + assertTrue(space.contains("unique key `uk_personal_space_owner` (`tenant_id`, `owner_user_id`)")); + + assertTrue(item.contains("`original_url` varchar(2000) default null")); + assertTrue(item.contains("`content_hash` varchar(64) not null")); + assertTrue(item.contains("`status` varchar(20) not null default 'queued'")); + assertTrue(item.contains("`error_code` varchar(80) default null")); + assertTrue(item.contains("`error_message` varchar(500) default null")); + assertTrue(item.contains("`tags_json` json")); + assertTrue(item.contains("`captured_at` datetime not null")); + assertTrue(item.contains("`parsed_at` datetime default null")); + assertTrue(item.contains("`deleted_at` datetime default null")); + assertTrue(item.contains( + "key `idx_personal_item_owner` (`tenant_id`, `owner_user_id`, `status`, `create_time`)")); + assertTrue(item.contains( + "key `idx_personal_item_date` (`tenant_id`, `owner_user_id`, `captured_at`)")); + assertTrue(item.contains("key `idx_personal_item_hash` (`space_id`, `content_hash`)")); + assertFalse(item.contains("`source_url`")); + assertFalse(item.contains("`last_error`")); + assertFalse(item.contains("`captured_time`")); + + assertTrue(fragment.contains("`space_id` bigint not null")); + assertTrue(fragment.contains("`idx` int not null")); + assertTrue(fragment.contains("`embedding_json` longtext")); + assertTrue(fragment.contains("`embedding_model` varchar(100) default null")); + assertTrue(fragment.contains("`embedding_time` datetime default null")); + assertTrue(fragment.contains("unique key `uk_personal_fragment_idx` (`item_id`, `idx`)")); + assertTrue(fragment.contains( + "key `idx_personal_fragment_owner` (`tenant_id`, `owner_user_id`, `item_id`)")); + assertTrue(fragment.contains( + "fulltext key `ft_personal_fragment_content` (`content`) with parser ngram"), + "Personal fragments must use the MySQL ngram full-text parser"); + + assertTrue(session.contains("`default_scope` varchar(50) not null default 'personal'")); + assertTrue(session.contains( + "key `idx_personal_session_owner` (`tenant_id`, `owner_user_id`, `update_time`)")); + + assertTrue(message.contains("`scope_json` json")); + assertTrue(message.contains("`citations_json` json")); + assertTrue(message.contains("`model_name` varchar(100) default null")); + assertTrue(message.contains("`prompt_version` varchar(100) default null")); + assertTrue(message.contains("`input_tokens` int not null default 0")); + assertTrue(message.contains("`output_tokens` int not null default 0")); + assertTrue(message.contains("`latency_ms` bigint not null default 0")); + assertTrue(message.contains( + "key `idx_personal_message_owner` (`tenant_id`, `owner_user_id`, `session_id`, `create_time`)")); + assertFalse(sql.contains("alter table aihr_knowledge_fragment"), "Personal schema must not mutate enterprise knowledge tables"); assertFalse(sql.contains("alter table `aihr_knowledge_fragment`"), "Personal schema must not mutate enterprise knowledge tables"); + + String resetScript = Files.readString(projectRoot.resolve("scripts/reset-dev-db.sh")); + int knowledgeImport = resetScript.indexOf("aihr_knowledge_mysql8.sql"); + int personalImport = resetScript.indexOf(SCHEMA_FILE); + assertTrue(knowledgeImport >= 0 && personalImport > knowledgeImport, + "reset-dev-db.sh must import personal schema after enterprise knowledge schema"); + assertTrue(resetScript.contains("--default-character-set=utf8mb4 ry-vue < " + + "\"$ROOT_DIR/backend/script/sql/" + SCHEMA_FILE + "\"")); } - private Path locateSqlDirectory() { + private String tableDefinition(String sql, String tableName) { + String marker = "create table if not exists `" + tableName + "`"; + int start = sql.indexOf(marker); + assertTrue(start >= 0, "Missing table definition: " + tableName); + int end = sql.indexOf(';', start); + assertTrue(end > start, "Unterminated table definition: " + tableName); + return sql.substring(start, end).replaceAll("\\s+", " "); + } + + private Path locateProjectRoot() { Path current = Path.of("").toAbsolutePath().normalize(); while (current != null) { - Path candidate = current.resolve("backend/script/sql"); - if (Files.isDirectory(candidate)) { - return candidate; + if (Files.isDirectory(current.resolve("backend/script/sql")) + && Files.isRegularFile(current.resolve("scripts/reset-dev-db.sh"))) { + return current; } current = current.getParent(); } - throw new IllegalStateException("Unable to locate backend/script/sql from " + throw new IllegalStateException("Unable to locate project root from " + Path.of("").toAbsolutePath()); } } diff --git a/backend/script/sql/aihr_personal_knowledge_mysql8.sql b/backend/script/sql/aihr_personal_knowledge_mysql8.sql index 135c41ef..463eccf3 100644 --- a/backend/script/sql/aihr_personal_knowledge_mysql8.sql +++ b/backend/script/sql/aihr_personal_knowledge_mysql8.sql @@ -13,84 +13,84 @@ CREATE TABLE IF NOT EXISTS `aihr_personal_space` ( `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`), - UNIQUE KEY `uk_aihr_personal_space_owner` (`tenant_id`, `owner_user_id`) + UNIQUE KEY `uk_personal_space_owner` (`tenant_id`, `owner_user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='个人AI助理知识空间'; CREATE TABLE IF NOT EXISTS `aihr_personal_item` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', `tenant_id` varchar(20) NOT NULL DEFAULT '000000' COMMENT '租户编号', - `owner_user_id` bigint NOT NULL COMMENT '资料所属用户ID', - `owner_ext_party_id` varchar(100) DEFAULT NULL COMMENT '外部组织主体ID', `space_id` bigint NOT NULL COMMENT '个人空间ID', - `source_type` varchar(30) NOT NULL COMMENT '来源类型: text/file/url', + `owner_user_id` bigint NOT NULL COMMENT '资料所属用户ID', + `source_type` varchar(20) NOT NULL COMMENT '来源类型: text/file/url', `title` varchar(500) NOT NULL COMMENT '资料标题', - `source_url` varchar(2000) DEFAULT NULL COMMENT '原始网址', + `original_url` varchar(2000) DEFAULT NULL COMMENT '原始网址', `oss_id` bigint DEFAULT NULL COMMENT '对象存储ID', - `oss_url` varchar(2000) DEFAULT NULL COMMENT '对象存储访问地址', - `mime_type` varchar(255) DEFAULT NULL COMMENT 'MIME类型', + `mime_type` varchar(100) DEFAULT NULL COMMENT 'MIME类型', `size_bytes` bigint NOT NULL DEFAULT 0 COMMENT '资料大小', - `content_hash` varchar(128) DEFAULT NULL COMMENT '内容摘要哈希', - `status` varchar(30) NOT NULL DEFAULT 'PENDING' COMMENT '处理状态', + `content_hash` varchar(64) NOT NULL COMMENT '内容摘要哈希', + `status` varchar(20) NOT NULL DEFAULT 'QUEUED' COMMENT '处理状态', `attempt_count` int NOT NULL DEFAULT 0 COMMENT '处理尝试次数', - `last_error` varchar(1000) DEFAULT NULL COMMENT '最后错误信息', + `error_code` varchar(80) DEFAULT NULL COMMENT '错误代码', + `error_message` varchar(500) DEFAULT NULL COMMENT '错误信息', `summary` text COMMENT '资料摘要', - `tags_json` longtext COMMENT '标签JSON', - `captured_time` datetime DEFAULT NULL COMMENT '采集完成时间', - `parsed_time` datetime DEFAULT NULL COMMENT '解析完成时间', - `deleted_time` datetime DEFAULT NULL COMMENT '软删除时间', + `tags_json` json COMMENT '标签JSON', + `captured_at` datetime NOT NULL COMMENT '采集完成时间', + `parsed_at` datetime DEFAULT NULL COMMENT '解析完成时间', + `deleted_at` datetime DEFAULT NULL COMMENT '软删除时间', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`), - KEY `idx_aihr_personal_item_owner_status` (`tenant_id`, `owner_user_id`, `status`, `update_time`), - KEY `idx_aihr_personal_item_space` (`space_id`, `id`), - KEY `idx_aihr_personal_item_hash` (`tenant_id`, `owner_user_id`, `content_hash`) + KEY `idx_personal_item_owner` (`tenant_id`, `owner_user_id`, `status`, `create_time`), + KEY `idx_personal_item_date` (`tenant_id`, `owner_user_id`, `captured_at`), + KEY `idx_personal_item_hash` (`space_id`, `content_hash`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='个人AI助理资料'; CREATE TABLE IF NOT EXISTS `aihr_personal_fragment` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', `tenant_id` varchar(20) NOT NULL DEFAULT '000000' COMMENT '租户编号', + `space_id` bigint NOT NULL COMMENT '个人空间ID', `owner_user_id` bigint NOT NULL COMMENT '片段所属用户ID', - `owner_ext_party_id` varchar(100) DEFAULT NULL COMMENT '外部组织主体ID', `item_id` bigint NOT NULL COMMENT '个人资料ID', - `fragment_index` int NOT NULL COMMENT '片段序号', + `idx` int NOT NULL COMMENT '片段序号', `content` text NOT NULL COMMENT '片段内容', `token_count` int NOT NULL DEFAULT 0 COMMENT '估算Token数', - `embedding_model` varchar(100) DEFAULT NULL COMMENT '向量模型', `embedding_json` longtext COMMENT '向量JSON', + `embedding_model` varchar(100) DEFAULT NULL COMMENT '向量模型', + `embedding_time` datetime DEFAULT NULL COMMENT '向量生成时间', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', PRIMARY KEY (`id`), - UNIQUE KEY `uk_aihr_personal_fragment_item_idx` (`item_id`, `fragment_index`), - KEY `idx_aihr_personal_fragment_owner` (`tenant_id`, `owner_user_id`, `item_id`), - FULLTEXT KEY `ft_aihr_personal_fragment_content` (`content`) WITH PARSER ngram + UNIQUE KEY `uk_personal_fragment_idx` (`item_id`, `idx`), + KEY `idx_personal_fragment_owner` (`tenant_id`, `owner_user_id`, `item_id`), + FULLTEXT KEY `ft_personal_fragment_content` (`content`) WITH PARSER ngram ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='个人AI助理知识片段'; CREATE TABLE IF NOT EXISTS `aihr_personal_chat_session` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', `tenant_id` varchar(20) NOT NULL DEFAULT '000000' COMMENT '租户编号', `owner_user_id` bigint NOT NULL COMMENT '会话所属用户ID', - `owner_ext_party_id` varchar(100) DEFAULT NULL COMMENT '外部组织主体ID', - `title` varchar(255) NOT NULL DEFAULT '新对话' COMMENT '会话标题', - `status` varchar(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '会话状态', + `title` varchar(200) NOT NULL COMMENT '会话标题', + `default_scope` varchar(50) NOT NULL DEFAULT 'PERSONAL' COMMENT '默认问答范围', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`), - KEY `idx_aihr_personal_chat_session_owner` (`tenant_id`, `owner_user_id`, `status`, `update_time`) + KEY `idx_personal_session_owner` (`tenant_id`, `owner_user_id`, `update_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='个人AI助理对话会话'; CREATE TABLE IF NOT EXISTS `aihr_personal_chat_message` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', `tenant_id` varchar(20) NOT NULL DEFAULT '000000' COMMENT '租户编号', `owner_user_id` bigint NOT NULL COMMENT '消息所属用户ID', - `owner_ext_party_id` varchar(100) DEFAULT NULL COMMENT '外部组织主体ID', `session_id` bigint NOT NULL COMMENT '对话会话ID', `role` varchar(20) NOT NULL COMMENT '消息角色: user/assistant/system', `content` longtext NOT NULL COMMENT '消息内容', - `citations_json` longtext COMMENT '引用来源JSON', + `scope_json` json COMMENT '本轮检索范围JSON', + `citations_json` json COMMENT '引用来源JSON', + `model_name` varchar(100) DEFAULT NULL COMMENT '模型名称', + `prompt_version` varchar(100) DEFAULT NULL COMMENT '提示词版本', `input_tokens` int NOT NULL DEFAULT 0 COMMENT '输入Token数', `output_tokens` int NOT NULL DEFAULT 0 COMMENT '输出Token数', - `model_name` varchar(100) DEFAULT NULL COMMENT '模型名称', + `latency_ms` bigint NOT NULL DEFAULT 0 COMMENT '响应耗时毫秒', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', PRIMARY KEY (`id`), - KEY `idx_aihr_personal_chat_message_session` (`session_id`, `id`), - KEY `idx_aihr_personal_chat_message_owner` (`tenant_id`, `owner_user_id`, `create_time`) + KEY `idx_personal_message_owner` (`tenant_id`, `owner_user_id`, `session_id`, `create_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='个人AI助理对话消息';