package com.wok.supportbot.config; import jakarta.annotation.PostConstruct; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Component; import java.util.List; /** * 数据库初始化配置 * 应用启动时检查并创建必要的表、迁移列、同步种子数据。 * * ⚠️ 开发规范:任何涉及建表、增删改列、初始数据的变更, * 必须同时更新本类(init 方法)和 init-database.sql(手动备用脚本), * 保持两者一致,否则新部署环境会丢失变更。 */ @Component @Slf4j public class DatabaseInitConfig { @Autowired private JdbcTemplate jdbcTemplate; @Autowired(required = false) private PasswordEncoder passwordEncoder; @Value("${knowledge.vector.dimension:1024}") private int vectorDimension; @PostConstruct public void init() { log.info("========== 数据库初始化开始 =========="); // ==================== 核心业务表 ==================== safeInit("创建聊天消息表 chat_message", () -> { if (!checkTableExists("chat_message")) { createChatMessageTable(); } }); safeInit("创建知识库分类表 knowledge_category", () -> { if (!checkTableExists("knowledge_category")) { createCategoryTable(); } }); safeInit("创建知识文档表 knowledge_document", () -> { if (!checkTableExists("knowledge_document")) { createDocumentTable(); } else { fixTagsDefaultValue(); } }); // 以下迁移方法始终执行(幂等),确保新旧环境都拥有所有列 safeInit("迁移 knowledge_document.content_hash 列", this::addContentHashColumn); safeInit("迁移 knowledge_document.enabled 列", this::addDocumentEnabledColumn); safeInit("迁移 knowledge_document.extra_config 列", this::addDocumentExtraConfigColumn); safeInit("迁移 knowledge_document.file_path 列", this::addDocumentFilePathColumn); safeInit("创建客服角色表 customer_service_role", () -> { if (!checkTableExists("customer_service_role")) { createCustomerServiceRoleTable(); } else { dropRoleModelColumn(); } }); safeInit("创建客服角色知识库关联表 customer_service_role_category", () -> { if (!checkTableExists("customer_service_role_category")) { createCustomerServiceRoleCategoryTable(); } }); safeInit("创建客服账号表 customer_account", () -> { if (!checkTableExists("customer_account")) { createCustomerAccountTable(); } }); safeInit("创建会话归属表 conversation_session", () -> { if (!checkTableExists("conversation_session")) { createConversationSessionTable(); } }); safeInit("迁移 conversation_session.external_account_id 列", this::addConversationSessionExternalAccountIdColumn); safeInit("迁移 customer_service_role.allowed_mcp_tools 列", this::addRoleAllowedMcpToolsColumn); safeInit("同步默认客服角色", this::syncDefaultCustomerServiceRoles); safeInit("同步默认客服账号", this::syncDefaultCustomerAccounts); // ==================== AI 模型配置表 ==================== safeInit("创建 AI 模型配置表 ai_model_config", () -> { if (!checkTableExists("ai_model_config")) { createAiModelConfigTable(); } }); // ==================== P0 阶段新增表 ==================== // P0-004: 内容安全过滤 safeInit("创建敏感词表 sensitive_word", () -> { if (!checkTableExists("sensitive_word")) { createSensitiveWordTable(); } }); safeInit("创建内容审计日志表 content_audit_log", () -> { if (!checkTableExists("content_audit_log")) { createContentAuditLogTable(); } }); // P0-002: 用户反馈 safeInit("创建消息反馈表 message_feedback", () -> { if (!checkTableExists("message_feedback")) { createMessageFeedbackTable(); } else { addMessageFeedbackProcessedColumns(); } }); // P0-002-EXT: FAQ 反馈维护关联 safeInit("创建 FAQ 反馈维护关联表 faq_feedback_link", () -> { if (!checkTableExists("faq_feedback_link")) { createFaqFeedbackLinkTable(); } }); // P0-003: FAQ 知识库 safeInit("创建 FAQ 知识库表 knowledge_faq", () -> { if (!checkTableExists("knowledge_faq")) { createKnowledgeFaqTable(); } else { addFaqCategoryIdColumn(); addKnowledgeFaqFeedbackColumns(); } }); safeInit("创建 FAQ 向量索引表 faq_embedding", () -> { if (!checkTableExists("faq_embedding")) { createFaqEmbeddingTable(); } }); // ==================== P1 阶段新增表 ==================== // P1-001: 用户认证与多用户管理 safeInit("创建系统用户表 sys_user", () -> { if (!checkTableExists("sys_user")) { createSysUserTable(); } }); safeInit("创建系统角色表 sys_role", () -> { if (!checkTableExists("sys_role")) { createSysRoleTable(); } }); safeInit("创建系统权限表 sys_permission", () -> { if (!checkTableExists("sys_permission")) { createSysPermissionTable(); } }); safeInit("创建用户角色关联表 sys_user_role", () -> { if (!checkTableExists("sys_user_role")) { createSysUserRoleTable(); } }); safeInit("迁移 chat_message.user_id 列", this::addChatMessageUserIdColumn); safeInit("同步默认系统角色", this::syncDefaultSysRoles); safeInit("同步默认管理员账号", this::syncDefaultAdminUser); // P1-002: 运营数据分析看板 safeInit("创建 RAG 命中记录表 rag_hit_log", () -> { if (!checkTableExists("rag_hit_log")) { createRagHitLogTable(); } }); safeInit("创建看板快照表 dashboard_snapshot", () -> { if (!checkTableExists("dashboard_snapshot")) { createDashboardSnapshotTable(); } }); safeInit("创建 LLM 调用追踪表 llm_call_trace", () -> { if (!checkTableExists("llm_call_trace")) { createLlmCallTraceTable(); } }); safeInit("迁移 llm_call_trace 扩展列", this::addLlmCallTraceExtensionColumns); // P1-003: API 开放平台 safeInit("创建 API Key 表 api_key", () -> { if (!checkTableExists("api_key")) { createApiKeyTable(); } }); safeInit("创建 Webhook 配置表 webhook_config", () -> { if (!checkTableExists("webhook_config")) { createWebhookConfigTable(); } }); // ==================== MCP Server 配置 ==================== safeInit("创建 MCP Server 配置表 mcp_server_config", () -> { if (!checkTableExists("mcp_server_config")) { createMcpServerConfigTable(); } }); // 迁移 mcp_server_config.headers 列(旧表补加自定义请求头列,用于鉴权场景) safeInit("迁移 mcp_server_config.headers 列", this::addMcpServerConfigHeadersColumn); // P0-001: 混合检索 - 为 vector_store 添加全文检索列 safeInit("初始化 vector_store 全文检索", this::initVectorStoreFullTextSearch); // 迁移 vector_store.create_time 列(Spring AI 自动建表时不包含此列) safeInit("迁移 vector_store.create_time 列", this::addVectorStoreCreateTimeColumn); // ==================== 清理租户相关数据 ==================== safeInit("清理租户表和 tenant_id 列", this::cleanupTenantData); // ==================== API Key 角色绑定 ==================== safeInit("api_key 新增 role_ids 列", this::addApiKeyRoleIdsColumn); // ==================== 系统配置 ==================== safeInit("创建系统配置表 system_config", () -> { if (!checkTableExists("system_config")) { createSystemConfigTable(); } }); safeInit("同步默认系统配置", this::syncDefaultSystemConfigs); // 为所有表添加注释(幂等,可重复执行) safeInit("应用数据库表注释", this::applyTableComments); // ==================== 初始化结果验证 ==================== verifyInitialization(); log.info("========== 数据库初始化完成 =========="); } /** * 安全执行初始化步骤,单个步骤失败不影响其他步骤 */ private void safeInit(String description, Runnable action) { try { action.run(); } catch (Exception e) { log.error("数据库初始化步骤失败 [{}]: {}", description, e.getMessage(), e); } } /** * 初始化完成后验证所有预期的表是否存在,汇总报告缺失表 */ private void verifyInitialization() { String[] expectedTables = { "chat_message", "knowledge_category", "knowledge_document", "customer_service_role", "customer_service_role_category", "customer_account", "conversation_session", "ai_model_config", "sensitive_word", "content_audit_log", "message_feedback", "knowledge_faq", "faq_embedding", "faq_feedback_link", "sys_user", "sys_role", "sys_permission", "sys_user_role", "rag_hit_log", "dashboard_snapshot", "api_key", "webhook_config", "mcp_server_config", "system_config", "llm_call_trace" }; java.util.List missingTables = new java.util.ArrayList<>(); for (String table : expectedTables) { if (!checkTableExists(table)) { missingTables.add(table); } } if (missingTables.isEmpty()) { log.info("数据库初始化验证通过,所有 {} 张表均已创建", expectedTables.length); } else { log.error("⚠️ 数据库初始化验证失败!以下 {} 张表缺失: {}", missingTables.size(), missingTables); log.error("请检查上方错误日志,修复后重启应用"); } } private boolean checkTableExists(String tableName) { try { String sql = "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = current_schema() AND table_name = ?"; Integer count = jdbcTemplate.queryForObject(sql, Integer.class, tableName.toLowerCase()); return count != null && count > 0; } catch (Exception e) { return false; } } private void createChatMessageTable() { String sql = """ CREATE TABLE IF NOT EXISTS chat_message ( id BIGSERIAL PRIMARY KEY, conversation_id VARCHAR(64) NOT NULL, message_type VARCHAR(20) NOT NULL, content TEXT NOT NULL, metadata JSONB DEFAULT '{}' NOT NULL, create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, is_delete BOOLEAN DEFAULT FALSE NOT NULL ) """; jdbcTemplate.execute(sql); // 创建索引 jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_chat_message_conversation_id ON chat_message (conversation_id)"); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_chat_message_create_time ON chat_message (create_time DESC)"); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_chat_message_type ON chat_message (message_type)"); } private void createCategoryTable() { String sql = """ CREATE TABLE IF NOT EXISTS knowledge_category ( id BIGSERIAL PRIMARY KEY, name VARCHAR(100) NOT NULL, description TEXT, parent_id BIGINT DEFAULT 0 NOT NULL, sort_order INTEGER DEFAULT 0 NOT NULL, document_count INTEGER DEFAULT 0 NOT NULL, create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, is_delete BOOLEAN DEFAULT FALSE NOT NULL ) """; jdbcTemplate.execute(sql); // 创建索引 jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_knowledge_category_parent ON knowledge_category (parent_id)"); } private void createDocumentTable() { String sql = """ CREATE TABLE IF NOT EXISTS knowledge_document ( id BIGSERIAL PRIMARY KEY, title VARCHAR(500) NOT NULL, source_name VARCHAR(500), file_type VARCHAR(20) NOT NULL, file_size BIGINT DEFAULT 0 NOT NULL, file_path VARCHAR(500), content TEXT, category_id BIGINT DEFAULT 0 NOT NULL, tags JSONB DEFAULT '{}' NOT NULL, chunk_count INTEGER DEFAULT 0 NOT NULL, status VARCHAR(20) DEFAULT 'PROCESSING' NOT NULL, error_message TEXT, content_hash VARCHAR(64), enabled BOOLEAN DEFAULT TRUE NOT NULL, extra_config JSONB DEFAULT '{}', create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, is_delete BOOLEAN DEFAULT FALSE NOT NULL ) """; jdbcTemplate.execute(sql); // 创建索引 jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_knowledge_document_category ON knowledge_document (category_id)"); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_knowledge_document_status ON knowledge_document (status)"); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_knowledge_document_create_time ON knowledge_document (create_time DESC)"); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_knowledge_document_content_hash ON knowledge_document (content_hash)"); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_knowledge_document_enabled ON knowledge_document (enabled)"); } private void createCustomerServiceRoleTable() { String sql = """ CREATE TABLE IF NOT EXISTS customer_service_role ( id BIGSERIAL PRIMARY KEY, role_key VARCHAR(64) NOT NULL UNIQUE, name VARCHAR(100) NOT NULL, description TEXT, prompt TEXT, enabled BOOLEAN DEFAULT TRUE NOT NULL, create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, is_delete BOOLEAN DEFAULT FALSE NOT NULL ) """; jdbcTemplate.execute(sql); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_customer_service_role_enabled ON customer_service_role (enabled)"); } private void createCustomerServiceRoleCategoryTable() { String sql = """ CREATE TABLE IF NOT EXISTS customer_service_role_category ( id BIGSERIAL PRIMARY KEY, role_id BIGINT NOT NULL, category_id BIGINT NOT NULL, create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, is_delete BOOLEAN DEFAULT FALSE NOT NULL, UNIQUE (role_id, category_id) ) """; jdbcTemplate.execute(sql); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_customer_service_role_category_role ON customer_service_role_category (role_id)"); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_customer_service_role_category_category ON customer_service_role_category (category_id)"); } private void createCustomerAccountTable() { String sql = """ CREATE TABLE IF NOT EXISTS customer_account ( id BIGSERIAL PRIMARY KEY, account_key VARCHAR(64) NOT NULL UNIQUE, name VARCHAR(100) NOT NULL, description TEXT, role_id BIGINT, enabled BOOLEAN DEFAULT TRUE NOT NULL, create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, is_delete BOOLEAN DEFAULT FALSE NOT NULL ) """; jdbcTemplate.execute(sql); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_customer_account_role ON customer_account (role_id)"); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_customer_account_enabled ON customer_account (enabled, id)"); } private void createConversationSessionTable() { String sql = """ CREATE TABLE IF NOT EXISTS conversation_session ( conversation_id VARCHAR(64) PRIMARY KEY, account_id BIGINT, external_account_id VARCHAR(128), role_id BIGINT, create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL ) """; jdbcTemplate.execute(sql); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_conversation_session_account ON conversation_session (account_id)"); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_conversation_session_external_account ON conversation_session (external_account_id)"); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_conversation_session_role ON conversation_session (role_id)"); } private void createAiModelConfigTable() { String sql = """ CREATE TABLE IF NOT EXISTS ai_model_config ( id BIGINT PRIMARY KEY, name VARCHAR(100) NOT NULL, app_type VARCHAR(32) NOT NULL, provider VARCHAR(64) NOT NULL, api_key VARCHAR(256), model_name VARCHAR(128) NOT NULL, temperature DOUBLE PRECISION DEFAULT 0.7, max_tokens INTEGER DEFAULT 2048, base_url VARCHAR(256), extra_config JSONB DEFAULT '{}' NOT NULL, is_active BOOLEAN DEFAULT FALSE NOT NULL, priority INTEGER DEFAULT 0 NOT NULL, description VARCHAR(500), create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, is_delete BOOLEAN NOT NULL DEFAULT FALSE ) """; jdbcTemplate.execute(sql); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_ai_model_config_app_type ON ai_model_config (app_type)"); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_ai_model_config_active ON ai_model_config (is_active) WHERE is_delete = FALSE"); } private void addConversationSessionExternalAccountIdColumn() { try { String checkSql = "SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'conversation_session' AND column_name = 'external_account_id'"; Integer count = jdbcTemplate.queryForObject(checkSql, Integer.class); if (count != null && count == 0) { jdbcTemplate.execute("ALTER TABLE conversation_session ADD COLUMN external_account_id VARCHAR(128)"); } jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_conversation_session_external_account ON conversation_session (external_account_id)"); } catch (Exception e) { log.warn("add conversation_session.external_account_id failed: {}", e.getMessage()); } } /** * 为 customer_service_role 表添加 allowed_mcp_tools 列(角色 MCP 工具权限控制) * 幂等:已有列则跳过 */ private void addRoleAllowedMcpToolsColumn() { try { String checkSql = "SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'customer_service_role' AND column_name = 'allowed_mcp_tools'"; Integer count = jdbcTemplate.queryForObject(checkSql, Integer.class); if (count != null && count == 0) { log.info("添加 customer_service_role.allowed_mcp_tools 列"); jdbcTemplate.execute("ALTER TABLE customer_service_role ADD COLUMN allowed_mcp_tools JSONB DEFAULT '[]'"); } } catch (Exception e) { log.error("添加 customer_service_role.allowed_mcp_tools 列失败", e); } } private void syncDefaultCustomerServiceRoles() { upsertDefaultRole("general", "客服", "用户咨询、业务办理、常见问题、问题受理与进度说明", """ 职责:回答用户关于业务办理、服务流程、常见问题、问题受理和进度说明的问题。 要求:优先依据知识库回答;资料不足时说明无法确认,并引导用户补充必要信息;涉及财务、行政制度细节时不要猜测,应提示转对应角色处理。 """); upsertDefaultRole("finance", "财务", "付款、退款、发票、对账、报销、结算与费用规则", """ 职责:回答付款、退款、发票、对账、报销、结算、费用规则相关问题。 要求:金额、账户、票据、时间节点必须严谨;知识库没有依据时不得编造政策;需要用户提供单号、金额、日期、发票抬头等关键信息。 """); upsertDefaultRole("administration", "行政", "办公制度、行政流程、资产、会议、考勤、用章、采购与后勤", """ 职责:回答办公制度、行政流程、资产、会议、考勤、入职、用章、采购、后勤等问题。 要求:优先依据公司制度和流程文件回答;涉及审批权限、特殊例外或未覆盖场景时提示按制度提交申请或联系行政负责人。 """); retireObsoleteDefaultRoles(); } private void syncDefaultCustomerAccounts() { upsertDefaultAccount("service", "客服账号", "默认客服账号", "general"); upsertDefaultAccount("finance", "财务账号", "默认财务账号", "finance"); upsertDefaultAccount("administration", "行政账号", "默认行政账号", "administration"); } private void upsertDefaultRole(String roleKey, String name, String description, String prompt) { jdbcTemplate.update(""" INSERT INTO customer_service_role (role_key, name, description, prompt) VALUES (?, ?, ?, ?) ON CONFLICT (role_key) DO NOTHING """, roleKey, name, description, prompt); } private void upsertDefaultAccount(String accountKey, String name, String description, String roleKey) { Long roleId = jdbcTemplate.queryForObject(""" SELECT id FROM customer_service_role WHERE role_key = ? AND is_delete = false LIMIT 1 """, Long.class, roleKey); jdbcTemplate.update(""" INSERT INTO customer_account (account_key, name, description, role_id) VALUES (?, ?, ?, ?) ON CONFLICT (account_key) DO UPDATE SET name = EXCLUDED.name, description = EXCLUDED.description, role_id = EXCLUDED.role_id, enabled = true, is_delete = false, update_time = CURRENT_TIMESTAMP """, accountKey, name, description, roleId); } private void retireObsoleteDefaultRoles() { jdbcTemplate.update(""" UPDATE customer_service_role SET is_delete = true, enabled = false, update_time = CURRENT_TIMESTAMP WHERE role_key IN ('after_sale', 'logistics', 'product') """); jdbcTemplate.update(""" UPDATE customer_service_role_category SET is_delete = true WHERE role_id IN ( SELECT id FROM customer_service_role WHERE role_key IN ('after_sale', 'logistics', 'product') ) """); } private void fixTagsDefaultValue() { try { // 检查当前默认值是否为数组 String checkSql = "SELECT column_default FROM information_schema.columns WHERE table_name = 'knowledge_document' AND column_name = 'tags'"; String currentDefault = jdbcTemplate.queryForObject(checkSql, String.class); if (currentDefault != null && currentDefault.contains("[]")) { log.info("修复 knowledge_document.tags 默认值"); jdbcTemplate.execute("ALTER TABLE knowledge_document ALTER COLUMN tags SET DEFAULT '{}'"); // 将已有的 '[]' 更新为 '{}' jdbcTemplate.execute("UPDATE knowledge_document SET tags = '{}' WHERE tags = '[]' OR tags IS NULL"); } } catch (Exception e) { log.warn("修复 tags 默认值时出错(可能已修复)", e); } } /** * 清理已废弃的 customer_service_role.model 列(per-role 模型功能已撤除)。 * 幂等:DROP COLUMN IF EXISTS,列不存在时不做任何事。 */ private void dropRoleModelColumn() { try { jdbcTemplate.execute("ALTER TABLE customer_service_role DROP COLUMN IF EXISTS model"); } catch (Exception e) { log.warn("清理 model 列时出错", e); } } /** * 自动添加 content_hash 列(二期去重功能新增字段) */ private void addContentHashColumn() { try { String checkSql = "SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'knowledge_document' AND column_name = 'content_hash'"; Integer count = jdbcTemplate.queryForObject(checkSql, Integer.class); if (count != null && count == 0) { log.info("添加 knowledge_document.content_hash 列"); jdbcTemplate.execute("ALTER TABLE knowledge_document ADD COLUMN content_hash VARCHAR(64)"); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_knowledge_document_content_hash ON knowledge_document (content_hash)"); } } catch (Exception e) { log.error("添加 knowledge_document.content_hash 列失败,请手动执行: ALTER TABLE knowledge_document ADD COLUMN content_hash VARCHAR(64)", e); } } /** * P1-2.1: 自动添加 enabled 列(文档启用/禁用功能) * 幂等:已有列则跳过 */ private void addDocumentEnabledColumn() { try { String checkSql = "SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'knowledge_document' AND column_name = 'enabled'"; Integer count = jdbcTemplate.queryForObject(checkSql, Integer.class); if (count != null && count == 0) { log.info("添加 knowledge_document.enabled 列"); jdbcTemplate.execute("ALTER TABLE knowledge_document ADD COLUMN enabled BOOLEAN DEFAULT TRUE NOT NULL"); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_knowledge_document_enabled ON knowledge_document (enabled)"); } } catch (Exception e) { log.error("添加 knowledge_document.enabled 列失败,请手动执行: ALTER TABLE knowledge_document ADD COLUMN enabled BOOLEAN DEFAULT TRUE NOT NULL", e); } } /** * P1-2.5: 自动添加 extra_config 列(per-doc 分块参数存储) * 幂等:已有列则跳过 */ private void addDocumentExtraConfigColumn() { try { String checkSql = "SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'knowledge_document' AND column_name = 'extra_config'"; Integer count = jdbcTemplate.queryForObject(checkSql, Integer.class); if (count != null && count == 0) { log.info("添加 knowledge_document.extra_config 列"); jdbcTemplate.execute("ALTER TABLE knowledge_document ADD COLUMN extra_config JSONB DEFAULT '{}'"); } } catch (Exception e) { log.error("添加 knowledge_document.extra_config 列失败,请手动执行: ALTER TABLE knowledge_document ADD COLUMN extra_config JSONB DEFAULT '{}'", e); } } /** * 为 knowledge_document 表添加 file_path 列(存储原始文件路径) */ private void addDocumentFilePathColumn() { try { String checkSql = "SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'knowledge_document' AND column_name = 'file_path'"; Integer count = jdbcTemplate.queryForObject(checkSql, Integer.class); if (count != null && count == 0) { log.info("添加 knowledge_document.file_path 列"); jdbcTemplate.execute("ALTER TABLE knowledge_document ADD COLUMN file_path VARCHAR(500)"); } } catch (Exception e) { log.error("添加 knowledge_document.file_path 列失败,请手动执行: ALTER TABLE knowledge_document ADD COLUMN file_path VARCHAR(500)", e); } } // ==================== P0-004: 内容安全过滤 ==================== private void createSensitiveWordTable() { String sql = """ CREATE TABLE IF NOT EXISTS sensitive_word ( id BIGSERIAL PRIMARY KEY, word VARCHAR(256) NOT NULL, category VARCHAR(64) NOT NULL DEFAULT 'custom', level INTEGER NOT NULL DEFAULT 1, is_active BOOLEAN NOT NULL DEFAULT TRUE, remark VARCHAR(512), create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, is_delete BOOLEAN DEFAULT FALSE NOT NULL ) """; jdbcTemplate.execute(sql); jdbcTemplate.execute("CREATE UNIQUE INDEX IF NOT EXISTS uk_sensitive_word_word_category ON sensitive_word (word, category) WHERE is_delete = false"); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_sensitive_word_active ON sensitive_word (is_active) WHERE is_delete = false"); } private void createContentAuditLogTable() { String sql = """ CREATE TABLE IF NOT EXISTS content_audit_log ( id BIGSERIAL PRIMARY KEY, session_id VARCHAR(64), direction VARCHAR(16) NOT NULL, original_text TEXT NOT NULL, hit_words JSONB DEFAULT '[]' NOT NULL, action_taken VARCHAR(32) NOT NULL, create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL ) """; jdbcTemplate.execute(sql); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_audit_log_created ON content_audit_log (create_time DESC)"); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_audit_log_session ON content_audit_log (session_id)"); } // ==================== P0-002: 用户反馈 ==================== private void createMessageFeedbackTable() { String sql = """ CREATE TABLE IF NOT EXISTS message_feedback ( id BIGSERIAL PRIMARY KEY, message_id VARCHAR(128) NOT NULL, conversation_id VARCHAR(64) NOT NULL, feedback_type VARCHAR(16) NOT NULL, reason_category VARCHAR(64), reason_comment TEXT, processed BOOLEAN DEFAULT FALSE NOT NULL, processed_by BIGINT, processed_time TIMESTAMP, create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, is_delete BOOLEAN DEFAULT FALSE NOT NULL ) """; jdbcTemplate.execute(sql); jdbcTemplate.execute("CREATE UNIQUE INDEX IF NOT EXISTS uk_message_feedback_message ON message_feedback (message_id) WHERE is_delete = false"); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_feedback_conversation ON message_feedback (conversation_id)"); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_feedback_type ON message_feedback (feedback_type) WHERE is_delete = false"); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_feedback_created ON message_feedback (create_time DESC)"); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_feedback_type_reason ON message_feedback (feedback_type, reason_category, processed, create_time)"); } /** * 为已存在的 message_feedback 表添加处理状态字段(反馈运营看板需要) */ private void addMessageFeedbackProcessedColumns() { try { String checkSql = "SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'message_feedback' AND column_name = 'processed'"; Integer count = jdbcTemplate.queryForObject(checkSql, Integer.class); if (count != null && count == 0) { log.info("添加 message_feedback.processed 列"); jdbcTemplate.execute("ALTER TABLE message_feedback ADD COLUMN IF NOT EXISTS processed BOOLEAN DEFAULT FALSE NOT NULL"); } checkSql = "SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'message_feedback' AND column_name = 'processed_by'"; count = jdbcTemplate.queryForObject(checkSql, Integer.class); if (count != null && count == 0) { log.info("添加 message_feedback.processed_by 列"); jdbcTemplate.execute("ALTER TABLE message_feedback ADD COLUMN IF NOT EXISTS processed_by BIGINT"); } checkSql = "SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'message_feedback' AND column_name = 'processed_time'"; count = jdbcTemplate.queryForObject(checkSql, Integer.class); if (count != null && count == 0) { log.info("添加 message_feedback.processed_time 列"); jdbcTemplate.execute("ALTER TABLE message_feedback ADD COLUMN IF NOT EXISTS processed_time TIMESTAMP"); } jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_feedback_type_reason ON message_feedback (feedback_type, reason_category, processed, create_time)"); } catch (Exception e) { log.error("添加 message_feedback 处理状态列失败", e); } } // ==================== P0-002-EXT: FAQ 反馈维护关联 ==================== private void createFaqFeedbackLinkTable() { String sql = """ CREATE TABLE IF NOT EXISTS faq_feedback_link ( id BIGSERIAL PRIMARY KEY, feedback_id BIGINT NOT NULL, faq_id BIGINT, action_type VARCHAR(32) NOT NULL, original_question TEXT, original_answer TEXT, operator_id BIGINT, remark TEXT, create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, is_delete BOOLEAN DEFAULT FALSE NOT NULL ) """; jdbcTemplate.execute(sql); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_faq_feedback_link_faq ON faq_feedback_link (faq_id)"); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_faq_feedback_link_feedback ON faq_feedback_link (feedback_id)"); } // ==================== P0-003: FAQ 知识库 ==================== private void createKnowledgeFaqTable() { String sql = """ CREATE TABLE IF NOT EXISTS knowledge_faq ( id BIGSERIAL PRIMARY KEY, question TEXT NOT NULL, answer TEXT NOT NULL, similar_questions TEXT DEFAULT '[]' NOT NULL, category VARCHAR(128), category_id BIGINT, status VARCHAR(20) NOT NULL DEFAULT 'ENABLED', priority INTEGER NOT NULL DEFAULT 0, hit_count BIGINT NOT NULL DEFAULT 0, source VARCHAR(64) DEFAULT 'manual', created_from_feedback_id BIGINT, create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, is_delete BOOLEAN DEFAULT FALSE NOT NULL ) """; jdbcTemplate.execute(sql); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_faq_status ON knowledge_faq (status) WHERE is_delete = false"); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_faq_category ON knowledge_faq (category)"); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_faq_category_id ON knowledge_faq (category_id)"); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_faq_priority ON knowledge_faq (priority DESC)"); } private void createFaqEmbeddingTable() { String sql = """ CREATE TABLE IF NOT EXISTS faq_embedding ( id BIGSERIAL PRIMARY KEY, faq_id BIGINT NOT NULL, embedding vector(%d) NOT NULL, model_name VARCHAR(64) NOT NULL, create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL ) """.formatted(vectorDimension); jdbcTemplate.execute(sql); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_faq_emb_faq_id ON faq_embedding (faq_id)"); } /** * 为已存在的 knowledge_faq 表添加 category_id 列(引用文档管理分类表 knowledge_category) */ private void addFaqCategoryIdColumn() { try { String checkSql = "SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'knowledge_faq' AND column_name = 'category_id'"; Integer count = jdbcTemplate.queryForObject(checkSql, Integer.class); if (count != null && count == 0) { log.info("添加 knowledge_faq.category_id 列"); jdbcTemplate.execute("ALTER TABLE knowledge_faq ADD COLUMN category_id BIGINT"); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_faq_category_id ON knowledge_faq (category_id)"); } } catch (Exception e) { log.error("添加 knowledge_faq.category_id 列失败,请手动执行: ALTER TABLE knowledge_faq ADD COLUMN category_id BIGINT", e); } } /** * 为已存在的 knowledge_faq 表添加反馈来源追溯字段 */ private void addKnowledgeFaqFeedbackColumns() { try { String checkSql = "SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'knowledge_faq' AND column_name = 'created_from_feedback_id'"; Integer count = jdbcTemplate.queryForObject(checkSql, Integer.class); if (count != null && count == 0) { log.info("添加 knowledge_faq.created_from_feedback_id 列"); jdbcTemplate.execute("ALTER TABLE knowledge_faq ADD COLUMN IF NOT EXISTS created_from_feedback_id BIGINT"); } } catch (Exception e) { log.error("添加 knowledge_faq.created_from_feedback_id 列失败", e); } } // ==================== P0-001: 混合检索 - 全文检索 ==================== /** * 为 vector_store 表添加全文检索支持(tsvector 列 + GIN 索引 + 触发器) * 幂等操作:已存在则跳过 */ private void initVectorStoreFullTextSearch() { try { // 检查 vector_store 表是否存在 if (!checkTableExists("vector_store")) { log.debug("vector_store 表尚未创建,跳过全文检索初始化"); return; } // 检查 content_tsvector 列是否已存在 String checkSql = "SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'vector_store' AND column_name = 'content_tsvector'"; Integer count = jdbcTemplate.queryForObject(checkSql, Integer.class); if (count != null && count > 0) { return; // 已初始化 } log.info("为 vector_store 添加全文检索支持"); // 添加 tsvector 列 jdbcTemplate.execute("ALTER TABLE vector_store ADD COLUMN content_tsvector tsvector"); // 填充已有数据 jdbcTemplate.execute("UPDATE vector_store SET content_tsvector = to_tsvector('simple', coalesce(content, '')) WHERE content_tsvector IS NULL"); // GIN 索引 jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_vector_store_tsvector ON vector_store USING gin(content_tsvector)"); // 触发器函数 jdbcTemplate.execute(""" CREATE OR REPLACE FUNCTION update_content_tsvector() RETURNS trigger AS $$ BEGIN NEW.content_tsvector := to_tsvector('simple', coalesce(NEW.content, '')); RETURN NEW; END; $$ LANGUAGE plpgsql """); // 绑定触发器 jdbcTemplate.execute("DROP TRIGGER IF EXISTS trg_update_content_tsvector ON vector_store"); jdbcTemplate.execute(""" CREATE TRIGGER trg_update_content_tsvector BEFORE INSERT OR UPDATE ON vector_store FOR EACH ROW EXECUTE FUNCTION update_content_tsvector() """); log.info("vector_store 全文检索初始化完成"); } catch (Exception e) { log.warn("初始化 vector_store 全文检索时出错(可稍后手动执行)", e); } } /** * 为 vector_store 表添加 create_time 列。 * Spring AI 自动建表时不会创建此列,但 DocumentService 查询分块列表时需要按 create_time 排序。 * 幂等:已有列则跳过。 */ private void addVectorStoreCreateTimeColumn() { try { // 表不存在时直接跳过,等待 Spring AI 创建后再迁移 if (!checkTableExists("vector_store")) { log.debug("vector_store 表尚未创建,跳过 create_time 列迁移"); return; } String checkSql = "SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'vector_store' AND column_name = 'create_time'"; Integer count = jdbcTemplate.queryForObject(checkSql, Integer.class); if (count != null && count == 0) { log.info("添加 vector_store.create_time 列"); jdbcTemplate.execute("ALTER TABLE vector_store ADD COLUMN create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL"); // 为已有记录回填创建时间(使用当前时间作为兜底) jdbcTemplate.execute("UPDATE vector_store SET create_time = CURRENT_TIMESTAMP WHERE create_time IS NULL"); log.info("vector_store.create_time 列添加完成"); } } catch (Exception e) { log.error("添加 vector_store.create_time 列失败,请手动执行: ALTER TABLE vector_store ADD COLUMN create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL", e); } } // ==================== P1-001: 用户认证与多用户管理 ==================== private void createSysUserTable() { String sql = """ CREATE TABLE IF NOT EXISTS sys_user ( id BIGSERIAL PRIMARY KEY, username VARCHAR(64) NOT NULL UNIQUE, password VARCHAR(256) NOT NULL, nickname VARCHAR(100), email VARCHAR(128), phone VARCHAR(20), avatar VARCHAR(512), enabled BOOLEAN DEFAULT TRUE NOT NULL, last_login_time TIMESTAMP, create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, is_delete BOOLEAN DEFAULT FALSE NOT NULL ) """; jdbcTemplate.execute(sql); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_sys_user_username ON sys_user (username) WHERE is_delete = false"); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_sys_user_enabled ON sys_user (enabled) WHERE is_delete = false"); } private void createSysRoleTable() { String sql = """ CREATE TABLE IF NOT EXISTS sys_role ( id BIGSERIAL PRIMARY KEY, role_key VARCHAR(64) NOT NULL UNIQUE, name VARCHAR(100) NOT NULL, description TEXT, enabled BOOLEAN DEFAULT TRUE NOT NULL, create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, is_delete BOOLEAN DEFAULT FALSE NOT NULL ) """; jdbcTemplate.execute(sql); } private void createSysPermissionTable() { String sql = """ CREATE TABLE IF NOT EXISTS sys_permission ( id BIGSERIAL PRIMARY KEY, permission_key VARCHAR(128) NOT NULL UNIQUE, name VARCHAR(100) NOT NULL, description TEXT, create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, is_delete BOOLEAN DEFAULT FALSE NOT NULL ) """; jdbcTemplate.execute(sql); } private void createSysUserRoleTable() { String sql = """ CREATE TABLE IF NOT EXISTS sys_user_role ( id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL, role_id BIGINT NOT NULL, create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, UNIQUE (user_id, role_id) ) """; jdbcTemplate.execute(sql); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_sys_user_role_user ON sys_user_role (user_id)"); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_sys_user_role_role ON sys_user_role (role_id)"); } /** * chat_message 表增加 user_id 列(数据隔离迁移,幂等) */ private void addChatMessageUserIdColumn() { try { String checkSql = "SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'chat_message' AND column_name = 'user_id'"; Integer count = jdbcTemplate.queryForObject(checkSql, Integer.class); if (count != null && count == 0) { log.info("添加 chat_message.user_id 列"); jdbcTemplate.execute("ALTER TABLE chat_message ADD COLUMN user_id BIGINT"); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_chat_message_user_id ON chat_message (user_id)"); } } catch (Exception e) { log.error("添加 chat_message.user_id 列失败,请手动执行: ALTER TABLE chat_message ADD COLUMN user_id BIGINT", e); } } /** * 同步默认系统角色(admin/kb_operator/cs_agent/viewer) */ private void syncDefaultSysRoles() { upsertSysRole("admin", "超级管理员", "系统最高权限,可管理所有功能"); upsertSysRole("kb_operator", "知识库运营", "管理知识库文档、分类、FAQ 等"); upsertSysRole("cs_agent", "客服人员", "处理对话、查看反馈等客服操作"); upsertSysRole("viewer", "只读查看者", "仅查看数据,不可修改"); } private void upsertSysRole(String roleKey, String name, String description) { jdbcTemplate.update(""" INSERT INTO sys_role (role_key, name, description) VALUES (?, ?, ?) ON CONFLICT (role_key) DO UPDATE SET name = EXCLUDED.name, description = EXCLUDED.description, enabled = true, is_delete = false, update_time = CURRENT_TIMESTAMP """, roleKey, name, description); } /** * 同步默认管理员账号(admin / admin123) */ private void syncDefaultAdminUser() { // 检查是否已有 admin 用户 Long count = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM sys_user WHERE username = 'admin' AND is_delete = false", Long.class ); if (count != null && count > 0) return; log.info("创建默认管理员账号 admin"); // 使用 PasswordEncoder 加密密码(如果可用),否则使用预计算的 BCrypt 哈希 String encodedPassword = (passwordEncoder != null) ? passwordEncoder.encode("admin123") : "$2a$10$EqKcp1WFKVQIShMPC7B3kuznX9gAZMsVnSNjN0ABNuHVBCpzqABKm"; jdbcTemplate.update(""" INSERT INTO sys_user (username, password, nickname, enabled) VALUES ('admin', ?, '超级管理员', true) """, encodedPassword); // 分配 admin 角色 jdbcTemplate.update(""" INSERT INTO sys_user_role (user_id, role_id) SELECT u.id, r.id FROM sys_user u, sys_role r WHERE u.username = 'admin' AND r.role_key = 'admin' ON CONFLICT DO NOTHING """); } // ==================== P1-002: 运营数据分析看板 ==================== private void createRagHitLogTable() { String sql = """ CREATE TABLE IF NOT EXISTS rag_hit_log ( id BIGSERIAL PRIMARY KEY, conversation_id VARCHAR(64), user_query TEXT NOT NULL, document_id BIGINT, document_title VARCHAR(500), chunk_id VARCHAR(128), score DOUBLE PRECISION, search_mode VARCHAR(20), create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL ) """; jdbcTemplate.execute(sql); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_rag_hit_log_created ON rag_hit_log (create_time DESC)"); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_rag_hit_log_document ON rag_hit_log (document_id)"); } private void createLlmCallTraceTable() { String sql = """ CREATE TABLE IF NOT EXISTS llm_call_trace ( id BIGINT PRIMARY KEY, conversation_id VARCHAR(64), role_id BIGINT, role_name VARCHAR(100), account_id VARCHAR(64), api_key_id BIGINT, intent VARCHAR(16), enable_rag BOOLEAN, system_prompt TEXT, global_prompt TEXT, role_prompt TEXT, user_message TEXT, ai_response TEXT, ai_response_truncated BOOLEAN, tool_calls_json TEXT, history_messages_json TEXT, history_turns INTEGER, rag_context TEXT, faq_hit BOOLEAN, faq_id BIGINT, faq_question TEXT, faq_match_type VARCHAR(16), faq_score DOUBLE PRECISION, search_mode VARCHAR(20), hit_count INTEGER, rag_hits_json TEXT, model_name VARCHAR(128), provider VARCHAR(64), temperature DOUBLE PRECISION, max_tokens INTEGER, prompt_tokens INTEGER, completion_tokens INTEGER, total_tokens INTEGER, error_type VARCHAR(32), error_message TEXT, latency_ms INTEGER, status VARCHAR(16), create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL ) """; jdbcTemplate.execute(sql); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_llm_trace_created ON llm_call_trace (create_time DESC, id DESC)"); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_llm_trace_role_created ON llm_call_trace (role_id, create_time DESC)"); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_llm_trace_conv_created ON llm_call_trace (conversation_id, create_time DESC)"); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_llm_trace_error_type ON llm_call_trace (error_type, create_time DESC)"); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_llm_trace_api_key ON llm_call_trace (api_key_id, create_time DESC)"); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_llm_trace_search_mode ON llm_call_trace (search_mode, create_time DESC)"); } /** * 为已存在的 llm_call_trace 表补加扩展列(幂等,供旧部署环境迁移)。 */ private void addLlmCallTraceExtensionColumns() { jdbcTemplate.execute("ALTER TABLE llm_call_trace ADD COLUMN IF NOT EXISTS tool_calls_json TEXT"); jdbcTemplate.execute("ALTER TABLE llm_call_trace ADD COLUMN IF NOT EXISTS history_messages_json TEXT"); jdbcTemplate.execute("ALTER TABLE llm_call_trace ADD COLUMN IF NOT EXISTS history_turns INTEGER"); jdbcTemplate.execute("ALTER TABLE llm_call_trace ADD COLUMN IF NOT EXISTS faq_id BIGINT"); jdbcTemplate.execute("ALTER TABLE llm_call_trace ADD COLUMN IF NOT EXISTS faq_question TEXT"); jdbcTemplate.execute("ALTER TABLE llm_call_trace ADD COLUMN IF NOT EXISTS faq_match_type VARCHAR(16)"); jdbcTemplate.execute("ALTER TABLE llm_call_trace ADD COLUMN IF NOT EXISTS faq_score DOUBLE PRECISION"); jdbcTemplate.execute("ALTER TABLE llm_call_trace ADD COLUMN IF NOT EXISTS rag_hits_json TEXT"); jdbcTemplate.execute("ALTER TABLE llm_call_trace ADD COLUMN IF NOT EXISTS prompt_tokens INTEGER"); jdbcTemplate.execute("ALTER TABLE llm_call_trace ADD COLUMN IF NOT EXISTS completion_tokens INTEGER"); jdbcTemplate.execute("ALTER TABLE llm_call_trace ADD COLUMN IF NOT EXISTS total_tokens INTEGER"); jdbcTemplate.execute("ALTER TABLE llm_call_trace ADD COLUMN IF NOT EXISTS error_type VARCHAR(32)"); jdbcTemplate.execute("ALTER TABLE llm_call_trace ADD COLUMN IF NOT EXISTS error_message TEXT"); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_llm_trace_error_type ON llm_call_trace (error_type, create_time DESC)"); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_llm_trace_api_key ON llm_call_trace (api_key_id, create_time DESC)"); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_llm_trace_search_mode ON llm_call_trace (search_mode, create_time DESC)"); } private void createDashboardSnapshotTable() { String sql = """ CREATE TABLE IF NOT EXISTS dashboard_snapshot ( id BIGSERIAL PRIMARY KEY, snapshot_date DATE NOT NULL UNIQUE, conversation_count INTEGER DEFAULT 0 NOT NULL, message_count INTEGER DEFAULT 0 NOT NULL, satisfaction_rate DOUBLE PRECISION DEFAULT 0, thumbs_up_count INTEGER DEFAULT 0 NOT NULL, thumbs_down_count INTEGER DEFAULT 0 NOT NULL, rag_hit_count INTEGER DEFAULT 0 NOT NULL, rag_miss_count INTEGER DEFAULT 0 NOT NULL, avg_response_time DOUBLE PRECISION DEFAULT 0, top_questions JSONB DEFAULT '[]' NOT NULL, top_hit_documents JSONB DEFAULT '[]' NOT NULL, miss_questions JSONB DEFAULT '[]' NOT NULL, create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL ) """; jdbcTemplate.execute(sql); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_snapshot_date ON dashboard_snapshot (snapshot_date DESC)"); } // ==================== P1-003: API 开放平台 ==================== private void createApiKeyTable() { String sql = """ CREATE TABLE IF NOT EXISTS api_key ( id BIGSERIAL PRIMARY KEY, key_value VARCHAR(128) NOT NULL UNIQUE, name VARCHAR(100) NOT NULL, description TEXT, user_id BIGINT, rate_limit INTEGER DEFAULT 60 NOT NULL, max_calls BIGINT, current_calls BIGINT DEFAULT 0 NOT NULL, expire_time TIMESTAMP, enabled BOOLEAN DEFAULT TRUE NOT NULL, create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, is_delete BOOLEAN DEFAULT FALSE NOT NULL ) """; jdbcTemplate.execute(sql); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_api_key_user ON api_key (user_id)"); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_api_key_enabled ON api_key (enabled) WHERE is_delete = false"); } private void createWebhookConfigTable() { String sql = """ CREATE TABLE IF NOT EXISTS webhook_config ( id BIGSERIAL PRIMARY KEY, user_id BIGINT, name VARCHAR(100) NOT NULL, url VARCHAR(512) NOT NULL, events JSONB DEFAULT '[]' NOT NULL, enabled BOOLEAN DEFAULT TRUE NOT NULL, secret VARCHAR(128), create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, is_delete BOOLEAN DEFAULT FALSE NOT NULL ) """; jdbcTemplate.execute(sql); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_webhook_user ON webhook_config (user_id)"); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_webhook_enabled ON webhook_config (enabled) WHERE is_delete = false"); } private void createMcpServerConfigTable() { String sql = """ CREATE TABLE IF NOT EXISTS mcp_server_config ( id BIGINT PRIMARY KEY, name VARCHAR(100) NOT NULL, transport_type VARCHAR(20) NOT NULL DEFAULT 'sse', server_url VARCHAR(500), command VARCHAR(500), args VARCHAR(1000), env_vars JSONB DEFAULT '{}' NOT NULL, headers JSONB DEFAULT '{}' NOT NULL, description VARCHAR(500), is_active BOOLEAN DEFAULT TRUE NOT NULL, create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, is_delete BOOLEAN NOT NULL DEFAULT FALSE ) """; jdbcTemplate.execute(sql); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_mcp_server_config_active ON mcp_server_config (is_active) WHERE is_delete = FALSE"); } /** * 为已存在的 mcp_server_config 表补加 headers 列(JSONB,自定义请求头,用于鉴权场景)。 * 幂等:仅在列不存在时执行 ALTER,保证旧库升级不丢失数据。 */ private void addMcpServerConfigHeadersColumn() { if (!checkTableExists("mcp_server_config")) { log.debug("mcp_server_config 表尚未创建,跳过 headers 迁移"); return; } try { String checkSql = "SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'mcp_server_config' AND column_name = 'headers'"; Integer count = jdbcTemplate.queryForObject(checkSql, Integer.class); if (count != null && count == 0) { log.info("添加 mcp_server_config.headers 列"); jdbcTemplate.execute("ALTER TABLE mcp_server_config ADD COLUMN headers JSONB DEFAULT '{}' NOT NULL"); } } catch (Exception e) { log.error("添加 mcp_server_config.headers 列失败", e); } } /** * 创建系统配置表 system_config(key-value 模式,支持灵活扩展配置项) */ private void createSystemConfigTable() { String sql = """ CREATE TABLE IF NOT EXISTS system_config ( id BIGINT PRIMARY KEY, config_key VARCHAR(128) NOT NULL UNIQUE, config_value TEXT NOT NULL DEFAULT '', description VARCHAR(500), create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, is_delete BOOLEAN NOT NULL DEFAULT FALSE ) """; jdbcTemplate.execute(sql); // 创建序列(仅当不存在时),供种子数据/手动 INSERT 生成雪花 ID 兜底 jdbcTemplate.execute("CREATE SEQUENCE IF NOT EXISTS system_config_id_seq START 1000 NO CYCLE"); jdbcTemplate.execute("ALTER TABLE system_config ALTER COLUMN id SET DEFAULT nextval('system_config_id_seq')"); } /** * 同步默认系统配置(种子数据) *

* 使用 INSERT ... ON CONFLICT DO NOTHING,首次启动写入默认值,已存在不覆盖。 */ private void syncDefaultSystemConfigs() { String defaultDisclaimer = """

特别声明 Claim of Confidential

\

✓ 本文件内容为 ④ 内部公开 请勿外传:禁止未授权的内部、第三方人员使用与访问

\

✓ 非专项必要的业务与项目负责人,收到此内容请立即删除

\

✓ 严禁在未经过主管领导审批,发送给无关业务团队与人员

\ """; jdbcTemplate.update(""" INSERT INTO system_config (config_key, config_value, description) VALUES (?, ?, ?) ON CONFLICT (config_key) DO NOTHING """, "disclaimer", defaultDisclaimer, "SDK 聊天窗口底部的保密声明"); // AI 对话全局系统提示词种子数据(为空则不注入) jdbcTemplate.update(""" INSERT INTO system_config (config_key, config_value, description) VALUES (?, ?, ?) ON CONFLICT (config_key) DO NOTHING """, "ai_system_prompt", "", "AI 对话全局系统提示词(为空则不注入;修改后即时生效,无需重启)"); // AI 推荐问题(suggest-message-list)种子数据 String defaultSuggestionPrompt = """ 【推荐问题生成规则】 在回答正文结束后,请严格按以下格式生成 3 条用户可能继续追问的推荐问题: ___SUGGESTIONS___ ["推荐问题1", "推荐问题2", "推荐问题3"] 要求: 1. 推荐问题需与当前回答内容相关,覆盖用户可能关心的不同方面 2. 推荐问题是用户可直接点击发送的完整问题文本 3. 必须是合法的 JSON 字符串数组 4. ___SUGGESTIONS___ 是分隔标记,不要在回答正文中出现此标记 """; jdbcTemplate.update(""" INSERT INTO system_config (config_key, config_value, description) VALUES (?, ?, ?) ON CONFLICT (config_key) DO NOTHING """, "suggestion_enabled", "false", "AI 推荐问题功能开关(true/false)"); jdbcTemplate.update(""" INSERT INTO system_config (config_key, config_value, description) VALUES (?, ?, ?) ON CONFLICT (config_key) DO NOTHING """, "suggestion_prompt", defaultSuggestionPrompt, "AI 推荐问题 Prompt 模板(仅在 suggestion_enabled=true 时生效)"); // LLM 调用追踪记录保留天数(自动清理,默认 30 天) jdbcTemplate.update(""" INSERT INTO system_config (config_key, config_value, description) VALUES (?, ?, ?) ON CONFLICT (config_key) DO NOTHING """, "llm_trace_retention_days", "30", "LLM 调用追踪记录保留天数(自动清理,默认 30 天)"); } /** * 为所有自动创建的表添加注释(COMMENT ON)。 * 所有语句均为幂等操作,可安全重复执行。 */ private void applyTableComments() { try { // ===== chat_message ===== executeComment("TABLE chat_message", "聊天消息表(存储用户与 AI 助手的对话历史)"); executeComment("COLUMN chat_message.id", "主键(雪花算法生成)"); executeComment("COLUMN chat_message.conversation_id", "会话 ID(标识同一次对话)"); executeComment("COLUMN chat_message.message_type", "消息类型: USER(用户消息) / ASSISTANT(AI回复,可能含toolCalls) / SYSTEM(系统消息) / TOOL(MCP工具调用响应)"); executeComment("COLUMN chat_message.content", "消息内容(实际对话文本)"); executeComment("COLUMN chat_message.metadata", "元数据(JSON 格式,存储额外信息)"); executeComment("COLUMN chat_message.create_time", "创建时间"); executeComment("COLUMN chat_message.update_time", "更新时间"); executeComment("COLUMN chat_message.is_delete", "逻辑删除: FALSE=正常 TRUE=已删除"); // ===== knowledge_category ===== executeComment("TABLE knowledge_category", "知识库分类表(支持树形结构)"); executeComment("COLUMN knowledge_category.id", "主键(雪花算法生成)"); executeComment("COLUMN knowledge_category.name", "分类名称"); executeComment("COLUMN knowledge_category.description", "分类描述"); executeComment("COLUMN knowledge_category.parent_id", "父分类 ID(0 表示顶级分类)"); executeComment("COLUMN knowledge_category.sort_order", "排序权重(数值越大越靠前)"); executeComment("COLUMN knowledge_category.document_count", "关联文档数量(冗余字段,定期更新)"); executeComment("COLUMN knowledge_category.create_time", "创建时间"); executeComment("COLUMN knowledge_category.is_delete", "逻辑删除: FALSE=正常 TRUE=已删除"); // ===== knowledge_document ===== executeComment("TABLE knowledge_document", "知识文档表(记录上传的文档元信息)"); executeComment("COLUMN knowledge_document.id", "主键(雪花算法生成)"); executeComment("COLUMN knowledge_document.title", "文档标题"); executeComment("COLUMN knowledge_document.source_name", "原始文件名"); executeComment("COLUMN knowledge_document.file_type", "文件类型: pdf / md / json / txt / word / excel 等"); executeComment("COLUMN knowledge_document.file_size", "文件大小(字节)"); executeComment("COLUMN knowledge_document.content", "原文内容(截断预览)"); executeComment("COLUMN knowledge_document.category_id", "所属分类 ID(0 表示未分类)"); executeComment("COLUMN knowledge_document.tags", "标签(JSON 格式)"); executeComment("COLUMN knowledge_document.chunk_count", "分块数量"); executeComment("COLUMN knowledge_document.status", "处理状态: PROCESSING / READY / FAILED"); executeComment("COLUMN knowledge_document.error_message", "处理失败时的错误信息"); executeComment("COLUMN knowledge_document.content_hash", "内容 SHA-256 哈希值(用于文档去重)"); executeComment("COLUMN knowledge_document.enabled", "是否启用: TRUE=参与RAG检索, FALSE=禁用(不参与检索但保留数据)"); executeComment("COLUMN knowledge_document.extra_config", "分块参数配置(JSONB),存储 per-doc 的 chunkSize/overlap 等"); executeComment("COLUMN knowledge_document.file_path", "原始文件存储路径(相对于存储根目录的相对路径)"); executeComment("COLUMN knowledge_document.create_time", "创建时间"); executeComment("COLUMN knowledge_document.update_time", "更新时间"); executeComment("COLUMN knowledge_document.is_delete", "逻辑删除: FALSE=正常 TRUE=已删除"); // ===== customer_service_role ===== executeComment("TABLE customer_service_role", "客服角色表(定义客服角色的身份与系统提示词)"); executeComment("COLUMN customer_service_role.id", "主键"); executeComment("COLUMN customer_service_role.role_key", "角色标识符(唯一)"); executeComment("COLUMN customer_service_role.name", "角色名称"); executeComment("COLUMN customer_service_role.description", "角色描述"); executeComment("COLUMN customer_service_role.prompt", "系统提示词(角色人设与行为规范)"); executeComment("COLUMN customer_service_role.enabled", "是否启用"); executeComment("COLUMN customer_service_role.create_time", "创建时间"); executeComment("COLUMN customer_service_role.update_time", "更新时间"); executeComment("COLUMN customer_service_role.is_delete", "逻辑删除: FALSE=正常 TRUE=已删除"); executeComment("COLUMN customer_service_role.allowed_mcp_tools", "允许使用的 MCP 工具列表(JSONB 数组,空=不允许,[\"*\"]=全部允许)"); // ===== customer_service_role_category ===== executeComment("TABLE customer_service_role_category", "客服角色知识库关联表(角色与知识库分类的多对多关系)"); executeComment("COLUMN customer_service_role_category.id", "主键"); executeComment("COLUMN customer_service_role_category.role_id", "角色 ID(关联 customer_service_role.id)"); executeComment("COLUMN customer_service_role_category.category_id", "分类 ID(关联 knowledge_category.id)"); executeComment("COLUMN customer_service_role_category.create_time", "创建时间"); executeComment("COLUMN customer_service_role_category.is_delete", "逻辑删除: FALSE=正常 TRUE=已删除"); // ===== customer_account ===== executeComment("TABLE customer_account", "客服账号表(对外暴露的客服入口账号)"); executeComment("COLUMN customer_account.id", "主键"); executeComment("COLUMN customer_account.account_key", "账号标识符(唯一)"); executeComment("COLUMN customer_account.name", "账号名称"); executeComment("COLUMN customer_account.description", "账号描述"); executeComment("COLUMN customer_account.role_id", "关联角色 ID(关联 customer_service_role.id)"); executeComment("COLUMN customer_account.enabled", "是否启用"); executeComment("COLUMN customer_account.create_time", "创建时间"); executeComment("COLUMN customer_account.update_time", "更新时间"); executeComment("COLUMN customer_account.is_delete", "逻辑删除: FALSE=正常 TRUE=已删除"); // ===== conversation_session ===== executeComment("TABLE conversation_session", "会话归属表(记录每个会话归属的账号和角色)"); executeComment("COLUMN conversation_session.conversation_id", "会话 ID(主键)"); executeComment("COLUMN conversation_session.account_id", "归属账号 ID(关联 customer_account.id)"); executeComment("COLUMN conversation_session.role_id", "归属角色 ID(关联 customer_service_role.id)"); executeComment("COLUMN conversation_session.create_time", "创建时间"); executeComment("COLUMN conversation_session.update_time", "更新时间"); // ===== sensitive_word ===== executeComment("TABLE sensitive_word", "敏感词表(DFA 引擎驱动的内容安全过滤)"); executeComment("COLUMN sensitive_word.id", "主键(雪花算法生成)"); executeComment("COLUMN sensitive_word.word", "敏感词内容"); executeComment("COLUMN sensitive_word.category", "分类: politics / porn / abuse / custom,默认 custom"); executeComment("COLUMN sensitive_word.level", "级别: 1=脱敏(MASK) / 2=拦截(BLOCK)"); executeComment("COLUMN sensitive_word.is_active", "是否启用"); executeComment("COLUMN sensitive_word.remark", "备注说明"); executeComment("COLUMN sensitive_word.create_time", "创建时间"); executeComment("COLUMN sensitive_word.update_time", "更新时间"); executeComment("COLUMN sensitive_word.is_delete", "逻辑删除: FALSE=正常 TRUE=已删除"); // ===== content_audit_log ===== executeComment("TABLE content_audit_log", "内容审计日志表(记录敏感词命中事件,只追加不删除)"); executeComment("COLUMN content_audit_log.id", "主键(雪花算法生成)"); executeComment("COLUMN content_audit_log.session_id", "会话 ID(关联 conversation_session)"); executeComment("COLUMN content_audit_log.direction", "检测方向: INPUT(用户输入) / OUTPUT(AI 输出)"); executeComment("COLUMN content_audit_log.original_text", "原始违规内容(截断至前 50 字)"); executeComment("COLUMN content_audit_log.hit_words", "命中词列表(JSON 数组)"); executeComment("COLUMN content_audit_log.action_taken", "采取的动作: PASS(放行) / MASK(脱敏) / BLOCK(拦截)"); executeComment("COLUMN content_audit_log.create_time", "创建时间(事件发生时间)"); // ===== message_feedback ===== executeComment("TABLE message_feedback", "消息反馈表(用户对 AI 回复的点赞/点踩反馈)"); executeComment("COLUMN message_feedback.id", "主键(雪花算法生成)"); executeComment("COLUMN message_feedback.message_id", "AI 消息 ID(唯一索引,重复提交覆盖)"); executeComment("COLUMN message_feedback.conversation_id", "会话 ID"); executeComment("COLUMN message_feedback.feedback_type", "反馈类型: THUMBS_UP(有帮助) / THUMBS_DOWN(没帮助)"); executeComment("COLUMN message_feedback.reason_category", "点踩原因分类: inaccurate / irrelevant / incomplete / other"); executeComment("COLUMN message_feedback.reason_comment", "自由文本补充说明"); executeComment("COLUMN message_feedback.processed", "是否已被运营人员处理"); executeComment("COLUMN message_feedback.processed_by", "处理人 ID"); executeComment("COLUMN message_feedback.processed_time", "处理时间"); executeComment("COLUMN message_feedback.create_time", "创建时间"); executeComment("COLUMN message_feedback.update_time", "更新时间(重复提交时覆盖)"); executeComment("COLUMN message_feedback.is_delete", "逻辑删除: FALSE=正常 TRUE=已删除"); // ===== faq_feedback_link ===== executeComment("TABLE faq_feedback_link", "FAQ 与反馈维护关联表(记录反馈如何转化为 FAQ 维护动作)"); executeComment("COLUMN faq_feedback_link.id", "主键(雪花算法生成)"); executeComment("COLUMN faq_feedback_link.feedback_id", "关联反馈 ID(对应 message_feedback.id)"); executeComment("COLUMN faq_feedback_link.faq_id", "关联 FAQ ID(对应 knowledge_faq.id,创建前可为空)"); executeComment("COLUMN faq_feedback_link.action_type", "维护动作类型: create / append_similar / edit_answer / mark_resolved"); executeComment("COLUMN faq_feedback_link.original_question", "用户原始问题快照"); executeComment("COLUMN faq_feedback_link.original_answer", "AI 原回复快照"); executeComment("COLUMN faq_feedback_link.operator_id", "运营人员 ID"); executeComment("COLUMN faq_feedback_link.remark", "备注说明"); executeComment("COLUMN faq_feedback_link.create_time", "创建时间"); // ===== knowledge_faq ===== executeComment("TABLE knowledge_faq", "FAQ 知识库表(用于意图路由后的精准问答匹配)"); executeComment("COLUMN knowledge_faq.id", "主键(雪花算法生成)"); executeComment("COLUMN knowledge_faq.question", "标准问题"); executeComment("COLUMN knowledge_faq.answer", "标准答案"); executeComment("COLUMN knowledge_faq.similar_questions", "相似问题列表(JSON 字符串数组)"); executeComment("COLUMN knowledge_faq.category", "FAQ 分类(已废弃,改用 category_id)"); executeComment("COLUMN knowledge_faq.category_id", "分类 ID(引用 knowledge_category.id)"); executeComment("COLUMN knowledge_faq.status", "状态: ENABLED(启用) / DISABLED(禁用)"); executeComment("COLUMN knowledge_faq.priority", "优先级(数值越大越优先匹配)"); executeComment("COLUMN knowledge_faq.hit_count", "命中次数统计"); executeComment("COLUMN knowledge_faq.source", "来源: manual(手动录入) / import(批量导入) / feedback_positive(点赞反馈) / feedback_negative(点踩反馈)"); executeComment("COLUMN knowledge_faq.created_from_feedback_id", "由哪条反馈创建(追溯用)"); executeComment("COLUMN knowledge_faq.create_time", "创建时间"); executeComment("COLUMN knowledge_faq.update_time", "更新时间"); executeComment("COLUMN knowledge_faq.is_delete", "逻辑删除: FALSE=正常 TRUE=已删除"); // ===== faq_embedding ===== executeComment("TABLE faq_embedding", "FAQ 向量索引表(存储 FAQ 语义向量,用于相似问题匹配)"); executeComment("COLUMN faq_embedding.id", "主键"); executeComment("COLUMN faq_embedding.faq_id", "关联 FAQ ID(对应 knowledge_faq.id)"); executeComment("COLUMN faq_embedding.embedding", "向量嵌入(维度由 knowledge.vector.dimension 配置)"); executeComment("COLUMN faq_embedding.model_name", "使用的 Embedding 模型名称"); executeComment("COLUMN faq_embedding.create_time", "创建时间"); // ===== sys_user ===== executeComment("TABLE sys_user", "系统用户表(管理后台登录用户)"); executeComment("COLUMN sys_user.id", "主键(雪花算法生成)"); executeComment("COLUMN sys_user.username", "用户名(唯一)"); executeComment("COLUMN sys_user.password", "密码(BCrypt 加密)"); executeComment("COLUMN sys_user.nickname", "昵称"); executeComment("COLUMN sys_user.email", "邮箱"); executeComment("COLUMN sys_user.phone", "手机号"); executeComment("COLUMN sys_user.avatar", "头像URL"); executeComment("COLUMN sys_user.enabled", "是否启用"); executeComment("COLUMN sys_user.last_login_time", "最后登录时间"); executeComment("COLUMN sys_user.create_time", "创建时间"); executeComment("COLUMN sys_user.update_time", "更新时间"); executeComment("COLUMN sys_user.is_delete", "逻辑删除"); // ===== sys_role ===== executeComment("TABLE sys_role", "系统角色表(RBAC 角色定义)"); executeComment("COLUMN sys_role.id", "主键"); executeComment("COLUMN sys_role.role_key", "角色标识符(唯一)"); executeComment("COLUMN sys_role.name", "角色名称"); executeComment("COLUMN sys_role.description", "角色描述"); executeComment("COLUMN sys_role.enabled", "是否启用"); executeComment("COLUMN sys_role.create_time", "创建时间"); executeComment("COLUMN sys_role.update_time", "更新时间"); executeComment("COLUMN sys_role.is_delete", "逻辑删除"); // ===== sys_permission ===== executeComment("TABLE sys_permission", "系统权限表(细粒度权限定义)"); // ===== sys_user_role ===== executeComment("TABLE sys_user_role", "用户-角色关联表(多对多)"); // ===== rag_hit_log ===== executeComment("TABLE rag_hit_log", "RAG 命中记录表(记录每次知识库检索的命中/未命中情况)"); // ===== dashboard_snapshot ===== executeComment("TABLE dashboard_snapshot", "运营看板每日汇总快照"); // ===== api_key ===== executeComment("TABLE api_key", "API Key 表(开放平台认证令牌)"); // ===== webhook_config ===== executeComment("TABLE webhook_config", "Webhook 配置表(事件推送订阅)"); // ===== mcp_server_config ===== executeComment("TABLE mcp_server_config", "MCP Server 配置表(管理外部 MCP Server 连接配置)"); executeComment("COLUMN mcp_server_config.id", "主键(雪花算法生成)"); executeComment("COLUMN mcp_server_config.name", "配置名称"); executeComment("COLUMN mcp_server_config.transport_type", "传输类型: stdio(标准输入输出) / sse(Server-Sent Events)"); executeComment("COLUMN mcp_server_config.server_url", "SSE 模式的 MCP Server URL"); executeComment("COLUMN mcp_server_config.command", "stdio 模式的启动命令"); executeComment("COLUMN mcp_server_config.args", "stdio 模式的命令参数(多个用逗号分隔)"); executeComment("COLUMN mcp_server_config.env_vars", "环境变量(JSONB 键值对)"); executeComment("COLUMN mcp_server_config.headers", "自定义请求头(JSONB 键值对,仅 SSE/HTTP 模式生效,用于鉴权)"); executeComment("COLUMN mcp_server_config.description", "描述说明"); executeComment("COLUMN mcp_server_config.is_active", "是否启用"); executeComment("COLUMN mcp_server_config.create_time", "创建时间"); executeComment("COLUMN mcp_server_config.update_time", "更新时间"); executeComment("COLUMN mcp_server_config.is_delete", "逻辑删除: FALSE=正常 TRUE=已删除"); // ===== ai_model_config ===== executeComment("TABLE ai_model_config", "AI 大模型配置表(管理多套模型配置,按应用类型绑定)"); executeComment("COLUMN ai_model_config.id", "主键(雪花算法生成)"); executeComment("COLUMN ai_model_config.name", "配置名称"); executeComment("COLUMN ai_model_config.app_type", "应用类型: CHAT / EMBEDDING / RAG_REWRITE / RERANK"); executeComment("COLUMN ai_model_config.provider", "模型提供商: dashscope / openai / deepseek / moonshot / zhipu / volcengine 等"); executeComment("COLUMN ai_model_config.api_key", "API Key(数据库加密存储,前端脱敏展示)"); executeComment("COLUMN ai_model_config.model_name", "模型名称(如 qwen-turbo、gpt-4o)"); executeComment("COLUMN ai_model_config.temperature", "温度参数(控制输出随机性,0.0~2.0)"); executeComment("COLUMN ai_model_config.max_tokens", "最大 Token 数(单次生成上限)"); executeComment("COLUMN ai_model_config.base_url", "API 基础地址(可选,用于私有化部署或第三方厂商)"); executeComment("COLUMN ai_model_config.extra_config", "扩展配置(JSONB,存储 topP、dimensions 等自定义参数)"); executeComment("COLUMN ai_model_config.is_active", "是否激活(每种 App 类型只能有一个激活配置)"); executeComment("COLUMN ai_model_config.priority", "优先级(数值越大越优先,多套配置时生效)"); executeComment("COLUMN ai_model_config.description", "配置描述说明"); executeComment("COLUMN ai_model_config.create_time", "创建时间"); executeComment("COLUMN ai_model_config.update_time", "更新时间"); executeComment("COLUMN ai_model_config.is_delete", "逻辑删除: FALSE=正常 TRUE=已删除"); // ===== chat_message.user_id ===== executeComment("COLUMN chat_message.user_id", "所属系统用户ID(数据隔离,SDK调用时为null)"); // ===== api_key.role_ids ===== executeComment("COLUMN api_key.role_ids", "绑定的客服角色 ID 列表(JSONB 数组,空数组=不限制,返回所有启用角色)"); // ===== llm_call_trace ===== executeComment("TABLE llm_call_trace", "LLM 调用追踪表(记录每次 LLM 调用的 system prompt/回复/模型参数/耗时,用于提示词优化调试,append-only)"); executeComment("COLUMN llm_call_trace.id", "主键(雪花算法生成)"); executeComment("COLUMN llm_call_trace.conversation_id", "会话 ID"); executeComment("COLUMN llm_call_trace.role_id", "客服角色 ID(可空)"); executeComment("COLUMN llm_call_trace.role_name", "角色名称快照(角色改名后仍可追溯)"); executeComment("COLUMN llm_call_trace.account_id", "账户 ID(可空,预留租户/个人信息删除权)"); executeComment("COLUMN llm_call_trace.api_key_id", "API Key ID(可空,预留租户隔离)"); executeComment("COLUMN llm_call_trace.intent", "意图:CHAT / CHITCHAT / FAQ / RAG"); executeComment("COLUMN llm_call_trace.enable_rag", "是否启用 RAG 增强"); executeComment("COLUMN llm_call_trace.system_prompt", "最终注入 LLM 的完整 system prompt"); executeComment("COLUMN llm_call_trace.global_prompt", "全局提示词快照(可空)"); executeComment("COLUMN llm_call_trace.role_prompt", "角色提示词快照(可空)"); executeComment("COLUMN llm_call_trace.user_message", "用户原始消息(脱敏后)"); executeComment("COLUMN llm_call_trace.ai_response", "AI 回复(截断,保留头部+尾部)"); executeComment("COLUMN llm_call_trace.ai_response_truncated", "AI 回复是否被截断"); executeComment("COLUMN llm_call_trace.rag_context", "RAG 资料块(可空)"); executeComment("COLUMN llm_call_trace.faq_hit", "是否 FAQ 命中"); executeComment("COLUMN llm_call_trace.search_mode", "检索模式:VECTOR / KEYWORD / HYBRID(可空)"); executeComment("COLUMN llm_call_trace.hit_count", "命中文档数(可空)"); executeComment("COLUMN llm_call_trace.model_name", "模型名称"); executeComment("COLUMN llm_call_trace.provider", "提供商"); executeComment("COLUMN llm_call_trace.temperature", "温度参数"); executeComment("COLUMN llm_call_trace.max_tokens", "最大 Token"); executeComment("COLUMN llm_call_trace.latency_ms", "调用耗时(毫秒)"); executeComment("COLUMN llm_call_trace.status", "状态:COMPLETE / ERROR / CANCEL / FAQ / BYPASS"); executeComment("COLUMN llm_call_trace.create_time", "创建时间"); log.info("数据库表注释已应用"); } catch (Exception e) { log.warn("应用数据库表注释时出错", e); } } /** * 执行单条 COMMENT ON 语句 */ private void executeComment(String target, String comment) { jdbcTemplate.execute(String.format("COMMENT ON %s IS '%s'", target, comment.replace("'", "''"))); } // ==================== 清理租户相关数据 ==================== /** * 清理租户相关数据:删除 tenant 表、移除各表的 tenant_id 列和索引 */ private void cleanupTenantData() { // 1. 删除 tenant 表 if (checkTableExists("tenant")) { jdbcTemplate.execute("DROP TABLE IF EXISTS tenant CASCADE"); log.info("已删除 tenant 表"); } // 2. 清理 api_key.tenant_id if (checkTableExists("api_key")) { try { jdbcTemplate.execute("DROP INDEX IF EXISTS idx_api_key_tenant_id"); jdbcTemplate.execute("ALTER TABLE api_key DROP COLUMN IF EXISTS tenant_id"); } catch (Exception e) { log.debug("清理 api_key.tenant_id 跳过: {}", e.getMessage()); } } // 3. 清理 customer_service_role.tenant_id if (checkTableExists("customer_service_role")) { try { jdbcTemplate.execute("DROP INDEX IF EXISTS idx_csr_tenant_id"); jdbcTemplate.execute("ALTER TABLE customer_service_role DROP COLUMN IF EXISTS tenant_id"); } catch (Exception e) { log.debug("清理 customer_service_role.tenant_id 跳过: {}", e.getMessage()); } } // 4. 清理 knowledge_category.tenant_id if (checkTableExists("knowledge_category")) { try { jdbcTemplate.execute("DROP INDEX IF EXISTS idx_kc_tenant_id"); jdbcTemplate.execute("ALTER TABLE knowledge_category DROP COLUMN IF EXISTS tenant_id"); } catch (Exception e) { log.debug("清理 knowledge_category.tenant_id 跳过: {}", e.getMessage()); } } // 5. 清理 knowledge_faq.tenant_id if (checkTableExists("knowledge_faq")) { try { jdbcTemplate.execute("DROP INDEX IF EXISTS idx_faq_tenant_id"); jdbcTemplate.execute("ALTER TABLE knowledge_faq DROP COLUMN IF EXISTS tenant_id"); } catch (Exception e) { log.debug("清理 knowledge_faq.tenant_id 跳过: {}", e.getMessage()); } } log.info("租户相关数据清理完成"); } // ==================== API Key 角色绑定 ==================== /** * 为 api_key 表添加 role_ids 列(JSONB 数组,存储绑定的客服角色 ID) */ private void addApiKeyRoleIdsColumn() { if (!checkTableExists("api_key")) { log.debug("api_key 表尚未创建,跳过 role_ids 迁移"); return; } try { String checkSql = "SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'api_key' AND column_name = 'role_ids'"; Integer count = jdbcTemplate.queryForObject(checkSql, Integer.class); if (count != null && count == 0) { log.info("添加 api_key.role_ids 列"); jdbcTemplate.execute("ALTER TABLE api_key ADD COLUMN role_ids JSONB DEFAULT '[]' NOT NULL"); } } catch (Exception e) { log.error("添加 api_key.role_ids 列失败", e); } } }