fix(personal): align schema with phase two contract

This commit is contained in:
2026-07-12 01:54:10 +08:00
parent f9a5516a1a
commit 5b3aeadb5f
2 changed files with 118 additions and 45 deletions
@@ -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());
}
}
@@ -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助理对话消息';