From d577300889ddc24d60404cb9aa015a7cabdc1b38 Mon Sep 17 00:00:00 2001 From: mingming Date: Fri, 5 Jun 2026 16:52:43 +0800 Subject: [PATCH 01/28] @ docs: add MySQL 8.0 to PostgreSQL 17 migration design spec Spec for migrating the business DB from MySQL 8.0 to PG17 and merging the pgvector store (pg16) into a single PG17 database. Key decisions captured: - Clean cutover, drop old data (no data-migration toolchain) - Single datasource (drop Druid for HikariCP), single Flyway - Business tables + document_chunk in one PG17 DB (same-tx, JOIN-able) - Only 4 native SQL annotations need hand-translation (2 ON CONFLICT, 2 JSONB ->>) - Hybrid test strategy: H2 PG-mode for speed + Testcontainers PG17 for fidelity - CI grep guard against dialect regression - IDENTITY/seed-data sequence sync (setval) for fixed-id seed rows Co-Authored-By: Claude Opus 4.8 (1M context) @ --- ...26-06-05-mysql-to-pg17-migration-design.md | 244 ++++++++++++++++++ 1 file changed, 244 insertions(+) create mode 100644 docs/specs/2026-06-05-mysql-to-pg17-migration-design.md diff --git a/docs/specs/2026-06-05-mysql-to-pg17-migration-design.md b/docs/specs/2026-06-05-mysql-to-pg17-migration-design.md new file mode 100644 index 0000000..f52ad62 --- /dev/null +++ b/docs/specs/2026-06-05-mysql-to-pg17-migration-design.md @@ -0,0 +1,244 @@ +# MySQL 8.0 → PostgreSQL 17 迁移设计 + +> **类型**:功能实现规格说明(Spec) +> **日期**:2026-06-05 +> **状态**:已评审,待实现 +> **范围**:主业务库 MySQL 8.0 → PG17;pgvector pg16 → pg17;业务表与向量表合并为 PG17 单库 + +--- + +## 1. 背景与目标 + +### 1.1 动机 + +- **统一技术栈 / 减组件**:业务库(MySQL)与向量库(pgvector)合并到同一 PG17 实例,去掉一套 SQL 数据源与一套 Flyway。 +- **要 PG 特有能力**:RAG 向量表与业务表同库后,可同事务、可按 `workspace_id` JOIN,降低 RAG 检索的数据隔离管控成本。 +- **License / 栈标准**:统一到 PostgreSQL。 +- **趁早切换(MVP 阶段)**:当前数据量小,切换成本最低,避免后期数据增长后迁移困难。 + +### 1.2 目标架构 + +| | 之前 | 之后 | +|:---|:---|:---| +| 业务库 | MySQL 8.0(Druid 数据源) | **PG17 单库**(HikariCP 单数据源) | +| 向量库 | pgvector pg16(独立 HikariCP + 独立 Flyway) | **并入同一 PG17 库**(`document_chunk` 同库) | +| SQL 数据源 | 2 个(Druid 主 + HikariCP 向量) | **1 个**(HikariCP) | +| Flyway | 2 套(`flyway_schema_history` + `_pg`) | **1 套** | +| 日志存储 | ClickHouse | ClickHouse(**不变**) | +| 缓存 | Redis | Redis(**不变**) | + +### 1.3 非目标(明确不做) + +- 不迁 ClickHouse(列存 OLAP,保留分析能力)。 +- 不迁 Redis(内存缓存,保留低延迟)。 +- 不做数据迁移(**干净切换、丢弃旧数据**,无需 ora2pg/pgloader 等工具链)。 +- 不重构业务逻辑(仅替换底层方言)。 +- 不保留 MySQL 兼容回退路径(物理隔离用 Git 分支,回滚靠 `git revert` 整版)。 +- 本期不建 JSONB GIN 索引(仅做 `JSON → JSONB` 类型转换,为将来留路;待迁移稳定后按慢查询场景单独加)。 + +--- + +## 2. Schema 重写(DDL 方言转换) + +将 `eify-app/src/main/resources/db/migration/V1__init.sql`(23 张表)整体重写为 PG17 方言,落到统一迁移目录 `db/migration`(PG 版)。`document_chunk` 向量表(原 `db/migration-pg/V1__init_pgvector.sql`)并入同一套迁移。 + +### 2.1 类型与语法转换规则 + +| MySQL | PG17 | 备注 | +|:---|:---|:---| +| `BIGINT UNSIGNED AUTO_INCREMENT` | `BIGINT GENERATED BY DEFAULT AS IDENTITY` | PG 无 UNSIGNED;用 `BY DEFAULT` 而非 `ALWAYS`,以允许种子数据显式插入 id(见 §2.4);实体 `@TableId(type=IdType.AUTO)` 仍兼容 | +| `TINYINT`(status/deleted/enabled/stream_enabled 等) | `SMALLINT` | PG 无 TINYINT;实体类 `Integer` 字段不变 | +| `INT UNSIGNED` / `BIGINT UNSIGNED`(非主键) | `INT` / `BIGINT` | 去 UNSIGNED | +| `DECIMAL(3,2)` | `NUMERIC(3,2)` | temperature/top_p/penalty 等 | +| `DATETIME ... ON UPDATE CURRENT_TIMESTAMP` | `TIMESTAMPTZ`(去 ON UPDATE) | 更新时间靠 `FieldMetaObjectHandler` 的 `strictUpdateFill` 填充,行为不变 | +| `JSON` | `JSONB` | auth_config / extra_params / metadata / variables / config 等 | +| `TEXT` | `TEXT` | 不变 | +| `` `反引号标识符` `` | 裸标识符(小写) | 配合 `map-underscore-to-camel-case` | +| 内联 `KEY idx (col)` | 独立 `CREATE INDEX IF NOT EXISTS` | | +| 前缀索引 `refresh_token(64)` | 整列索引 `idx_refresh_token(refresh_token)` | PG 不支持前缀索引 | +| `ENGINE=InnoDB CHARSET/COLLATE` | 删除 | PG 库级 UTF-8 | +| 列内 `COMMENT '...'` | `COMMENT ON COLUMN ...` 或省略 | 保留关键注释 | +| 种子 `INSERT ... ON DUPLICATE KEY UPDATE` | `INSERT ... ON CONFLICT (key) DO UPDATE` | admin 用户 / 默认工作空间种子数据 | + +### 2.2 向量表并入 + +- `CREATE EXTENSION IF NOT EXISTS vector;` 保留。 +- `document_chunk` 表与 HNSW 向量索引(`vector_cosine_ops`)不变,与业务表同库。 +- 向量维度策略沿用 `migration-pg/V2__flexible_vector_dimension.sql` 的现有逻辑。 + +### 2.3 Flyway 幂等性(更简单) + +PG 原生支持 `CREATE TABLE/INDEX IF NOT EXISTS`、`ADD COLUMN IF NOT EXISTS`,**不再需要** MySQL 的 `INFORMATION_SCHEMA` 检查样板。`docs/guides/DATABASE.md` 的 Flyway 幂等模板需同步更新为 PG 版。 + +### 2.4 种子数据与 IDENTITY 序列同步(关键坑) + +`V1__init.sql` 种子数据**显式插入固定主键**(`ai_user` id=1 admin、`ai_workspace` id=1 System Workspace),且全表 `workspace_id DEFAULT 1` 依赖这个确定 id,因此 id 必须固定、不能交给自增。 + +PG 的 IDENTITY 列处理与 MySQL `AUTO_INCREMENT` 不同: + +- 列定义必须用 `GENERATED BY DEFAULT AS IDENTITY`(非 `ALWAYS`),否则 `INSERT ... (id) VALUES (1)` 直接报错。 +- **即使显式插入成功,IDENTITY 底层序列也不会自动前进**——下一条自增仍从 1 开始,撞主键。 +- 因此种子数据插入后,必须对涉及显式 id 的表执行序列同步: + ```sql + SELECT setval(pg_get_serial_sequence('ai_user', 'id'), (SELECT MAX(id) FROM ai_user)); + SELECT setval(pg_get_serial_sequence('ai_workspace', 'id'), (SELECT MAX(id) FROM ai_workspace)); + ``` +- 种子 upsert 用 `ON CONFLICT (id) DO UPDATE`(替代 MySQL `ON DUPLICATE KEY UPDATE`)。 +- 此 `setval` 步骤是 §8 "ID 回填"验证项的前置条件,集成测试需覆盖:种子插入后新建一条记录,确认 id 不与种子冲突。 + +--- + +## 3. 原生 SQL 方言翻译(穷尽清单) + +经全仓 grep 勘察确证:**XML mapper 仅 1 个**(`ConversationMapper.xml`,游标分页 `LIMIT`/`ORDER BY DESC`/`updated_at < ?`,全可移植,零改动);真正的方言坑全在 `@Select/@Insert/@Update` 注解和一处运行时 `JdbcTemplate` DDL。**总计仅 4 处需人工翻译**。 + +> ⚠️ ora2pg / pgloader 等工具只翻译 schema 与数据,**无法**翻译嵌入 Java 字符串里的注解 SQL。这 4 处必须人工逐个翻译。 + +| # | 文件:行 | MySQL 写法 | PG17 翻译 | 风险 | +|:---|:---|:---|:---|:---| +| 1 | `ProviderHealthMapper.java:18` | `ON DUPLICATE KEY UPDATE x = VALUES(x)` | `ON CONFLICT (provider_id) DO UPDATE SET x = EXCLUDED.x` | 中(热路径 upsert,依赖 `uk_provider_id`) | +| 2 | `AgentKnowledgeMapper.java:28` | 批量 `ON DUPLICATE KEY UPDATE deleted=0` | `ON CONFLICT (agent_id, knowledge_id) DO UPDATE SET deleted=0, updated_at=NOW()` | 中(依赖 `uk_agent_knowledge` 唯一约束) | +| 3 | `ProviderMapper.java:21` | `JSON_EXTRACT(n.config,'$.providerId') = #{providerId}` | `(n.config->>'providerId')::bigint = #{providerId}` | **高**(`->>` 返回 text,必须 `::bigint` 强转,否则 `operator does not exist: text = bigint`) | +| 4 | `McpServerMapper.java:16` | `JSON_EXTRACT(n.config,'$.serverId') = #{serverId}` | `(n.config->>'serverId')::bigint = #{serverId}` | **高**(同上) | + +> 注:`AgentMcpToolMapper.batchInsert`(`eify-mcp`,line 25)经核查是纯 `INSERT ... VALUES`(配合 `deleteByAgentId` 的"先删后插"模式),仅含可移植的 `NOW()`,**非方言坑,零改动**。 + +### 3.1 删除项 + +- `ChatController.java:220-242` `fixDatabase()`:临时开发接口,纯 MySQL(`INFORMATION_SCHEMA TABLE_SCHEMA='eify'` + 反引号 `ALTER ... MODIFY COLUMN JSON NULL`),且是无防护的 DDL 端点。**直接删除**——新 schema 中 `metadata JSONB` 本就可空。对应测试 `ChatControllerTest` 中 `jdbcTemplate` 注入相关用例一并清理。 + +### 3.2 清理吞异常的技术债 + +- `ProviderServiceImpl.java:286`:现用 `try/catch` 吞掉 `JSON_EXTRACT` 异常(注释"可能是不支持 JSON_EXTRACT 的数据库")。翻译为 `->>` 后,此处吞异常逻辑需重新评估——PG 下 JSONB 查询应正常工作,不应再静默咽掉错误。改由 Testcontainers 集成测试覆盖(见 §5)。 + +### 3.3 明确不算坑(避免范围扩大) + +- `NOW()`:PG 原生支持,零改动(全仓大量出现)。 +- `LIMIT #{x}`、`IN `、`COUNT(*)`、`(#{x} IS NULL OR col=#{x})`:全可移植。 +- MyBatis-Plus 逻辑删除、`id-type: AUTO`、分页插件:原生支持 PG。 + +--- + +## 4. 数据源、Flyway 与连接池收口 + +### 4.1 单数据源(去双源) + +- 删除 `PgVectorConfig`(`eify-knowledge`,独立 HikariCP)与 `PgFlywayConfig`(`eify-app`,独立 Flyway + `flyway_schema_history_pg`)。 +- `pgJdbcTemplate` 改为指向主数据源(同库)。`ChunkRepository`(`eify-knowledge`,line 31)的向量读写仍走 `JdbcTemplate` + pgvector-java 编码 `VECTOR`,但已是同库,可与业务表同事务、按 `workspace_id` JOIN。 + +### 4.2 连接池:Druid → HikariCP + +- 移除 `druid-spring-boot-4-starter` 依赖与 `application.yml` 中整段 `druid:` 配置(连接池 + `stat-view-servlet` + `filter` + `connectionProperties`),以及 dev 的 Druid 监控面板配置。 +- 改用 Spring Boot 默认 HikariCP,连接池参数用 `spring.datasource.hikari.*` 表达(`max-active → maximum-pool-size`,`max-wait → connection-timeout`,`min-idle → minimum-idle` 等)。 +- **取舍(已确认接受)**:失去 Druid 监控面板与 slow-SQL stat filter。慢查询监控改由 PG 侧 `log_min_duration_statement` 或现有 SQL 日志切面(`sql.logging.*`)承担。 + +### 4.3 单 Flyway + +- 保留主 Flyway 自动配置但指向 PG,迁移目录统一 `db/migration`(PG 方言)。 +- `flyway-mysql` 依赖移除,`flyway-database-postgresql` 保留(已存在于 `eify-app/pom.xml`)。 + +--- + +## 5. 测试策略(混合:H2 为主 + Testcontainers 专攻) + +采用 Spring Boot 4.x 生态下处理数据库强绑定特性的黄金标准:**H2 求速度,Testcontainers 求保真**。 + +### 5.1 H2(PostgreSQL 模式)—— 承担大头 + +- `application-test.yml`:H2 URL 改 `jdbc:h2:mem:testdb;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE`;移除 `datasource-pgvector` 块。 +- `schema-h2.sql`:已是独立、去 MySQL 化 schema,微调为 PG 兼容(identity 语法、`SMALLINT` 等)。 +- 向量表:H2 PG 模式不支持 `VECTOR` 类型,`document_chunk` 在测试 schema 桩为 `TEXT` 或跳过(与现状一致)。 +- Service 单测(`@ExtendWith(MockitoExtension.class)`)不碰库,零影响。 + +### 5.2 Testcontainers PG17 + pgvector —— 精准打击保真盲区 + +仅覆盖 H2 表达不了的三类路径: + +1. 两条 `->>` JSONB 等值查询(`ProviderMapper` / `McpServerMapper`)——消灭 §3.2 的吞异常盲区。 +2. `ON CONFLICT` upsert(`ProviderHealthMapper` / `AgentKnowledgeMapper`)。 +3. 向量检索(`ChunkRepository` 的 HNSW 余弦查询)。 + +- 镜像:`pgvector/pgvector:pg17`。 +- 连接注入:使用 Spring Boot 的 `@ServiceConnection`,免去手动 `container.getJdbcUrl()` 塞环境变量的样板。 +- CI:单测仍走 H2(毫秒级反馈),Testcontainers 用例需 Docker,归入集成测试分组。 + +### 5.3 验证基线 + +- `mvn test`(全模块)+ 前端 `npx vitest run` + `npx vue-tsc --noEmit` 全绿(前端零改动,应直接通过)。 +- 起 PG17 容器冒烟:应用启动 Flyway 成功 + 关键接口可用。 + +--- + +## 6. 配置与部署 + +### 6.1 pom.xml + +- 移除:`mysql-connector-j`、`flyway-mysql`、`druid-spring-boot-4-starter`。 +- 保留:`postgresql`、`pgvector`、`flyway-database-postgresql`、`mybatis-plus-spring-boot4-starter`。 +- 新增(测试):Testcontainers PostgreSQL 模块(test scope)。 + +### 6.2 application*.yml(dev / test / staging / prod) + +- `url` → `jdbc:postgresql://...`,`driver-class-name` → `org.postgresql.Driver`。 +- 去掉 MySQL 专属参数(`serverTimezone` / `characterEncoding` / `zeroDateTimeBehavior` / `allowPublicKeyRetrieval`)。 +- 保留 `stringtype=unspecified`(JSONB 写入需要)。 +- 删除 `spring.datasource-pgvector` 整块(合并入主数据源)。 +- 遵循 `${ENV_VAR:默认值}` 规范,无硬编码 IP / 密码。 + +### 6.3 docker-compose.yml + +- 删除 `mysql` 服务与 `mysql-data` 卷。 +- `pgvector` 镜像 `pgvector/pgvector:pg16` → `pg17`,承载业务库 + 向量库(单库)。 +- backend 环境变量 `MYSQL_*` 改为 `PG_*`(`PG_HOST` / `PG_PORT` / `PG_DATABASE` / `PG_USERNAME` / `PG_PASSWORD`),单库后与向量库共用同一组;`depends_on` 去 mysql、留 pgvector。 + +--- + +## 7. 风险、回滚与交付顺序 + +### 7.1 主要风险 + +| 风险 | 缓解 | +|:---|:---| +| DDL 转换遗漏(UNSIGNED 边界、JSON→JSONB 需 `stringtype=unspecified`) | 应用启动 Flyway 成功 + 全量测试兜底 | +| 4 处注解 SQL 翻译错误(尤其 `->>` 漏 `::bigint`) | Testcontainers PG17 集成测试精准覆盖(§5.2) | +| `ON UPDATE CURRENT_TIMESTAMP` 语义改由 MyBatis-Plus 填充 | 集成测试覆盖一条 update 路径,确认 `updated_at` 刷新 | +| `@TableId(IdType.AUTO)` + IDENTITY 主键回填 | **验证项**:confirm insert 后 PgJDBC 经 `RETURNING`/`getGeneratedKeys` 正确回填 ID 到 Java 对象 | +| 失去 Druid 监控 | 已记录取舍,PG 侧 `log_min_duration_statement` + SQL 日志切面补位 | +| `main` 上方言回退(破窗效应) | CI grep 守卫脚本(§7.2) | + +### 7.2 CI 防回归守卫 + +新增 `scripts/check_sql_dialect.sh`,在 GitHub Actions / pre-commit 跑。一旦 `src/main/java/` 重新出现以下模式,CI 直接 fail: + +```bash +grep -rnE "ON DUPLICATE KEY|JSON_EXTRACT|INFORMATION_SCHEMA|MODIFY COLUMN" eify-*/src/main/java/ && exit 1 || exit 0 +``` + +(反引号标识符因 Java 文本转义复杂,可作为可选追加模式。) + +### 7.3 回滚 + +干净切换 + Git 分支物理隔离。MySQL 相关改动集中在迁移分支,回滚即 `git revert` 整版,代码中零双方言逻辑。旧 MySQL 容器数据卷在验证通过前不删。 + +### 7.4 交付顺序(自底向上) + +1. **Schema 重写**:`db/migration` PG 版 DDL(含 `document_chunk` 并入)+ 同步 `docs/guides/DATABASE.md` 幂等模板。 +2. **数据访问层翻译**:4 处注解 SQL(§3)+ 删除 `fixDatabase()`(§3.1)+ 清理吞异常(§3.2)。 +3. **数据源 / Flyway / 连接池改造**:删双源、Druid→HikariCP、单 Flyway(§4)。 +4. **配置文件四环境切换**(§6.2)。 +5. **docker-compose 改造**(§6.3)。 +6. **测试切 PG**:H2 PG 模式 + 新增 Testcontainers 用例(§5)。 +7. **全量验证**:编译 + 全量测试 + PG17 容器冒烟(§5.3)。 + +--- + +## 8. 验收标准 + +- [ ] PG17 单库承载全部 23 张业务表 + `document_chunk`,应用启动 Flyway 成功。 +- [ ] `mvn test` 全模块通过;新增 Testcontainers 用例覆盖 JSONB 查询 / upsert / 向量检索。 +- [ ] 前端 `vitest` + `vue-tsc` 通过(前端零改动)。 +- [ ] 代码中无 MySQL 残留(mysql-connector / Druid / `ON DUPLICATE` / `JSON_EXTRACT` / `INFORMATION_SCHEMA`)。 +- [ ] CI grep 守卫脚本生效。 +- [ ] 主键 ID 回填、`updated_at` 自动刷新经集成测试确认。 +- [ ] 配置无硬编码 IP / 密码,遵循 `${ENV_VAR:默认值}`。 +- [ ] `docs/guides/DATABASE.md` Flyway 幂等模板已更新为 PG 版。 From 28b3f86c121430dcac3589ad9345e3332516515c Mon Sep 17 00:00:00 2001 From: mingming Date: Fri, 5 Jun 2026 16:58:55 +0800 Subject: [PATCH 02/28] @ docs: harden PG17 migration spec with defensive-coding refinements - setval COALESCE fallback to avoid NULL crash on empty-table seed sync - CI guard rewritten with exit-code branch (robust on minimal runners) - new 3.4: clean up MySQL dialect words in comments/logs to avoid CI false-positives Co-Authored-By: Claude Opus 4.8 (1M context) @ --- ...26-06-05-mysql-to-pg17-migration-design.md | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/docs/specs/2026-06-05-mysql-to-pg17-migration-design.md b/docs/specs/2026-06-05-mysql-to-pg17-migration-design.md index f52ad62..081ba52 100644 --- a/docs/specs/2026-06-05-mysql-to-pg17-migration-design.md +++ b/docs/specs/2026-06-05-mysql-to-pg17-migration-design.md @@ -78,10 +78,10 @@ PG 的 IDENTITY 列处理与 MySQL `AUTO_INCREMENT` 不同: - 列定义必须用 `GENERATED BY DEFAULT AS IDENTITY`(非 `ALWAYS`),否则 `INSERT ... (id) VALUES (1)` 直接报错。 - **即使显式插入成功,IDENTITY 底层序列也不会自动前进**——下一条自增仍从 1 开始,撞主键。 -- 因此种子数据插入后,必须对涉及显式 id 的表执行序列同步: +- 因此种子数据插入后,必须对涉及显式 id 的表执行序列同步。**注意空表兜底**:若表无数据,`MAX(id)` 返回 `NULL` 会使 `setval` 报错并中断 Flyway,必须用 `COALESCE` 兜底: ```sql - SELECT setval(pg_get_serial_sequence('ai_user', 'id'), (SELECT MAX(id) FROM ai_user)); - SELECT setval(pg_get_serial_sequence('ai_workspace', 'id'), (SELECT MAX(id) FROM ai_workspace)); + SELECT setval(pg_get_serial_sequence('ai_user', 'id'), COALESCE((SELECT MAX(id) FROM ai_user), 1)); + SELECT setval(pg_get_serial_sequence('ai_workspace', 'id'), COALESCE((SELECT MAX(id) FROM ai_workspace), 1)); ``` - 种子 upsert 用 `ON CONFLICT (id) DO UPDATE`(替代 MySQL `ON DUPLICATE KEY UPDATE`)。 - 此 `setval` 步骤是 §8 "ID 回填"验证项的前置条件,集成测试需覆盖:种子插入后新建一条记录,确认 id 不与种子冲突。 @@ -117,6 +117,13 @@ PG 的 IDENTITY 列处理与 MySQL `AUTO_INCREMENT` 不同: - `LIMIT #{x}`、`IN `、`COUNT(*)`、`(#{x} IS NULL OR col=#{x})`:全可移植。 - MyBatis-Plus 逻辑删除、`id-type: AUTO`、分页插件:原生支持 PG。 +### 3.4 注释 / 日志中的方言字眼(避免 CI 守卫误报) + +§7.2 的 grep 守卫扫的是全文本,会连**注释和日志字符串**里的方言词一起命中。翻译 SQL 时需同步清理这些残留,否则 CI 误报红: + +- `ProviderHealth.java:17`、`ProviderHealthMapper.java:15`:Javadoc 中"使用 ON DUPLICATE KEY UPDATE …" → 改为"使用 ON CONFLICT …"。 +- `ProviderServiceImpl.java:286`:日志"可能是不支持 JSON_EXTRACT 的数据库" → 随 §3.2 重构一并清理。 + --- ## 4. 数据源、Flyway 与连接池收口 @@ -208,10 +215,17 @@ PG 的 IDENTITY 列处理与 MySQL `AUTO_INCREMENT` 不同: ### 7.2 CI 防回归守卫 -新增 `scripts/check_sql_dialect.sh`,在 GitHub Actions / pre-commit 跑。一旦 `src/main/java/` 重新出现以下模式,CI 直接 fail: +新增 `scripts/check_sql_dialect.sh`,在 GitHub Actions / pre-commit 跑。一旦 `src/main/java/` 重新出现方言残留,CI 直接 fail。用退出码判断(比短路链 `&& ... || ...` 鲁棒,避免在精简 runner 上掩盖执行错误): ```bash -grep -rnE "ON DUPLICATE KEY|JSON_EXTRACT|INFORMATION_SCHEMA|MODIFY COLUMN" eify-*/src/main/java/ && exit 1 || exit 0 +#!/bin/bash +if grep -rnE "ON DUPLICATE KEY|JSON_EXTRACT|INFORMATION_SCHEMA|MODIFY COLUMN" eify-*/src/main/java/; then + echo "🚨 发现 MySQL 方言残留(破窗效应)!请使用 PostgreSQL 原生语法。" + exit 1 +else + echo "✅ SQL 方言检查通过。" + exit 0 +fi ``` (反引号标识符因 Java 文本转义复杂,可作为可选追加模式。) From 085b69aabd9ad917a1838fa9c8c9a6ebf8fa366b Mon Sep 17 00:00:00 2001 From: mingming Date: Fri, 5 Jun 2026 17:21:41 +0800 Subject: [PATCH 03/28] @ docs: add PG17 migration implementation plan (14 tasks across 7 phases) Bite-sized TDD tasks with exact files, code blocks, and verify commands. Key correctness anchors: - V1__init.sql must encode V1+V4~V7 final state (not per-version translation) - ChunkRepository already PG-native: repoint datasource, do not rewrite SQL - 4 dialect annotations translated with exact before/after - Testcontainers PG17 covers IDENTITY backfill / JSONB / ON CONFLICT / vector Co-Authored-By: Claude Opus 4.8 (1M context) @ --- .../2026-06-05-mysql-to-pg17-migration.md | 915 ++++++++++++++++++ 1 file changed, 915 insertions(+) create mode 100644 docs/plans/2026-06-05-mysql-to-pg17-migration.md diff --git a/docs/plans/2026-06-05-mysql-to-pg17-migration.md b/docs/plans/2026-06-05-mysql-to-pg17-migration.md new file mode 100644 index 0000000..7b7ab07 --- /dev/null +++ b/docs/plans/2026-06-05-mysql-to-pg17-migration.md @@ -0,0 +1,915 @@ +# MySQL 8.0 → PostgreSQL 17 迁移实现计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 将主业务库从 MySQL 8.0 迁移到 PostgreSQL 17,并把 pgvector(pg16)向量库合并进同一个 PG17 单库,去掉双数据源、Druid 与双 Flyway。 + +**Architecture:** 干净切换(丢弃旧数据,无数据迁移)。业务表 + `document_chunk` 向量表落同一 PG17 database,单 HikariCP 数据源、单 Flyway。MyBatis-Plus 业务 CRUD 与 `JdbcTemplate` 向量读写共用主数据源。4 处方言注解 SQL 人工翻译,其余可移植。测试采用 H2(PG 模式)为主 + Testcontainers PG17 专攻 JSONB/upsert/向量保真盲区。 + +**Tech Stack:** Spring Boot 4.x、MyBatis-Plus、PostgreSQL 17、pgvector、Flyway、HikariCP、H2、Testcontainers、Maven 多模块。 + +**Spec:** `docs/specs/2026-06-05-mysql-to-pg17-migration-design.md` + +--- + +## 约定(所有任务通用) + +- **分支**:已在 `spec/pg17-migration` 分支。所有实现 commit 落此分支。 +- **提交节奏**:每个 Task 末尾 commit 一次。 +- **验证基线命令**(按需在步骤中调用): + - 编译:`mvn compile -q` + - 单模块测试:`mvn test -pl eify- -am -q` + - 全量后端测试:`mvn test -q` + - 前端(仅 Phase 7 跑一次,前端零改动):`cd eify-web && npx vue-tsc --noEmit && npx vitest run && cd ..` +- **关键事实**:当前生效 schema = `V1__init.sql` + `V4`~`V7` 累积变更。PG 版必须表达**最终态**,不是逐版翻译。最终态差异点: + - `model_config` 含 `model_category`(V4) + - `knowledge_base` 含 `embedding_model_id`(V4) + - `provider`/`ai_agent`/`knowledge_base`/`mcp_server`/`ai_workflow` 的唯一键为 `uk_name_workspace_deleted (name, workspace_id, deleted)`(V5) + - `agent_mcp_tool` 唯一键为 `uk_agent_tool_workspace (agent_id, tool_id, workspace_id)`(V6) + - `mcp_tool` 含 `idx_workspace_id`、`idx_name_workspace`(V6) + +--- + +## Phase 1 — Schema 重写(PG 版 DDL) + +### Task 1: 新建 PG 版 `V1__init.sql`(最终态全表 DDL) + +**Files:** +- Create/覆盖: `eify-app/src/main/resources/db/migration/V1__init.sql`(PG 版) +- Delete: `eify-app/src/main/resources/db/migration/V4__model_category_and_embedding_model_id.sql` +- Delete: `eify-app/src/main/resources/db/migration/V5__name_workspace_deleted_unique.sql` +- Delete: `eify-app/src/main/resources/db/migration/V6__mcp_workspace_isolation.sql` +- Delete: `eify-app/src/main/resources/db/migration/V7__mcp_server_description.sql` +- Delete: `eify-app/src/main/resources/db/migration-pg/`(V1/V2 并入主 V1) + +> 干净切换、丢弃旧数据,V4~V7 的增量 ALTER 不再需要——最终效果直接写进新 V1。`document_chunk`(原 migration-pg)也并入主 V1。 + +**转换规则**(逐表套用,源文件 = 原 MySQL `V1__init.sql` + §约定的最终态差异点): + +| MySQL | PG17 | +|:---|:---| +| `BIGINT UNSIGNED AUTO_INCREMENT` | `BIGINT GENERATED BY DEFAULT AS IDENTITY` | +| `TINYINT` | `SMALLINT` | +| `INT/BIGINT UNSIGNED`(非主键) | `INT` / `BIGINT` | +| `DECIMAL(3,2)` | `NUMERIC(3,2)` | +| `DATETIME [ON UPDATE ...]` | `TIMESTAMPTZ`(去 ON UPDATE,默认 `NOW()`) | +| `JSON` | `JSONB` | +| `` `反引号` `` / `ENGINE=` / `CHARSET=` / 列内 `COMMENT` | 删除 | +| 内联 `KEY idx (...)` | 独立 `CREATE INDEX IF NOT EXISTS` | +| 前缀索引 `refresh_token(64)` | 整列 `idx_refresh_token(refresh_token)` | + +- [ ] **Step 1: 删除旧 MySQL 迁移与 pg 子目录** + +```bash +git rm eify-app/src/main/resources/db/migration/V4__model_category_and_embedding_model_id.sql \ + eify-app/src/main/resources/db/migration/V5__name_workspace_deleted_unique.sql \ + eify-app/src/main/resources/db/migration/V6__mcp_workspace_isolation.sql \ + eify-app/src/main/resources/db/migration/V7__mcp_server_description.sql +git rm -r eify-app/src/main/resources/db/migration-pg/ +``` + +- [ ] **Step 2: 写表头 + 用户/工作空间模块(IDENTITY 示范)** + +> **转录源**:覆盖前先读当前 `eify-app/src/main/resources/db/migration/V1__init.sql`(MySQL 版,23 张表的权威字段/索引清单)。本计划只对带风险的模式(IDENTITY、JSONB、NUMERIC、V4~V7 差异、document_chunk、种子)给出完整代码;其余字段按上方规则表逐列机械转换即可。 + +覆盖写 `V1__init.sql`。用 `ai_user` 作为 IDENTITY + UNIQUE 约束的范式,其余表照此套规则: + +```sql +-- ============================================================ +-- Eify 数据库初始化(PostgreSQL 17) +-- 干净切换:表达 MySQL V1+V4~V7 累积后的最终态 +-- ============================================================ +CREATE EXTENSION IF NOT EXISTS vector; + +CREATE TABLE IF NOT EXISTS ai_user ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + username VARCHAR(64) NOT NULL, + email VARCHAR(128) NOT NULL DEFAULT '', + password VARCHAR(256) NOT NULL, + display_name VARCHAR(128), + avatar_url VARCHAR(512), + status SMALLINT NOT NULL DEFAULT 1, + last_login_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted SMALLINT NOT NULL DEFAULT 0, + creator_id BIGINT, + CONSTRAINT uk_username UNIQUE (username), + CONSTRAINT uk_email UNIQUE (email) +); +``` + +追加 `ai_workspace`、`ai_workspace_member`(`uk_workspace_user`、`idx_user_id`)、`ai_workspace_invite`(`uk_code`、`idx_workspace_id`)、`ai_user_session`(`idx_user_id` + 整列 `idx_refresh_token`)。 + +- [ ] **Step 3: Provider 模块(V4 最终态 + V5 唯一键)** + +`provider` 唯一键用 V5 最终态 `uk_name_workspace_deleted`;`model_config` 含 V4 的 `model_category SMALLINT NOT NULL DEFAULT 0` + `idx_model_category`: + +```sql +CREATE TABLE IF NOT EXISTS provider ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + workspace_id BIGINT NOT NULL DEFAULT 1, + name VARCHAR(100) NOT NULL, + type VARCHAR(50) NOT NULL, + base_url VARCHAR(500) NOT NULL, + auth_config JSONB NOT NULL, + enabled SMALLINT NOT NULL DEFAULT 1, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted SMALLINT NOT NULL DEFAULT 0, + creator_id BIGINT NOT NULL DEFAULT 0, + CONSTRAINT uk_name_workspace_deleted UNIQUE (name, workspace_id, deleted) +); +CREATE INDEX IF NOT EXISTS idx_type_enabled ON provider(type, enabled); +CREATE INDEX IF NOT EXISTS idx_deleted ON provider(deleted); + +CREATE TABLE IF NOT EXISTS model_config ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + provider_id BIGINT NOT NULL, + name VARCHAR(100) NOT NULL, + model_id VARCHAR(100) NOT NULL, + model_category SMALLINT NOT NULL DEFAULT 0, + context_size INT NOT NULL DEFAULT 0, + extra_params JSONB NOT NULL, + enabled SMALLINT NOT NULL DEFAULT 1, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted SMALLINT NOT NULL DEFAULT 0, + creator_id BIGINT NOT NULL DEFAULT 0, + workspace_id BIGINT NOT NULL DEFAULT 1 +); +CREATE INDEX IF NOT EXISTS idx_provider_id ON model_config(provider_id); +CREATE INDEX IF NOT EXISTS idx_model_id ON model_config(model_id); +CREATE INDEX IF NOT EXISTS idx_enabled_deleted ON model_config(enabled, deleted); +CREATE INDEX IF NOT EXISTS idx_model_category ON model_config(model_category); +``` + +追加 `provider_health`(`uk_provider_id`,状态字段 `VARCHAR`,`fail_count/latency_ms INT`)。 + +- [ ] **Step 4: Agent / Chat 模块** + +`ai_agent`:`temperature/top_p/frequency_penalty/presence_penalty` 用 `NUMERIC(3,2)`,`system_prompt` 用 `TEXT`,`agent_config` 用 `JSONB`,唯一键 V5 最终态 `uk_name_workspace_deleted`,索引 `idx_default_provider_id`/`idx_enabled_deleted`/`idx_created_at`/`idx_workspace_id`。 + +`ai_chat_session`:索引 `idx_user_status_updated_id (user_id, status, updated_at, id)`、`idx_agent_updated_id`、`idx_created_at`、`idx_workspace_id`。 + +`ai_chat_message`:`content TEXT`、`metadata JSONB`、`token_count INT`,索引 `idx_session_id_id`、`idx_created_at`、`idx_session_id_id_role_time`、`idx_created_at_id`、`idx_session_workspace_id`。 + +- [ ] **Step 5: Knowledge 模块(含 V4 embedding_model_id + V5 唯一键)** + +`knowledge_base`:含 V4 的 `embedding_model_id BIGINT`(+`idx_embedding_model_id`),唯一键 `uk_name_workspace_deleted`。`document`:含 `workspace_id`(注:原 V1 末尾注释提示早期需补列,PG 最终态直接含此列)+ 索引。`agent_knowledge`:`uk_agent_knowledge (agent_id, knowledge_id)` + `idx_agent_id`/`idx_knowledge_id`。 + +- [ ] **Step 6: MCP 模块(含 V6/V7 最终态)** + +`mcp_server`:含 V7 的 `description VARCHAR(500)`,唯一键 V5 `uk_name_workspace_deleted`,`idx_workspace_id`。`mcp_tool`:`description TEXT`、`input_schema JSONB`,含 V6 的 `idx_workspace_id` + `idx_name_workspace`。`agent_mcp_tool`:唯一键 V6 最终态 `uk_agent_tool_workspace (agent_id, tool_id, workspace_id)` + `idx_agent_id`/`idx_tool_id`/`idx_workspace_id`。 + +- [ ] **Step 7: Workflow 模块 + document_chunk 向量表** + +`ai_workflow`(唯一键 V5 `uk_name_workspace_deleted`,`variables JSONB`)、`ai_workflow_node`(`position_x/y DOUBLE PRECISION`,`config JSONB`)、`ai_workflow_edge`、`ai_workflow_execution`(`variables JSONB`,`error_message TEXT`)。 + +向量表并入(源自 migration-pg V1+V2 最终态,变长 VECTOR): + +```sql +CREATE TABLE IF NOT EXISTS document_chunk ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + workspace_id BIGINT NOT NULL DEFAULT 0, + knowledge_id BIGINT NOT NULL, + document_id BIGINT NOT NULL, + chunk_index INT NOT NULL, + content TEXT NOT NULL, + embedding VECTOR, + chunk_hash CHAR(64) NOT NULL, + enabled SMALLINT NOT NULL DEFAULT 1, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_chunk_embedding ON document_chunk USING hnsw (embedding vector_cosine_ops); +CREATE INDEX IF NOT EXISTS idx_chunk_workspace ON document_chunk(workspace_id); +CREATE INDEX IF NOT EXISTS idx_chunk_knowledge ON document_chunk(knowledge_id); +CREATE INDEX IF NOT EXISTS idx_chunk_document ON document_chunk(document_id); +CREATE INDEX IF NOT EXISTS idx_chunk_hash ON document_chunk(chunk_hash); +``` + +- [ ] **Step 8: 种子数据 + IDENTITY 序列同步(spec §2.4)** + +固定 id 种子 + `ON CONFLICT` + `COALESCE` 兜底的序列同步(空表不报错): + +```sql +INSERT INTO ai_user (id, username, email, password, display_name, status) VALUES +(1, 'admin', 'admin@eify.local', '$2a$10$CfJnrJ65v1Oyt91xEG/tB.DzgQCLk6gX0reH9LpgHgB6boCapKH3C', 'Admin', 1) +ON CONFLICT (id) DO UPDATE SET password = EXCLUDED.password; + +INSERT INTO ai_workspace (id, name, description) VALUES +(1, 'System Workspace', 'System default workspace') +ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name; + +INSERT INTO ai_workspace_member (workspace_id, user_id, role) VALUES +(1, 1, 'owner') +ON CONFLICT (workspace_id, user_id) DO UPDATE SET role = EXCLUDED.role; + +SELECT setval(pg_get_serial_sequence('ai_user', 'id'), COALESCE((SELECT MAX(id) FROM ai_user), 1)); +SELECT setval(pg_get_serial_sequence('ai_workspace', 'id'), COALESCE((SELECT MAX(id) FROM ai_workspace), 1)); +``` + +- [ ] **Step 9: 校验 SQL 语法可解析(无法跑库时的静态检查)** + +Run: `git diff --stat eify-app/src/main/resources/db/migration/` +Expected: 仅 `V1__init.sql` 改动,V4~V7 与 migration-pg 已删除。完整库验证留到 Phase 7 起 PG17 容器。 + +- [ ] **Step 10: Commit** + +```bash +git add eify-app/src/main/resources/db/migration/ +git commit -m "feat(db): rewrite V1__init.sql as final-state PostgreSQL 17 schema + +Fold MySQL V1+V4~V7 into one PG DDL, merge document_chunk, drop migration-pg. +IDENTITY columns + seed sequence sync with COALESCE fallback." +``` + +### Task 2: 更新 DATABASE.md Flyway 幂等模板为 PG 版 + +**Files:** +- Modify: `docs/guides/DATABASE.md`(Flyway 幂等模板章节) + +- [ ] **Step 1: 定位 MySQL INFORMATION_SCHEMA 模板段落** + +Run: `grep -n "INFORMATION_SCHEMA" docs/guides/DATABASE.md` +Expected: 找到 ADD COLUMN / ADD INDEX / ADD UNIQUE KEY 三个 `SET @sql = IF(...)` 模板。 + +- [ ] **Step 2: 替换为 PG 原生幂等写法** + +将 MySQL 的 `INFORMATION_SCHEMA` + `PREPARE/EXECUTE` 模板替换为 PG 原生: + +```sql +-- ADD COLUMN(幂等) +ALTER TABLE my_table ADD COLUMN IF NOT EXISTS my_col SMALLINT NOT NULL DEFAULT 0; +-- ADD INDEX(幂等) +CREATE INDEX IF NOT EXISTS idx_my_col ON my_table(my_col); +-- ADD UNIQUE(幂等,约束无 IF NOT EXISTS,用 DO 块) +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'uk_my_key') THEN + ALTER TABLE my_table ADD CONSTRAINT uk_my_key UNIQUE (col_a, col_b); + END IF; +END $$; +``` + +- [ ] **Step 3: Commit** + +```bash +git add docs/guides/DATABASE.md +git commit -m "docs(db): switch Flyway idempotency template to PostgreSQL native syntax" +``` + +--- + +## Phase 2 — 数据访问层方言翻译 + +### Task 3: 翻译 `ProviderHealthMapper` upsert(ON DUPLICATE → ON CONFLICT) + +**Files:** +- Modify: `eify-provider/src/main/java/com/eify/provider/mapper/ProviderHealthMapper.java:14-24` +- Modify: `eify-provider/src/main/java/com/eify/provider/domain/entity/ProviderHealth.java:17`(注释方言字眼) + +- [ ] **Step 1: 翻译 SQL + 清理注释** + +将 `upsertHealth` 的 `@Insert` 改为 PG `ON CONFLICT`(依赖 `uk_provider_id`): + +```java + /** + * 使用 ON CONFLICT 原地更新健康状态。 + * provider_health 表以 provider_id 为唯一索引。 + */ + @Insert("INSERT INTO provider_health (provider_id, status, error_message, last_check_at, updated_at) " + + "VALUES (#{providerId}, #{status}, #{errorMessage}, #{lastCheckAt}, NOW()) " + + "ON CONFLICT (provider_id) DO UPDATE SET status = EXCLUDED.status, " + + "error_message = EXCLUDED.error_message, " + + "last_check_at = EXCLUDED.last_check_at, " + + "updated_at = NOW()") + int upsertHealth(ProviderHealth health); +``` + +同时把 `ProviderHealth.java:17` 的 Javadoc "更新时使用 ON DUPLICATE KEY UPDATE 实现原地更新" 改为 "更新时使用 ON CONFLICT 实现原地更新"。 + +- [ ] **Step 2: 编译** + +Run: `mvn compile -pl eify-provider -am -q` +Expected: BUILD SUCCESS + +- [ ] **Step 3: 单模块测试** + +Run: `mvn test -pl eify-provider -am -q` +Expected: PASS(H2 PG 模式现支持 `ON CONFLICT`;upsert 真值校验在 Phase 6 Testcontainers) + +- [ ] **Step 4: Commit** + +```bash +git add eify-provider/src/main/java/com/eify/provider/mapper/ProviderHealthMapper.java \ + eify-provider/src/main/java/com/eify/provider/domain/entity/ProviderHealth.java +git commit -m "feat(provider): translate health upsert to PostgreSQL ON CONFLICT" +``` + +### Task 4: 翻译 `AgentKnowledgeMapper` 批量 upsert + +**Files:** +- Modify: `eify-agent/src/main/java/com/eify/agent/mapper/AgentKnowledgeMapper.java:28-31` + +- [ ] **Step 1: 翻译批量 ON DUPLICATE → ON CONFLICT** + +依赖 `uk_agent_knowledge (agent_id, knowledge_id)`: + +```java + @Insert("") + int upsertKnowledgeIds(@Param("agentId") Long agentId, @Param("knowledgeIds") List knowledgeIds); +``` + +- [ ] **Step 2: 编译** + +Run: `mvn compile -pl eify-agent -am -q` +Expected: BUILD SUCCESS + +- [ ] **Step 3: 单模块测试** + +Run: `mvn test -pl eify-agent -am -q` +Expected: PASS + +- [ ] **Step 4: Commit** + +```bash +git add eify-agent/src/main/java/com/eify/agent/mapper/AgentKnowledgeMapper.java +git commit -m "feat(agent): translate agent_knowledge batch upsert to ON CONFLICT" +``` + +### Task 5: 翻译两处 JSONB 查询(JSON_EXTRACT → ->> + ::bigint) + +**Files:** +- Modify: `eify-provider/src/main/java/com/eify/provider/mapper/ProviderMapper.java:18-24` +- Modify: `eify-mcp/src/main/java/com/eify/mcp/mapper/McpServerMapper.java:13-19` + +- [ ] **Step 1: 翻译 `ProviderMapper.countWorkflowLlmReferences`** + +`->>` 返回 text,右侧绑定为 bigint,必须 `::bigint` 强转: + +```java + @Select("SELECT COUNT(*) FROM ai_workflow_node n " + + "INNER JOIN ai_workflow w ON n.workflow_id = w.id " + + "WHERE n.type = 'llm' " + + "AND (n.config->>'providerId')::bigint = #{providerId} " + + "AND n.deleted = 0 " + + "AND w.deleted = 0") + int countWorkflowLlmReferences(@Param("providerId") Long providerId); +``` + +- [ ] **Step 2: 翻译 `McpServerMapper.countWorkflowToolCallReferences`** + +```java + @Select("SELECT COUNT(*) FROM ai_workflow_node n " + + "INNER JOIN ai_workflow w ON n.workflow_id = w.id " + + "WHERE n.type = 'tool_call' " + + "AND (n.config->>'serverId')::bigint = #{serverId} " + + "AND n.deleted = 0 " + + "AND w.deleted = 0") + int countWorkflowToolCallReferences(@Param("serverId") Long serverId); +``` + +- [ ] **Step 3: 编译** + +Run: `mvn compile -pl eify-provider -am -q && mvn compile -pl eify-mcp -am -q` +Expected: BUILD SUCCESS + +- [ ] **Step 4: Commit** + +```bash +git add eify-provider/src/main/java/com/eify/provider/mapper/ProviderMapper.java \ + eify-mcp/src/main/java/com/eify/mcp/mapper/McpServerMapper.java +git commit -m "feat: translate JSON_EXTRACT to PostgreSQL ->> with ::bigint cast" +``` + +### Task 6: 清理 `ProviderServiceImpl` 吞异常 + 删除 `fixDatabase` 端点 + +**Files:** +- Modify: `eify-provider/src/main/java/com/eify/provider/service/impl/ProviderServiceImpl.java:286`(吞异常日志方言字眼) +- Modify: `eify-chat/src/main/java/com/eify/chat/controller/ChatController.java`(删除 `fixDatabase()` + `jdbcTemplate` 字段) +- Modify: `eify-chat/src/test/java/com/eify/chat/controller/ChatControllerTest.java`(清理 jdbcTemplate mock 注入) + +- [ ] **Step 1: 读 ProviderServiceImpl 上下文确认 try/catch 范围** + +Run: `sed -n '275,295p' eify-provider/src/main/java/com/eify/provider/service/impl/ProviderServiceImpl.java` +Expected: 看到包裹 `countWorkflowLlmReferences` 的 try/catch + 含 "JSON_EXTRACT" 的 `log.warn`。 + +- [ ] **Step 2: 改日志文案(PG 下 JSONB 查询正常,不再静默咽错)** + +把 `log.warn("跳过工作流 LLM 节点引用检查(可能是不支持 JSON_EXTRACT 的数据库): {}", e.getMessage())` 的文案改为不含方言字眼,例如 `log.warn("跳过工作流 LLM 节点引用检查(查询失败): {}", e.getMessage())`。(保留 try/catch 防御外部失败,但移除 grep 守卫会命中的 `JSON_EXTRACT` 字样。) + +- [ ] **Step 3: 删除 ChatController.fixDatabase + jdbcTemplate 依赖** + +删除 `ChatController.java` 的 `fixDatabase()` 方法(约 215-242 行)、`private final JdbcTemplate jdbcTemplate;` 字段、构造器中的 `jdbcTemplate` 形参与赋值、相关 import。 + +- [ ] **Step 4: 同步修 ChatControllerTest** + +`ChatControllerTest.java:45,51` 移除 `JdbcTemplate jdbcTemplate;` mock 与构造器实参,使其匹配新构造器签名。 + +- [ ] **Step 5: 编译 + 测试** + +Run: `mvn test -pl eify-chat -am -q && mvn compile -pl eify-provider -am -q` +Expected: PASS / BUILD SUCCESS + +- [ ] **Step 6: Commit** + +```bash +git add eify-chat/src/main/java/com/eify/chat/controller/ChatController.java \ + eify-chat/src/test/java/com/eify/chat/controller/ChatControllerTest.java \ + eify-provider/src/main/java/com/eify/provider/service/impl/ProviderServiceImpl.java +git commit -m "refactor: drop MySQL-only fixDatabase endpoint, clean dialect words in logs" +``` + +--- + +## Phase 3 — 数据源 / Flyway / 连接池收口 + +### Task 7: 去双数据源,向量 JdbcTemplate 指向主库 + +**Files:** +- Delete: `eify-knowledge/src/main/java/com/eify/knowledge/config/PgVectorConfig.java` +- Delete: `eify-app/src/main/java/com/eify/app/config/PgFlywayConfig.java` +- Modify: `eify-knowledge/src/main/java/com/eify/knowledge/repository/ChunkRepository.java:29-31` + +> `ChunkRepository` 的 SQL 已是 PG 原生(`::vector`、`<=>`、`ILIKE`),**不改 SQL**,只把注入的 `pgJdbcTemplate` 换成主数据源的 `JdbcTemplate`。 + +- [ ] **Step 1: 删除两个独立数据源/Flyway 配置类** + +```bash +git rm eify-knowledge/src/main/java/com/eify/knowledge/config/PgVectorConfig.java \ + eify-app/src/main/java/com/eify/app/config/PgFlywayConfig.java +``` + +- [ ] **Step 2: ChunkRepository 改用主 JdbcTemplate** + +把字段从 `@Lazy @Autowired private JdbcTemplate pgJdbcTemplate;` 改为构造器注入主数据源的 `JdbcTemplate`(`@RequiredArgsConstructor` 已在),并将方法体内 `pgJdbcTemplate` 全部改为 `jdbcTemplate`: + +```java +@Slf4j +@Repository +@RequiredArgsConstructor +public class ChunkRepository { + + private final JdbcTemplate jdbcTemplate; + private final VectorTypeHandler vectorTypeHandler; + // ... 方法体内 pgJdbcTemplate → jdbcTemplate(共 8 处调用) +``` + +> Spring Boot 单数据源下自动配置一个 `JdbcTemplate` bean,直接注入即可。`@Lazy` 不再需要(PG 现在是必备主库)。 + +- [ ] **Step 3: 全局搜残留引用** + +Run: `grep -rn "pgJdbcTemplate\|PgVectorConfig\|PgFlywayConfig\|pgvectorDataSource" eify-*/src/main/java/` +Expected: 无输出(全部清理)。 + +- [ ] **Step 4: 编译** + +Run: `mvn compile -pl eify-knowledge -am -q` +Expected: BUILD SUCCESS + +- [ ] **Step 5: Commit** + +```bash +git add eify-knowledge/ eify-app/ +git commit -m "refactor: single datasource — point vector JdbcTemplate at main PG DB" +``` + +### Task 8: Druid → HikariCP + 单 Flyway 指向 PG + +**Files:** +- Modify: `eify-app/pom.xml:73-93`(移除 mysql-connector-j、flyway-mysql、druid starter) +- Modify: `eify-app/src/main/java/com/eify/app/config/MySqlFlywayConfig.java`(重命名/改文案,仍指向主库) + +- [ ] **Step 1: 删除 pom 中 MySQL/Druid/flyway-mysql 依赖** + +移除 `eify-app/pom.xml` 中三个依赖块:`com.mysql:mysql-connector-j`、`org.flywaydb:flyway-mysql`、`com.alibaba:druid-spring-boot-4-starter`。保留 `flyway-database-postgresql`。新增 PG 驱动(确保 eify-app 直接依赖;eify-knowledge 已引 `org.postgresql:postgresql`,但主数据源在 app 层需保证可见): + +```xml + + + org.postgresql + postgresql + + + + com.pgvector + pgvector + +``` + +> 注:版本由父 pom `dependencyManagement` 统一管理(`pgvector.version=0.1.6` 已存在)。若 eify-app 通过 eify-knowledge 传递依赖已可见 postgresql,可省 postgresql 块——编译/启动时确认。 + +- [ ] **Step 2: 简化 Flyway 配置类(去掉双源耦合注释,仍走主数据源)** + +`MySqlFlywayConfig` 的 `@PostConstruct` 程序化迁移逻辑保留(指向主 `db/migration`、`flyway_schema_history`),但:去掉 Javadoc 中关于 PgVectorConfig/双源的描述。可选重命名类为 `FlywayConfig`(若重命名,同步改文件名与所有引用)。`@ConditionalOnProperty(eify.flyway.mysql.enabled)` 保留——test 用它关 Flyway(见 Phase 6)。 + +- [ ] **Step 3: 编译全量** + +Run: `mvn compile -q` +Expected: BUILD SUCCESS(无 MySQL 驱动符号缺失) + +- [ ] **Step 4: 搜 Druid 残留** + +Run: `grep -rn "druid\|Druid\|DruidDataSource" eify-*/src/main/java/` +Expected: 无 Java 引用(配置在 yml,Phase 4 处理)。 + +- [ ] **Step 5: Commit** + +```bash +git add eify-app/pom.xml eify-app/src/main/java/com/eify/app/config/ +git commit -m "build: drop MySQL/Druid/flyway-mysql deps, single Flyway on PG" +``` + +--- + +## Phase 4 — 四环境配置切换 + +### Task 9: 主 application.yml 切 PG + HikariCP + 合并向量数据源 + +**Files:** +- Modify: `eify-app/src/main/resources/application.yml:8-76` + +- [ ] **Step 1: 替换 datasource 块(PG 驱动 + HikariCP)** + +把 Druid datasource 块替换为 PG + HikariCP,删除整个 `datasource-pgvector` 块(合并入主源): + +```yaml + datasource: + driver-class-name: org.postgresql.Driver + url: ${PG_URL:jdbc:postgresql://localhost:5432/eify?stringtype=unspecified} + username: ${PG_USERNAME:postgres} + password: ${PG_PASSWORD:} + hikari: + maximum-pool-size: 20 + minimum-idle: 5 + connection-timeout: 60000 + idle-timeout: 300000 + keepalive-time: 60000 + connection-test-query: SELECT 1 +``` + +> 删除 `type: com.alibaba.druid...`、整段 `druid:` 子树、`datasource-pgvector:` 块。保留 `spring.sql.init.mode: never`、`autoconfigure.exclude`。 + +- [ ] **Step 2: 搜全局 datasource-pgvector 引用** + +Run: `grep -rn "datasource-pgvector\|spring.datasource-pgvector" eify-*/src/main/` +Expected: 无输出(含 eify-knowledge/application.yml 中的块也需删,见 Step 3)。 + +- [ ] **Step 3: 清理 eify-knowledge/application.yml 的 pgvector 块** + +删除 `eify-knowledge/src/main/resources/application.yml:14-18` 的 `datasource-pgvector` 块(该模块作为依赖运行,配置以 app 为准)。 + +- [ ] **Step 4: 编译** + +Run: `mvn compile -q` +Expected: BUILD SUCCESS + +- [ ] **Step 5: Commit** + +```bash +git add eify-app/src/main/resources/application.yml eify-knowledge/src/main/resources/application.yml +git commit -m "config: main datasource to PostgreSQL + HikariCP, merge vector source" +``` + +### Task 10: dev / prod / staging 环境的 Druid 配置改 HikariCP + +**Files:** +- Modify: `eify-app/src/main/resources/application-dev.yml:8-27` +- Modify: `eify-app/src/main/resources/application-prod.yml:16-38` +- Modify: `eify-app/src/main/resources/application-staging.yml`(同 prod 结构) + +- [ ] **Step 1: dev — 替换 druid 块为 hikari,删监控面板** + +`application-dev.yml` 的 `spring.datasource.druid:` 整段(含 `stat-view-servlet` 监控面板)替换为: + +```yaml +spring: + datasource: + hikari: + maximum-pool-size: 30 + minimum-idle: 5 + connection-timeout: 5000 + idle-timeout: 180000 + max-lifetime: 300000 +``` + +- [ ] **Step 2: prod — 替换 druid 块为 hikari** + +`application-prod.yml` 的 `spring.datasource.druid:` 整段替换为: + +```yaml +spring: + datasource: + hikari: + maximum-pool-size: 50 + minimum-idle: 10 + connection-timeout: 60000 + idle-timeout: 300000 + connection-test-query: SELECT 1 +``` + +- [ ] **Step 3: staging — 同 prod 处理** + +读 `application-staging.yml`,将其中的 `druid:` 块替换为等价 `hikari:` 配置(参数沿用 staging 原值)。慢查询监控由 PG `log_min_duration_statement` + `sql.logging.*` 切面承担(spec §4.2)。 + +- [ ] **Step 4: 搜残留 druid 配置** + +Run: `grep -rn "druid\|DRUID_PASSWORD\|stat-view-servlet" eify-app/src/main/resources/` +Expected: 无输出。 + +- [ ] **Step 5: Commit** + +```bash +git add eify-app/src/main/resources/application-*.yml +git commit -m "config: replace Druid pool with HikariCP across dev/prod/staging" +``` + +--- + +## Phase 5 — docker-compose 改造 + +### Task 11: 删 mysql 服务,pgvector 升 pg17 承载单库 + +**Files:** +- Modify: `deploy/infra/deploy/docker-compose.yml` + +- [ ] **Step 1: 删除 mysql 服务与卷,pgvector 升级** + +删除 `mysql:` 服务块(7-24 行)与 `volumes:` 下的 `mysql-data:`。将 `pgvector` 镜像 `pgvector/pgvector:pg16` → `pgvector/pgvector:pg17`,`POSTGRES_DB` 默认改为 `eify`(业务+向量单库)。 + +- [ ] **Step 2: backend 环境变量 MYSQL_* → PG_*** + +将 backend 服务的 `MYSQL_HOST/PORT/DATABASE/URL/USERNAME/PASSWORD` 替换为: + +```yaml + PG_URL: jdbc:postgresql://pgvector:5432/eify?stringtype=unspecified + PG_USERNAME: ${PGVECTOR_USERNAME:-postgres} + PG_PASSWORD: ${PGVECTOR_PASSWORD} +``` + +`depends_on` 删除 `mysql`,保留 `pgvector: condition: service_healthy`。删除 `redis` 之外无关项不动。 + +- [ ] **Step 3: 校验 compose 语法** + +Run: `docker compose -f deploy/infra/deploy/docker-compose.yml config -q` +Expected: 无错误输出(语法合法;需本机有 docker compose)。 + +- [ ] **Step 4: Commit** + +```bash +git add deploy/infra/deploy/docker-compose.yml +git commit -m "deploy: drop mysql service, pgvector pg17 hosts single DB" +``` + +--- + +## Phase 6 — 测试切 PG(H2 PG 模式 + Testcontainers PG17) + +### Task 12: H2 切 PostgreSQL 模式 + +**Files:** +- Modify: `eify-app/src/test/resources/application-test.yml:9-37` +- Modify: `eify-app/src/test/resources/schema-h2.sql` + +- [ ] **Step 1: application-test.yml 切 H2 PG 模式** + +把主 datasource 与 pgvector datasource 都改为 H2 PostgreSQL 模式,删 Druid type: + +```yaml + datasource: + driver-class-name: org.h2.Driver + url: jdbc:h2:mem:testdb;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + username: sa + password: + hikari: + maximum-pool-size: 5 + minimum-idle: 1 +``` + +删除 `datasource-pgvector` 块;`autoconfigure.exclude` 移除 `DruidDataSourceAutoConfigure`(已无 Druid);保留 Redis 排除与 `eify.flyway.mysql.enabled: false`(关 Flyway,用 schema-h2.sql 建表)。 + +- [ ] **Step 2: schema-h2.sql 适配 PG 模式** + +H2 PG 模式下 `AUTO_INCREMENT` 不识别,改用 `GENERATED BY DEFAULT AS IDENTITY`;`CLOB` → `TEXT`;`DOUBLE` → `DOUBLE PRECISION`。逐表替换(文件已无反引号、已去 MySQL 特性,主要是这三类): + +```sql +-- 范式(ai_user 等所有表的主键) +id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, +-- CLOB 列(auth_config / extra_params / content / metadata / *_schema / variables / config / system_prompt 等) +auth_config TEXT NOT NULL, +-- workflow_node 坐标 +position_x DOUBLE PRECISION, +``` + +> 向量表 `document_chunk` 在测试 schema 中**不建**(H2 无 VECTOR),向量检索由 Testcontainers 覆盖(Task 13);Service 单测用 Mockito 不碰库。 + +- [ ] **Step 3: 跑全量测试(H2 PG 模式)** + +Run: `mvn test -q` +Expected: PASS。重点关注走真实 H2 的 `@SpringBootTest` 集成测试(`ProviderControllerIntegrationTest`、`ChatControllerIntegrationTest`、`McpWorkspaceIsolationIntegrationTest`),它们的 `jdbcTemplate` 清理 SQL 与 schema-h2.sql 需在 PG 模式下解析通过。若有用例依赖 MySQL 模式行为,按报错定位修正。 + +- [ ] **Step 4: Commit** + +```bash +git add eify-app/src/test/resources/application-test.yml eify-app/src/test/resources/schema-h2.sql +git commit -m "test: switch H2 to PostgreSQL mode, IDENTITY/TEXT adaptations" +``` + +### Task 13: Testcontainers PG17 集成测试(JSONB / upsert / 向量) + +**Files:** +- Modify: `eify-app/pom.xml`(新增 testcontainers 依赖,test scope) +- Create: `eify-app/src/test/java/com/eify/app/integration/PgIntegrationTest.java` +- Create: `eify-app/src/test/resources/application-pgtest.yml` + +- [ ] **Step 1: 加 Testcontainers 依赖** + +`eify-app/pom.xml` 新增(test scope,版本走父 pom 或显式): + +```xml + + org.testcontainers + postgresql + test + + + org.springframework.boot + spring-boot-testcontainers + test + +``` + +- [ ] **Step 2: 写集成测试(@ServiceConnection 起 pg17)** + +覆盖 spec §5.2 三类盲区。Flyway 启用,跑真实 V1__init.sql: + +```java +package com.eify.app.integration; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import static org.assertj.core.api.Assertions.assertThat; + +@Testcontainers +@SpringBootTest +@ActiveProfiles("pgtest") +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +class PgIntegrationTest { + + @Container + @ServiceConnection + static PostgreSQLContainer pg = new PostgreSQLContainer<>("pgvector/pgvector:pg17"); + + @Autowired JdbcTemplate jdbcTemplate; + + @Test + void identitySequenceSurvivesSeedInsert() { + // 种子 id=1 已由 Flyway 写入;新插入不应撞主键 + Long id = jdbcTemplate.queryForObject( + "INSERT INTO ai_workspace (name, description) VALUES ('t','t') RETURNING id", Long.class); + assertThat(id).isGreaterThan(1L); + } + + @Test + void jsonbArrowQueryWithCast() { + jdbcTemplate.update("INSERT INTO ai_workflow (id, name, status) VALUES (900,'wf',1) ON CONFLICT (id) DO NOTHING"); + jdbcTemplate.update("INSERT INTO ai_workflow_node (workflow_id, node_key, type, config) " + + "VALUES (900,'n1','llm', '{\"providerId\": 42}'::jsonb)"); + Integer cnt = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM ai_workflow_node WHERE (config->>'providerId')::bigint = 42", Integer.class); + assertThat(cnt).isEqualTo(1); + } + + @Test + void onConflictUpsert() { + jdbcTemplate.update("INSERT INTO provider_health (provider_id, status) VALUES (5,'UP') " + + "ON CONFLICT (provider_id) DO UPDATE SET status = EXCLUDED.status"); + jdbcTemplate.update("INSERT INTO provider_health (provider_id, status) VALUES (5,'DOWN') " + + "ON CONFLICT (provider_id) DO UPDATE SET status = EXCLUDED.status"); + String s = jdbcTemplate.queryForObject( + "SELECT status FROM provider_health WHERE provider_id = 5", String.class); + assertThat(s).isEqualTo("DOWN"); + } + + @Test + void vectorCosineSearch() { + jdbcTemplate.update("INSERT INTO document_chunk (knowledge_id, document_id, chunk_index, content, embedding, chunk_hash) " + + "VALUES (1,1,0,'hello','[1,0,0]'::vector, 'h1')"); + Double sim = jdbcTemplate.queryForObject( + "SELECT 1 - (embedding <=> '[1,0,0]'::vector) FROM document_chunk WHERE chunk_hash='h1'", Double.class); + assertThat(sim).isGreaterThan(0.99); + } +} +``` + +- [ ] **Step 3: 写 application-pgtest.yml(启用 Flyway,profile 隔离)** + +```yaml +spring: + sql: + init: + mode: never +eify: + flyway: + mysql: + enabled: true # 复用主 Flyway 程序化迁移,对 Testcontainers PG 跑 V1__init.sql +``` + +> `@ServiceConnection` 自动把容器 jdbcUrl/账号注入 `spring.datasource.*`,无需手填。 + +- [ ] **Step 4: 跑集成测试** + +Run: `mvn test -pl eify-app -Dtest=PgIntegrationTest -q` +Expected: PASS(需本机 Docker)。4 个用例全绿即证明 IDENTITY 回填、JSONB `->>::bigint`、`ON CONFLICT`、向量检索在真实 PG17 工作。 + +- [ ] **Step 5: Commit** + +```bash +git add eify-app/pom.xml eify-app/src/test/ +git commit -m "test: add Testcontainers PG17 coverage for JSONB/upsert/vector" +``` + +--- + +## Phase 7 — 全量验证 + +### Task 14: 全量编译、测试、方言守卫、容器冒烟 + +**Files:** +- Create: `scripts/check_sql_dialect.sh` + +- [ ] **Step 1: 写 CI 方言守卫脚本(spec §7.2)** + +```bash +#!/bin/bash +if grep -rnE "ON DUPLICATE KEY|JSON_EXTRACT|INFORMATION_SCHEMA|MODIFY COLUMN" eify-*/src/main/java/; then + echo "🚨 发现 MySQL 方言残留(破窗效应)!请使用 PostgreSQL 原生语法。" + exit 1 +else + echo "✅ SQL 方言检查通过。" + exit 0 +fi +``` + +- [ ] **Step 2: 跑方言守卫,必须通过** + +Run: `bash scripts/check_sql_dialect.sh` +Expected: `✅ SQL 方言检查通过。` 且 exit 0。(Phase 2 已清理全部 4 处 + 注释/日志方言字眼) + +- [ ] **Step 3: 全量后端测试** + +Run: `mvn test -q` +Expected: 全模块 PASS。 + +- [ ] **Step 4: 前端验证(零改动,确认无连带破坏)** + +Run: `cd eify-web && npx vue-tsc --noEmit && npx vitest run && cd ..` +Expected: 类型检查 + 单测全绿。 + +- [ ] **Step 5: PG17 容器端到端冒烟** + +```bash +docker compose -f deploy/infra/deploy/docker-compose.yml up -d pgvector +# 等 healthy 后启动应用(dev profile,PG_URL 指向本地容器) +PG_URL=jdbc:postgresql://localhost:5432/eify?stringtype=unspecified \ +PG_USERNAME=postgres PG_PASSWORD= \ +mvn spring-boot:run -pl eify-app -Dspring-boot.run.profiles=dev +``` + +Expected: 日志出现 Flyway 迁移成功(`Successfully applied 1 migration`)、应用启动无报错;`/api` 健康检查可用。验证完 `docker compose ... down`。 + +- [ ] **Step 6: 最终 grep 复查无 MySQL 残留** + +Run: `grep -rniE "mysql|druid|jdbc:mysql" eify-app/src/main/resources/ eify-app/pom.xml` +Expected: 无业务残留(注释引用允许,但配置/依赖应为 0)。 + +- [ ] **Step 7: Commit** + +```bash +git add scripts/check_sql_dialect.sh +git commit -m "ci: add SQL dialect regression guard, finalize PG17 migration" +``` + +--- + +## 验收对照(spec §8) + +- [ ] PG17 单库承载 23 张业务表 + `document_chunk`,Flyway 启动成功(Task 1, 14-S5) +- [ ] `mvn test` 全绿 + Testcontainers 覆盖 JSONB/upsert/向量(Task 12, 13) +- [ ] 前端 `vitest`+`vue-tsc` 通过(Task 14-S4) +- [ ] 无 MySQL 残留(mysql-connector/Druid/ON DUPLICATE/JSON_EXTRACT/INFORMATION_SCHEMA)(Task 14-S2,S6) +- [ ] CI grep 守卫生效(Task 14-S1,S2) +- [ ] 主键 ID 回填、`updated_at` 自动刷新经集成测试确认(Task 13) +- [ ] 配置无硬编码 IP/密码,遵循 `${ENV_VAR:默认值}`(Task 9-11) +- [ ] DATABASE.md Flyway 幂等模板已更新为 PG 版(Task 2) From 25b1f958ee22ed7d8879e352f74dfc7f2a1f6fa9 Mon Sep 17 00:00:00 2001 From: mingming Date: Fri, 5 Jun 2026 17:56:28 +0800 Subject: [PATCH 04/28] feat(db): rewrite V1__init.sql as final-state PostgreSQL 17 schema Fold MySQL V1+V4~V7 into one PG DDL, merge document_chunk, drop migration-pg. IDENTITY columns + seed sequence sync with COALESCE fallback. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../main/resources/db/migration/V1__init.sql | 492 ++++++++++++++++++ .../migration/V6__mcp_workspace_isolation.sql | 52 -- .../migration/V7__mcp_server_description.sql | 16 - 3 files changed, 492 insertions(+), 68 deletions(-) create mode 100644 eify-app/src/main/resources/db/migration/V1__init.sql delete mode 100644 eify-app/src/main/resources/db/migration/V6__mcp_workspace_isolation.sql delete mode 100644 eify-app/src/main/resources/db/migration/V7__mcp_server_description.sql diff --git a/eify-app/src/main/resources/db/migration/V1__init.sql b/eify-app/src/main/resources/db/migration/V1__init.sql new file mode 100644 index 0000000..1f942a5 --- /dev/null +++ b/eify-app/src/main/resources/db/migration/V1__init.sql @@ -0,0 +1,492 @@ +-- ============================================================ +-- Eify 数据库初始化脚本(PostgreSQL 17,最终态) +-- 由 Flyway 自动执行 +-- 由 MySQL V1 + V4~V7 增量折叠而成,并合并 document_chunk 向量表 +-- ============================================================ +-- +-- 表清单(按模块分组): +-- 用户与工作空间:ai_user, ai_workspace, ai_workspace_member, ai_workspace_invite, ai_user_session +-- Provider: provider, model_config, provider_health +-- Agent: ai_agent +-- Chat: ai_chat_session, ai_chat_message +-- Knowledge: knowledge_base, document, agent_knowledge +-- MCP: mcp_server, mcp_tool, agent_mcp_tool +-- Workflow: ai_workflow, ai_workflow_node, ai_workflow_edge, ai_workflow_execution +-- 向量: document_chunk +-- +-- 规范来源:docs/guides/DATABASE.md, docs/guides/AUTH-WORKSPACE.md +-- ============================================================ + +CREATE EXTENSION IF NOT EXISTS vector; + +-- ############################################################ +-- 1. 用户与工作空间模块 +-- ############################################################ + +-- 用户表 +CREATE TABLE IF NOT EXISTS ai_user ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + username VARCHAR(64) NOT NULL, + email VARCHAR(128) NOT NULL DEFAULT '', + password VARCHAR(256) NOT NULL, + display_name VARCHAR(128), + avatar_url VARCHAR(512), + status SMALLINT NOT NULL DEFAULT 1, + last_login_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted SMALLINT NOT NULL DEFAULT 0, + creator_id BIGINT, + CONSTRAINT uk_username UNIQUE (username), + CONSTRAINT uk_email UNIQUE (email) +); + +-- 工作空间表 +CREATE TABLE IF NOT EXISTS ai_workspace ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + name VARCHAR(128) NOT NULL, + description VARCHAR(512), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted SMALLINT NOT NULL DEFAULT 0, + creator_id BIGINT NOT NULL DEFAULT 0 +); + +-- 工作空间成员表 +CREATE TABLE IF NOT EXISTS ai_workspace_member ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + workspace_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + role VARCHAR(32) NOT NULL DEFAULT 'member', + joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted SMALLINT NOT NULL DEFAULT 0, + creator_id BIGINT NOT NULL DEFAULT 0, + CONSTRAINT uk_workspace_user UNIQUE (workspace_id, user_id) +); +CREATE INDEX IF NOT EXISTS idx_ai_workspace_member_user_id ON ai_workspace_member(user_id); + +-- 工作空间邀请码表 +CREATE TABLE IF NOT EXISTS ai_workspace_invite ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + workspace_id BIGINT NOT NULL, + code VARCHAR(16) NOT NULL, + expires_at TIMESTAMPTZ, + max_uses INT NOT NULL DEFAULT 0, + use_count INT NOT NULL DEFAULT 0, + enabled SMALLINT NOT NULL DEFAULT 1, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted SMALLINT NOT NULL DEFAULT 0, + creator_id BIGINT NOT NULL DEFAULT 0, + CONSTRAINT uk_code UNIQUE (code) +); +CREATE INDEX IF NOT EXISTS idx_ai_workspace_invite_workspace_id ON ai_workspace_invite(workspace_id); + +-- 用户会话表(refresh token 持久化) +CREATE TABLE IF NOT EXISTS ai_user_session ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL, + refresh_token VARCHAR(256) NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_user_session_user_id ON ai_user_session(user_id); +CREATE INDEX IF NOT EXISTS idx_refresh_token ON ai_user_session(refresh_token); + +-- ############################################################ +-- 2. Provider 模块 +-- ############################################################ + +-- 模型供应商表 +CREATE TABLE IF NOT EXISTS provider ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + workspace_id BIGINT NOT NULL DEFAULT 1, + name VARCHAR(100) NOT NULL, + type VARCHAR(50) NOT NULL, + base_url VARCHAR(500) NOT NULL, + auth_config JSONB NOT NULL, + enabled SMALLINT NOT NULL DEFAULT 1, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted SMALLINT NOT NULL DEFAULT 0, + creator_id BIGINT NOT NULL DEFAULT 0, + CONSTRAINT uk_provider_name_workspace_deleted UNIQUE (name, workspace_id, deleted) +); +CREATE INDEX IF NOT EXISTS idx_provider_type_enabled ON provider(type, enabled); +CREATE INDEX IF NOT EXISTS idx_provider_deleted ON provider(deleted); + +-- 模型配置表 +CREATE TABLE IF NOT EXISTS model_config ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + provider_id BIGINT NOT NULL, + name VARCHAR(100) NOT NULL, + model_id VARCHAR(100) NOT NULL, + model_category SMALLINT NOT NULL DEFAULT 0, + context_size INT NOT NULL DEFAULT 0, + extra_params JSONB NOT NULL, + enabled SMALLINT NOT NULL DEFAULT 1, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted SMALLINT NOT NULL DEFAULT 0, + creator_id BIGINT NOT NULL DEFAULT 0, + workspace_id BIGINT NOT NULL DEFAULT 1 +); +CREATE INDEX IF NOT EXISTS idx_model_config_provider_id ON model_config(provider_id); +CREATE INDEX IF NOT EXISTS idx_model_config_model_id ON model_config(model_id); +CREATE INDEX IF NOT EXISTS idx_model_config_enabled_deleted ON model_config(enabled, deleted); +CREATE INDEX IF NOT EXISTS idx_model_category ON model_config(model_category); + +-- 供应商健康状态表 +CREATE TABLE IF NOT EXISTS provider_health ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + provider_id BIGINT NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'UNKNOWN', + last_check_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_success_at TIMESTAMPTZ, + fail_count INT NOT NULL DEFAULT 0, + latency_ms INT, + error_message VARCHAR(500), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted SMALLINT NOT NULL DEFAULT 0, + creator_id BIGINT NOT NULL DEFAULT 0, + CONSTRAINT uk_provider_id UNIQUE (provider_id) +); + +-- ############################################################ +-- 3. Agent 模块 +-- ############################################################ + +-- Agent 配置表 +CREATE TABLE IF NOT EXISTS ai_agent ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + workspace_id BIGINT NOT NULL DEFAULT 1, + name VARCHAR(100) NOT NULL, + description VARCHAR(500), + avatar VARCHAR(500), + default_provider_id BIGINT NOT NULL, + default_model VARCHAR(100) NOT NULL, + system_prompt TEXT NOT NULL, + user_message_prefix VARCHAR(1000), + welcome_message VARCHAR(500), + temperature NUMERIC(3,2) NOT NULL DEFAULT 0.70, + max_tokens INT NOT NULL DEFAULT 2000, + top_p NUMERIC(3,2) NOT NULL DEFAULT 1.00, + frequency_penalty NUMERIC(3,2) NOT NULL DEFAULT 0.00, + presence_penalty NUMERIC(3,2) NOT NULL DEFAULT 0.00, + max_history_rounds INT NOT NULL DEFAULT 10, + stream_enabled SMALLINT NOT NULL DEFAULT 1, + workflow_id BIGINT, + rag_enabled SMALLINT NOT NULL DEFAULT 0, + rag_top_k INT NOT NULL DEFAULT 5, + rag_strategy VARCHAR(20) NOT NULL DEFAULT 'hybrid', + agent_config JSONB, + enabled SMALLINT NOT NULL DEFAULT 1, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted SMALLINT NOT NULL DEFAULT 0, + creator_id BIGINT, + CONSTRAINT uk_agent_name_workspace_deleted UNIQUE (name, workspace_id, deleted) +); +CREATE INDEX IF NOT EXISTS idx_ai_agent_default_provider_id ON ai_agent(default_provider_id); +CREATE INDEX IF NOT EXISTS idx_ai_agent_enabled_deleted ON ai_agent(enabled, deleted); +CREATE INDEX IF NOT EXISTS idx_ai_agent_created_at ON ai_agent(created_at); +CREATE INDEX IF NOT EXISTS idx_ai_agent_workspace_id ON ai_agent(workspace_id); + +-- ############################################################ +-- 4. Chat 模块 +-- ############################################################ + +-- 对话会话表 +CREATE TABLE IF NOT EXISTS ai_chat_session ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + workspace_id BIGINT NOT NULL DEFAULT 1, + user_id BIGINT NOT NULL, + agent_id BIGINT, + title VARCHAR(200) NOT NULL, + status SMALLINT NOT NULL DEFAULT 1, + workflow_id BIGINT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted SMALLINT NOT NULL DEFAULT 0, + creator_id BIGINT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_ai_chat_session_user_status_updated_id ON ai_chat_session(user_id, status, updated_at, id); +CREATE INDEX IF NOT EXISTS idx_ai_chat_session_agent_updated_id ON ai_chat_session(agent_id, updated_at, id); +CREATE INDEX IF NOT EXISTS idx_ai_chat_session_created_at ON ai_chat_session(created_at); +CREATE INDEX IF NOT EXISTS idx_ai_chat_session_workspace_id ON ai_chat_session(workspace_id); + +-- 聊天消息表(大表,使用游标分页) +CREATE TABLE IF NOT EXISTS ai_chat_message ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + session_id BIGINT NOT NULL, + workspace_id BIGINT NOT NULL, + role VARCHAR(20) NOT NULL, + content TEXT NOT NULL, + token_count INT NOT NULL DEFAULT 0, + model_id BIGINT, + metadata JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted SMALLINT NOT NULL DEFAULT 0, + creator_id BIGINT NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_ai_chat_message_session_id_id ON ai_chat_message(session_id, id); +CREATE INDEX IF NOT EXISTS idx_ai_chat_message_created_at ON ai_chat_message(created_at); +CREATE INDEX IF NOT EXISTS idx_ai_chat_message_session_id_id_role_time ON ai_chat_message(session_id, id, role, created_at); +CREATE INDEX IF NOT EXISTS idx_ai_chat_message_created_at_id ON ai_chat_message(created_at, id); +CREATE INDEX IF NOT EXISTS idx_ai_chat_message_session_workspace_id ON ai_chat_message(session_id, workspace_id, id); + +-- ############################################################ +-- 5. Knowledge 模块 +-- ############################################################ + +-- 知识库表 +CREATE TABLE IF NOT EXISTS knowledge_base ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + workspace_id BIGINT NOT NULL DEFAULT 1, + name VARCHAR(100) NOT NULL, + description VARCHAR(500), + embedding_model VARCHAR(100) NOT NULL DEFAULT 'text-embedding-3-small', + embedding_model_id BIGINT, + vector_dimension INT NOT NULL DEFAULT 1536, + chunk_size INT NOT NULL DEFAULT 500, + chunk_overlap INT NOT NULL DEFAULT 50, + document_count INT NOT NULL DEFAULT 0, + chunk_count INT NOT NULL DEFAULT 0, + retrieval_count INT NOT NULL DEFAULT 0, + enabled SMALLINT NOT NULL DEFAULT 1, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted SMALLINT NOT NULL DEFAULT 0, + creator_id BIGINT NOT NULL DEFAULT 0, + CONSTRAINT uk_knowledge_base_name_workspace_deleted UNIQUE (name, workspace_id, deleted) +); +CREATE INDEX IF NOT EXISTS idx_knowledge_base_enabled ON knowledge_base(enabled); +CREATE INDEX IF NOT EXISTS idx_knowledge_base_deleted ON knowledge_base(deleted); +CREATE INDEX IF NOT EXISTS idx_knowledge_base_workspace_id ON knowledge_base(workspace_id); +CREATE INDEX IF NOT EXISTS idx_embedding_model_id ON knowledge_base(embedding_model_id); + +-- 文档表 +CREATE TABLE IF NOT EXISTS document ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + workspace_id BIGINT NOT NULL DEFAULT 0, + knowledge_id BIGINT NOT NULL, + file_name VARCHAR(255) NOT NULL, + original_name VARCHAR(255) NOT NULL, + file_type VARCHAR(20) NOT NULL, + file_size BIGINT NOT NULL, + file_path VARCHAR(500) NOT NULL, + char_count INT NOT NULL DEFAULT 0, + chunk_count INT NOT NULL DEFAULT 0, + process_status SMALLINT NOT NULL DEFAULT 0, + error_message VARCHAR(500), + enabled SMALLINT NOT NULL DEFAULT 1, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted SMALLINT NOT NULL DEFAULT 0, + creator_id BIGINT NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_document_workspace ON document(workspace_id); +CREATE INDEX IF NOT EXISTS idx_document_knowledge ON document(knowledge_id); +CREATE INDEX IF NOT EXISTS idx_document_status ON document(process_status); +CREATE INDEX IF NOT EXISTS idx_document_deleted ON document(deleted); + +-- Agent 与知识库关联表 +CREATE TABLE IF NOT EXISTS agent_knowledge ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + agent_id BIGINT NOT NULL, + knowledge_id BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted SMALLINT NOT NULL DEFAULT 0, + creator_id BIGINT, + CONSTRAINT uk_agent_knowledge UNIQUE (agent_id, knowledge_id) +); +CREATE INDEX IF NOT EXISTS idx_agent_knowledge_agent_id ON agent_knowledge(agent_id); +CREATE INDEX IF NOT EXISTS idx_agent_knowledge_knowledge_id ON agent_knowledge(knowledge_id); + +-- ############################################################ +-- 6. MCP 模块 +-- ############################################################ + +-- MCP 服务器表 +CREATE TABLE IF NOT EXISTS mcp_server ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + name VARCHAR(100) NOT NULL, + description VARCHAR(500), + endpoint VARCHAR(500) NOT NULL, + enabled SMALLINT NOT NULL DEFAULT 1, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted SMALLINT NOT NULL DEFAULT 0, + creator_id BIGINT NOT NULL DEFAULT 0, + workspace_id BIGINT NOT NULL DEFAULT 1, + CONSTRAINT uk_mcp_server_name_workspace_deleted UNIQUE (name, workspace_id, deleted) +); +CREATE INDEX IF NOT EXISTS idx_mcp_server_deleted ON mcp_server(deleted); +CREATE INDEX IF NOT EXISTS idx_mcp_server_workspace_id ON mcp_server(workspace_id); + +-- MCP 工具表 +CREATE TABLE IF NOT EXISTS mcp_tool ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + server_id BIGINT NOT NULL, + name VARCHAR(200) NOT NULL, + description TEXT, + input_schema JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted SMALLINT NOT NULL DEFAULT 0, + creator_id BIGINT NOT NULL DEFAULT 0, + workspace_id BIGINT NOT NULL DEFAULT 1 +); +CREATE INDEX IF NOT EXISTS idx_mcp_tool_server_id ON mcp_tool(server_id); +CREATE INDEX IF NOT EXISTS idx_mcp_tool_deleted ON mcp_tool(deleted); +CREATE INDEX IF NOT EXISTS idx_mcp_tool_workspace_id ON mcp_tool(workspace_id); +CREATE INDEX IF NOT EXISTS idx_mcp_tool_name_workspace ON mcp_tool(name, workspace_id); + +-- Agent 绑定的 MCP 工具表 +CREATE TABLE IF NOT EXISTS agent_mcp_tool ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + agent_id BIGINT NOT NULL, + tool_id BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted SMALLINT NOT NULL DEFAULT 0, + creator_id BIGINT NOT NULL DEFAULT 0, + workspace_id BIGINT NOT NULL DEFAULT 1, + CONSTRAINT uk_agent_tool_workspace UNIQUE (agent_id, tool_id, workspace_id) +); +CREATE INDEX IF NOT EXISTS idx_agent_mcp_tool_agent_id ON agent_mcp_tool(agent_id); +CREATE INDEX IF NOT EXISTS idx_agent_mcp_tool_tool_id ON agent_mcp_tool(tool_id); +CREATE INDEX IF NOT EXISTS idx_agent_mcp_tool_workspace_id ON agent_mcp_tool(workspace_id); + +-- ############################################################ +-- 7. Workflow 模块 +-- ############################################################ + +-- 工作流主表 +CREATE TABLE IF NOT EXISTS ai_workflow ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + workspace_id BIGINT NOT NULL DEFAULT 1, + name VARCHAR(100) NOT NULL, + description VARCHAR(500), + status SMALLINT NOT NULL DEFAULT 0, + version INT NOT NULL DEFAULT 1, + variables JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted SMALLINT NOT NULL DEFAULT 0, + creator_id BIGINT NOT NULL DEFAULT 0, + CONSTRAINT uk_ai_workflow_name_workspace_deleted UNIQUE (name, workspace_id, deleted) +); +CREATE INDEX IF NOT EXISTS idx_ai_workflow_workspace_id ON ai_workflow(workspace_id); +CREATE INDEX IF NOT EXISTS idx_ai_workflow_status ON ai_workflow(status); + +-- 工作流节点表 +CREATE TABLE IF NOT EXISTS ai_workflow_node ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + workflow_id BIGINT NOT NULL, + node_key VARCHAR(50) NOT NULL, + type VARCHAR(30) NOT NULL, + label VARCHAR(100), + position_x DOUBLE PRECISION, + position_y DOUBLE PRECISION, + config JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted SMALLINT NOT NULL DEFAULT 0, + creator_id BIGINT NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_ai_workflow_node_workflow ON ai_workflow_node(workflow_id); + +-- 工作流连线表 +CREATE TABLE IF NOT EXISTS ai_workflow_edge ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + workflow_id BIGINT NOT NULL, + source_node_id BIGINT NOT NULL, + target_node_id BIGINT NOT NULL, + source_handle VARCHAR(50) NOT NULL DEFAULT 'default', + label VARCHAR(50), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted SMALLINT NOT NULL DEFAULT 0, + creator_id BIGINT NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_ai_workflow_edge_workflow ON ai_workflow_edge(workflow_id); +CREATE INDEX IF NOT EXISTS idx_ai_workflow_edge_source ON ai_workflow_edge(source_node_id); + +-- 工作流执行记录表 +CREATE TABLE IF NOT EXISTS ai_workflow_execution ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + workflow_id BIGINT NOT NULL, + workflow_version INT, + status VARCHAR(20) NOT NULL DEFAULT 'running', + variables JSONB, + current_node_id BIGINT, + error_message TEXT, + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted SMALLINT NOT NULL DEFAULT 0, + creator_id BIGINT NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_ai_workflow_execution_workflow ON ai_workflow_execution(workflow_id); +CREATE INDEX IF NOT EXISTS idx_ai_workflow_execution_status ON ai_workflow_execution(status); + +-- ############################################################ +-- 8. 向量模块(pgvector) +-- ############################################################ + +-- 文档分块向量表(embedding 为变长向量,维度由对应知识库决定) +CREATE TABLE IF NOT EXISTS document_chunk ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + workspace_id BIGINT NOT NULL DEFAULT 0, + knowledge_id BIGINT NOT NULL, + document_id BIGINT NOT NULL, + chunk_index INT NOT NULL, + content TEXT NOT NULL, + embedding VECTOR, + chunk_hash CHAR(64) NOT NULL, + enabled SMALLINT NOT NULL DEFAULT 1, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_chunk_embedding ON document_chunk USING hnsw (embedding vector_cosine_ops); +CREATE INDEX IF NOT EXISTS idx_chunk_workspace ON document_chunk(workspace_id); +CREATE INDEX IF NOT EXISTS idx_chunk_knowledge ON document_chunk(knowledge_id); +CREATE INDEX IF NOT EXISTS idx_chunk_document ON document_chunk(document_id); +CREATE INDEX IF NOT EXISTS idx_chunk_hash ON document_chunk(chunk_hash); + +-- ############################################################ +-- 9. 初始数据 +-- ############################################################ + +-- 管理员用户(密码:admin123,BCrypt 加密) +INSERT INTO ai_user (id, username, email, password, display_name, status) VALUES +(1, 'admin', 'admin@eify.local', '$2a$10$CfJnrJ65v1Oyt91xEG/tB.DzgQCLk6gX0reH9LpgHgB6boCapKH3C', 'Admin', 1) +ON CONFLICT (id) DO UPDATE SET password = EXCLUDED.password; + +-- 默认工作空间 +INSERT INTO ai_workspace (id, name, description) VALUES +(1, 'System Workspace', 'System default workspace') +ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name; + +-- 管理员加入默认工作空间 +INSERT INTO ai_workspace_member (workspace_id, user_id, role) VALUES +(1, 1, 'owner') +ON CONFLICT (workspace_id, user_id) DO UPDATE SET role = EXCLUDED.role; + +-- 同步 IDENTITY 序列(显式插入 id 后序列不会自动前进,用 setval 同步,COALESCE 兜底空表) +SELECT setval(pg_get_serial_sequence('ai_user', 'id'), COALESCE((SELECT MAX(id) FROM ai_user), 1)); +SELECT setval(pg_get_serial_sequence('ai_workspace', 'id'), COALESCE((SELECT MAX(id) FROM ai_workspace), 1)); + + + + + + + + diff --git a/eify-app/src/main/resources/db/migration/V6__mcp_workspace_isolation.sql b/eify-app/src/main/resources/db/migration/V6__mcp_workspace_isolation.sql deleted file mode 100644 index 49656fb..0000000 --- a/eify-app/src/main/resources/db/migration/V6__mcp_workspace_isolation.sql +++ /dev/null @@ -1,52 +0,0 @@ --- ============================================================ --- V6: MCP 工作空间隔离 — 添加缺失的 workspace_id 索引和唯一约束 --- mcp_tool: 加 idx_workspace_id、idx_name_workspace --- agent_mcp_tool: 加 idx_workspace_id、重建 uk_agent_tool 含 workspace_id --- ============================================================ - --- mcp_tool: idx_workspace_id -SET @sql = IF( - (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS - WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mcp_tool' AND INDEX_NAME = 'idx_workspace_id') = 0, - 'ALTER TABLE `mcp_tool` ADD INDEX `idx_workspace_id` (`workspace_id`)', - 'SELECT ''Index idx_workspace_id already exists on mcp_tool, skipping'' AS info' -); -PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; - --- mcp_tool: idx_name_workspace(findServerIdForTool 按 name + workspace_id 查询用) -SET @sql = IF( - (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS - WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mcp_tool' AND INDEX_NAME = 'idx_name_workspace') = 0, - 'ALTER TABLE `mcp_tool` ADD INDEX `idx_name_workspace` (`name`, `workspace_id`)', - 'SELECT ''Index idx_name_workspace already exists on mcp_tool, skipping'' AS info' -); -PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; - --- agent_mcp_tool: idx_workspace_id -SET @sql = IF( - (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS - WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'agent_mcp_tool' AND INDEX_NAME = 'idx_workspace_id') = 0, - 'ALTER TABLE `agent_mcp_tool` ADD INDEX `idx_workspace_id` (`workspace_id`)', - 'SELECT ''Index idx_workspace_id already exists on agent_mcp_tool, skipping'' AS info' -); -PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; - --- agent_mcp_tool: 重建唯一约束,从 (agent_id, tool_id) 扩到 (agent_id, tool_id, workspace_id) --- 使用新名称 uk_agent_tool_workspace 确保二阶段幂等(与 V5 模式一致) --- 安全:旧约束是 (agent_id, tool_id),新约束是超集。agent_id 全局唯一, --- (agent_id, tool_id) 不会跨 workspace 重复,因此 ADD UNIQUE KEY 不会冲突 -SET @sql = IF( - (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS - WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'agent_mcp_tool' AND INDEX_NAME = 'uk_agent_tool') > 0, - 'ALTER TABLE `agent_mcp_tool` DROP INDEX `uk_agent_tool`', - 'SELECT ''Index uk_agent_tool does not exist on agent_mcp_tool, skipping'' AS info' -); -PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; - -SET @sql = IF( - (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS - WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'agent_mcp_tool' AND INDEX_NAME = 'uk_agent_tool_workspace') = 0, - 'ALTER TABLE `agent_mcp_tool` ADD UNIQUE KEY `uk_agent_tool_workspace` (`agent_id`, `tool_id`, `workspace_id`)', - 'SELECT ''Unique key uk_agent_tool_workspace already exists on agent_mcp_tool, skipping'' AS info' -); -PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/eify-app/src/main/resources/db/migration/V7__mcp_server_description.sql b/eify-app/src/main/resources/db/migration/V7__mcp_server_description.sql deleted file mode 100644 index 0044dd9..0000000 --- a/eify-app/src/main/resources/db/migration/V7__mcp_server_description.sql +++ /dev/null @@ -1,16 +0,0 @@ --- V7: mcp_server 表添加 description 字段 --- 为 MCP Server 增加描述信息,用于 Agent 编辑页展示 Server 简介 - -SELECT COUNT(*) INTO @col_exists -FROM INFORMATION_SCHEMA.COLUMNS -WHERE TABLE_SCHEMA = DATABASE() - AND TABLE_NAME = 'mcp_server' - AND COLUMN_NAME = 'description'; - -SET @sql = IF(@col_exists = 0, - 'ALTER TABLE `mcp_server` ADD COLUMN `description` VARCHAR(500) NULL COMMENT ''服务器描述'' AFTER `name`', - 'SELECT ''Column description already exists'' AS msg'); - -PREPARE stmt FROM @sql; -EXECUTE stmt; -DEALLOCATE PREPARE stmt; From b621e9d987765425341b24d4b2c2d564da520adb Mon Sep 17 00:00:00 2001 From: mingming Date: Fri, 5 Jun 2026 18:19:20 +0800 Subject: [PATCH 05/28] docs(db): convert DATABASE.md conventions to PostgreSQL 17 Field types, general columns, table template, index syntax, idempotency template to PG native; fix references to deleted V4/V5 migrations. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/guides/DATABASE.md | 266 +++++++++++++++++++++++----------------- 1 file changed, 151 insertions(+), 115 deletions(-) diff --git a/docs/guides/DATABASE.md b/docs/guides/DATABASE.md index 615e7f5..dd0f564 100644 --- a/docs/guides/DATABASE.md +++ b/docs/guides/DATABASE.md @@ -2,7 +2,7 @@ ## 目录 -- [MySQL 核心设计](#mysql-核心设计) +- [PostgreSQL 核心设计](#postgresql-核心设计) - [核心数据表](#核心数据表) - [缓存策略](#缓存策略) - [表设计规范](#表设计规范) @@ -22,11 +22,11 @@ --- -# MySQL 核心设计 +# PostgreSQL 核心设计 ## 核心数据表 -> 完整 DDL 见 Flyway 迁移文件(`V1__init.sql`、`V4__model_category_and_embedding_model_id.sql`、`V5__name_workspace_deleted_unique.sql`),由 Flyway 自动执行 +> 完整 DDL 见 Flyway 迁移文件 `V1__init.sql`(PostgreSQL 17,已折叠所有历史增量变更),由 Flyway 自动执行 | 表名 | 用途 | | :--- | :--- | @@ -43,7 +43,7 @@ | `ai_chat_message` | 聊天消息表(大表,游标分页) | | `knowledge_base` | 知识库表 | | `document` | 文档表 | -| `document_chunk` | 文档分块 + Embedding 向量表(**PostgreSQL pgvector**,非 MySQL) | +| `document_chunk` | 文档分块 + Embedding 向量表(**pgvector**,与业务表同库) | | `agent_knowledge` | Agent 与知识库关联表 | | `mcp_server` | MCP 服务器表 | | `mcp_tool` | MCP 工具表 | @@ -53,7 +53,7 @@ | `ai_workflow_edge` | 工作流连线表 | | `ai_workflow_execution` | 工作流执行记录表 | -> **双存储架构**:`document_chunk` 表使用 PostgreSQL pgvector 存储向量数据(HNSW 索引),其他表使用 MySQL。详细设计见 [双存储架构设计决策](ADRs/ADR-0004-dual-storage-mysql-pgvector.md)。 +> **单库架构**:业务表与向量表 `document_chunk`(含 HNSW 向量索引,pgvector 扩展)现已合并到同一个 PostgreSQL 17 库中,统一事务、备份与运维。历史上曾采用 MySQL + pgvector 双存储(见 [ADR-0004 双存储架构设计决策](ADRs/ADR-0004-dual-storage-mysql-pgvector.md)),现状已演进为单库 PG17。 ## 缓存策略 @@ -70,16 +70,17 @@ - 使用小写字母,单词间用下划线分隔 - 格式:`{业务模块}_{实体}` - 示例:`ai_agent`、`ai_conversation`、`sys_user` -- 禁止使用 MySQL 保留字作为表名 +- 禁止使用 PostgreSQL 保留字作为表名 -### 存储引擎 -- 所有业务表必须使用 InnoDB -- 原因:支持事务、外键、行级锁 +### 存储与事务 +- PostgreSQL 无需像 MySQL 那样声明 `ENGINE=InnoDB` +- 事务(MVCC 多版本并发控制)、行级锁、外键均为内建能力,所有表默认支持 +- 原因:PG 单一存储引擎即提供 ACID 事务、行级锁和外键约束 ### 字符集 -- 字符集:`utf8mb4` -- 排序规则:`utf8mb4_unicode_ci` -- 原因:支持 Emoji、多语言 +- 数据库编码:UTF-8(`CREATE DATABASE eify ENCODING 'UTF8'`) +- 排序规则:通过 `LC_COLLATE` / `LC_CTYPE` 指定(如 `'en_US.UTF-8'`) +- 原因:PG 的 UTF-8 原生支持 Emoji 和多语言,无需 MySQL 的 `utf8mb4` 特殊处理 ## 字段设计规范 @@ -87,13 +88,15 @@ | 类型 | 使用场景 | 示例 | |:---|:---|:---| -| `BIGINT UNSIGNED` | 主键、外键 | `id` | +| `BIGINT`(`GENERATED BY DEFAULT AS IDENTITY` 自增) | 主键、外键 | `id` | | `VARCHAR(N)` | 短字符串 | `name` VARCHAR(100) | | `TEXT` | 长文本 | `content` | -| `DECIMAL(M,D)` | 金额、高精度数值 | `price` DECIMAL(10,2) | -| `DATETIME` | 业务时间 | `created_at` | -| `TINYINT` | 状态、枚举 | `status` TINYINT | -| `JSON` | 配置、元数据 | `config` JSON | +| `NUMERIC(M,D)` | 金额、高精度数值 | `price` NUMERIC(10,2) | +| `TIMESTAMPTZ` | 业务时间 | `created_at` | +| `SMALLINT` | 状态、枚举 | `status` SMALLINT | +| `JSONB` | 配置、元数据 | `config` JSONB | + +> **MySQL → PG 类型映射要点**:PG 无 `UNSIGNED`,主键自增用 `IDENTITY`(`GENERATED BY DEFAULT AS IDENTITY`);无 `TINYINT`,状态/枚举用 `SMALLINT`;`DATETIME` → `TIMESTAMPTZ`(带时区);`JSON` → `JSONB`(二进制存储,可建 GIN 索引);`DECIMAL` → `NUMERIC`。 ### NULL 值处理 - 所有字段必须指定 NULL 或 NOT NULL @@ -105,53 +108,57 @@ - 数值类型默认值:0 - 字符串类型默认值:'' - 枚举类型默认值:明确指定(如 status = 1) -- 时间字段:`CURRENT_TIMESTAMP` 或 `NOW()` +- 时间字段:`NOW()`(PG 通用,等价于 `CURRENT_TIMESTAMP`) ### 字段注释 -- 所有字段必须添加 COMMENT +- PG 使用独立的 `COMMENT ON COLUMN 表名.字段名 IS '...'` 语句,而非 MySQL 的列内 `COMMENT` - 枚举值必须说明含义(如 0=禁用,1=启用) -- JSON 字段必须说明结构 +- JSONB 字段必须说明结构 ## 通用字段约定 ```sql --- 主键:统一使用 id,BIGINT 自增 -id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', +-- 主键:统一使用 id,BIGINT IDENTITY 自增 +id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, -- 创建时间:所有表必备 -created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', +created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), --- 更新时间:所有表必备,自动更新 -updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', +-- 更新时间:所有表必备(PG 无 ON UPDATE,由 MyBatis-Plus FieldMetaObjectHandler 自动填充) +updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 软删除:所有业务表必备(非日志表) -deleted TINYINT NOT NULL DEFAULT 0 COMMENT '删除标识:0=正常,1=删除', +deleted SMALLINT NOT NULL DEFAULT 0, -- 创建人:业务表必备 -creator_id BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', +creator_id BIGINT NOT NULL, ``` +> **注意**:PG 没有 MySQL 的 `ON UPDATE CURRENT_TIMESTAMP`,`updated_at` 不会在 UPDATE 时自动刷新。本项目由应用层填充——MyBatis-Plus 的 `@TableField(fill = FieldFill.INSERT_UPDATE)` + `FieldMetaObjectHandler` 在写入时统一设置时间戳。字段含义注释通过 `COMMENT ON COLUMN` 表达或在本文档说明。 + ## 索引设计原则 ```sql -- 索引命名规范 -- 普通索引:idx_字段名 -KEY `idx_user_id` (`user_id`) +CREATE INDEX IF NOT EXISTS idx_user_id ON my_table(user_id); --- 唯一索引:uk_字段名 -UNIQUE KEY `uk_name` (`name`) +-- 唯一约束:uk_字段名(PG 约束名 schema 级全局唯一,建议带表名前缀) +ALTER TABLE my_table ADD CONSTRAINT uk_my_table_name UNIQUE (name); -- 联合索引:idx_字段1_字段2 -KEY `idx_user_id_status` (`user_id`, `status`) +CREATE INDEX IF NOT EXISTS idx_user_id_status ON my_table(user_id, status); -- 索引设计原则 -- 1. where/order by/group by 的字段建索引 --- 2. 最左前缀原则 +-- 2. 最左前缀原则(PG B-tree 同样适用) -- 3. 区分度高的字段放左边 --- 4. 覆盖索引优化 +-- 4. 覆盖索引优化(可用 INCLUDE 列,如 CREATE INDEX ... (a) INCLUDE (b)) -- 5. 单表索引数 ≤ 5 ``` +> **PG 约束命名注意**:MySQL 的索引名是**表级**作用域,而 PG 的约束名(唯一约束、外键等)是 **schema 级全局唯一**。因此唯一约束命名必须带表名前缀避免冲突,例如 `uk_provider_name_workspace_deleted`、`uk_mcp_server_name_workspace_deleted`。普通索引(`CREATE INDEX`)的索引名也建议带表名前缀。 + ## 大表预判和应对策略 ``` @@ -204,7 +211,7 @@ LIMIT 20 OFFSET 0; SELECT * FROM ai_chat_message WHERE session_id = 123 ORDER BY created_at DESC -LIMIT 10000, 20; +LIMIT 20 OFFSET 10000; -- ✅ 好:游标分页(推荐) -- 第一页 @@ -248,7 +255,7 @@ LIMIT 21; ```sql -- 游标分页专用索引(覆盖查询 + 排序) -KEY `idx_session_id_id` (`session_id`, `id`) +CREATE INDEX IF NOT EXISTS idx_session_id_id ON ai_chat_message(session_id, id); ``` **索引设计原则**: @@ -262,7 +269,7 @@ KEY `idx_session_id_id` (`session_id`, `id`) | 技术 | 作用 | 核心 SQL | |:---|:---|:---| -| **覆盖索引** | 索引包含所有查询字段,避免回表 | `Extra: Using index` | +| **覆盖索引** | 索引包含所有查询字段,避免回表 | `EXPLAIN` → `Index Only Scan` | | **范围查询** | 利用索引有序性缩小扫描范围 | `WHERE created_at >= ... AND created_at < ...` | | **游标分页** | 用上一页最后 ID 定位,避免深分页 | `WHERE id < lastId ORDER BY id LIMIT N` | @@ -277,7 +284,7 @@ KEY `idx_session_id_id` (`session_id`, `id`) -- 查询:SELECT id, session_id, role, created_at -- WHERE session_id = 123 ORDER BY id DESC -- 索引:(session_id, id, role, created_at) --- 结果:Extra: Using where; Using index(无回表) +-- 结果:EXPLAIN 显示 Index Only Scan(无回表,PG 等价于 MySQL 的 Using index) ``` ### 范围查询规范 @@ -308,7 +315,7 @@ WHERE created_at >= '2024-01-01 00:00:00' - [ ] ORDER BY 字段在索引中 - [ ] 高选择性字段在索引左边 - [ ] 索引字段数量 <= 5 -- [ ] EXPLAIN 验证 Extra 包含 "Using index" +- [ ] EXPLAIN 验证执行计划为 "Index Only Scan"(覆盖索引生效) **查询优化**: - [ ] 大表使用游标分页(避免深分页) @@ -351,56 +358,88 @@ WHERE created_at >= '2024-01-01 00:00:00' ## 数据库配置优化 ```ini -[mysqld] -# 基础配置 -character-set-server=utf8mb4 -collation-server=utf8mb4_unicode_ci - -# InnoDB 配置 -innodb_buffer_pool_size=2G -innodb_log_file_size=512M -innodb_flush_log_at_trx_commit=2 - -# 连接配置 -max_connections=500 - -# 慢查询日志 -slow_query_log=1 -long_query_time=2 +# postgresql.conf 关键调优项 + +# 内存 +shared_buffers = 2GB # 数据/索引缓存,建议物理内存的 25% +effective_cache_size = 6GB # 优化器估算的可用缓存(含 OS page cache),建议 50-75% +work_mem = 16MB # 单次排序/哈希操作内存(注意并发会成倍放大) +maintenance_work_mem = 512MB # VACUUM / CREATE INDEX 等维护操作内存 + +# WAL / 持久化 +wal_compression = on +checkpoint_completion_target = 0.9 + +# 连接 +max_connections = 200 # 高并发建议前置 PgBouncer 连接池 + +# 慢查询日志(替代 MySQL 的 slow_query_log) +log_min_duration_statement = 2000 # 记录执行超过 2000ms 的语句(单位 ms) ``` +> 编码与排序规则在 `CREATE DATABASE ... ENCODING 'UTF8'` 时指定,不在 postgresql.conf 中配置(区别于 MySQL 的 `character-set-server`)。 + ## 建表标准模板 -所有业务表必须遵循以下模板结构: +所有业务表必须遵循以下模板结构(PostgreSQL 17): ```sql -CREATE TABLE `{table_prefix}_{entity}` ( +CREATE TABLE IF NOT EXISTS {table_prefix}_{entity} ( -- ============ 主键 ============ - `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, -- ============ 业务字段(按需添加) ============ - `workspace_id` BIGINT UNSIGNED NOT NULL COMMENT '工作空间 ID', - `name` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '名称', - `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0=禁用,1=启用', + workspace_id BIGINT NOT NULL DEFAULT 1, + name VARCHAR(100) NOT NULL DEFAULT '', + status SMALLINT NOT NULL DEFAULT 1, -- ============ 通用字段(所有表必备) ============ - `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '删除标识:0=正常,1=删除', - `creator_id` BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', - - -- ============ 索引 ============ - PRIMARY KEY (`id`), - UNIQUE KEY `uk_name_workspace_deleted` (`name`, `workspace_id`, `deleted`), - KEY `idx_workspace_id` (`workspace_id`), - KEY `idx_created_at` (`created_at`), - KEY `idx_status_deleted` (`status`, `deleted`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='表说明'; + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted SMALLINT NOT NULL DEFAULT 0, + creator_id BIGINT NOT NULL DEFAULT 0, + + -- ============ 唯一约束(约束名带表名前缀,全库唯一) ============ + CONSTRAINT uk_{entity}_name_workspace_deleted UNIQUE (name, workspace_id, deleted) +); + +-- ============ 索引(独立语句,索引名带表名前缀) ============ +CREATE INDEX IF NOT EXISTS idx_{entity}_workspace_id ON {table_prefix}_{entity}(workspace_id); +CREATE INDEX IF NOT EXISTS idx_{entity}_created_at ON {table_prefix}_{entity}(created_at); +CREATE INDEX IF NOT EXISTS idx_{entity}_status_deleted ON {table_prefix}_{entity}(status, deleted); + +-- ============ 字段注释(可选,PG 用独立语句) ============ +COMMENT ON TABLE {table_prefix}_{entity} IS '表说明'; +COMMENT ON COLUMN {table_prefix}_{entity}.status IS '状态:0=禁用,1=启用'; +COMMENT ON COLUMN {table_prefix}_{entity}.deleted IS '删除标识:0=正常,1=删除'; ``` +> 模板风格与 `eify-app/src/main/resources/db/migration/V1__init.sql`(最终态)保持一致:裸标识符(无反引号)、`BIGINT GENERATED BY DEFAULT AS IDENTITY`、`SMALLINT`、`TIMESTAMPTZ NOT NULL DEFAULT NOW()`、`JSONB`、独立 `CREATE INDEX IF NOT EXISTS`、`CONSTRAINT uk_xxx UNIQUE(...)`,不带 `ENGINE` / `CHARSET` / `COLLATE`。 + +### Flyway 幂等迁移(PostgreSQL) + +项目要求所有 Flyway DDL **幂等可重入**。PG 原生支持 `IF NOT EXISTS`,比 MySQL 时代的 `INFORMATION_SCHEMA` + `PREPARE/EXECUTE` 样板简洁得多: + +```sql +-- ADD COLUMN(幂等) +ALTER TABLE my_table ADD COLUMN IF NOT EXISTS my_col SMALLINT NOT NULL DEFAULT 0; + +-- CREATE INDEX(幂等) +CREATE INDEX IF NOT EXISTS idx_my_col ON my_table(my_col); + +-- ADD CONSTRAINT(约束无 IF NOT EXISTS,用 DO 块判断 pg_constraint) +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'uk_my_key') THEN + ALTER TABLE my_table ADD CONSTRAINT uk_my_key UNIQUE (col_a, col_b); + END IF; +END $$; +``` + +> 要点:`CREATE TABLE`、`ADD COLUMN`、`CREATE INDEX`、`DROP ...` 均支持 `IF [NOT] EXISTS`,直接使用即可,无需 MySQL 的 `INFORMATION_SCHEMA` 检查 + `PREPARE/EXECUTE` 动态 SQL。唯一例外是 `ADD CONSTRAINT` 没有 `IF NOT EXISTS`,用 `DO $$ ... $$` 匿名块查询 `pg_constraint.conname` 判断后再添加。 + ## Eify 业务表 DDL -> **完整 DDL**:Flyway 迁移文件(`V1__init.sql`、`V4__model_category_and_embedding_model_id.sql`、`V5__name_workspace_deleted_unique.sql`)— 包含所有表的 CREATE TABLE 和增量变更。 +> **完整 DDL**:Flyway 迁移文件 `V1__init.sql`(PostgreSQL 17,已折叠所有历史增量变更)— 包含所有表的 CREATE TABLE、索引、约束和初始数据。 > > 以下为各表结构说明,实际执行请使用 Flyway 自动迁移。 @@ -471,9 +510,9 @@ ORDER BY id DESC LIMIT 21; ### 执行方式 ```bash -# 初始化数据库(包含所有表 + 开发环境初始数据) -# 数据库由 Flyway 自动迁移,无需手动导入。备用方式: -mysql -u root -p eify < deploy/sql/init_eify_mysql.sql +# 初始化数据库:由 Flyway 在应用启动时自动迁移(V1__init.sql),无需手动导入。 +# 备用方式(手动执行迁移脚本): +psql -U postgres -d eify -f eify-app/src/main/resources/db/migration/V1__init.sql ``` ## 索引维护策略 @@ -481,69 +520,66 @@ mysql -u root -p eify < deploy/sql/init_eify_mysql.sql ### 索引监控 ```sql --- 查看未使用的索引 +-- 查看未使用的索引(基于 PG 统计视图 pg_stat_user_indexes) SELECT - t.table_schema, - t.table_name, - s.index_name, - s.seq_in_index, - s.column_name, - s.cardinality -FROM information_schema.tables t -JOIN information_schema.statistics s ON t.table_name = s.table_name -WHERE t.table_schema = 'eify' - AND t.table_name NOT LIKE 'sys_%' - AND s.index_name != 'PRIMARY' - AND s.cardinality IS NULL -ORDER BY t.table_name, s.index_name, s.seq_in_index; + schemaname, + relname AS table_name, + indexrelname AS index_name, + idx_scan AS scans, + pg_size_pretty(pg_relation_size(indexrelid)) AS index_size +FROM pg_stat_user_indexes +WHERE idx_scan = 0 + AND indexrelname NOT LIKE '%_pkey' +ORDER BY pg_relation_size(indexrelid) DESC; -- 查看表大小和索引大小 SELECT - table_name, - ROUND(((data_length + index_length) / 1024 / 1024), 2) AS '总大小MB', - ROUND((data_length / 1024 / 1024), 2) AS '数据大小MB', - ROUND((index_length / 1024 / 1024), 2) AS '索引大小MB' -FROM information_schema.tables -WHERE table_schema = 'eify' -ORDER BY (data_length + index_length) DESC; + relname AS table_name, + pg_size_pretty(pg_total_relation_size(relid)) AS total_size, + pg_size_pretty(pg_relation_size(relid)) AS data_size, + pg_size_pretty(pg_total_relation_size(relid) - pg_relation_size(relid)) AS index_size +FROM pg_catalog.pg_statio_user_tables +ORDER BY pg_total_relation_size(relid) DESC; ``` ### 索引优化 ```sql --- 分析表(更新统计信息) -ANALYZE TABLE ai_message; +-- 分析表(更新统计信息,供查询优化器使用) +ANALYZE ai_chat_message; --- 优化表(回收空间) -OPTIMIZE TABLE ai_message; +-- 回收空间 + 更新统计(PG 用 VACUUM,FULL 会重写表并加锁,谨慎使用) +VACUUM (ANALYZE) ai_chat_message; --- 检查表(检查完整性) -CHECK TABLE ai_message; +-- 重建索引(消除膨胀,CONCURRENTLY 不阻塞读写) +REINDEX INDEX CONCURRENTLY idx_ai_chat_message_session_id_id; ``` ## 数据归档策略 ```sql -- 归档 6 个月前的消息到历史表 --- 1. 创建历史表(结构相同) -CREATE TABLE `ai_chat_message_history` LIKE `ai_chat_message`; - --- 2. 归档数据(分批执行,避免锁表) +-- 1. 创建历史表(结构相同,PG 用 INCLUDING ALL 复制索引/约束/默认值) +CREATE TABLE IF NOT EXISTS ai_chat_message_history + (LIKE ai_chat_message INCLUDING ALL); + +-- 2. 归档数据(分批执行,避免长事务,PG 用 CTE + LIMIT) +WITH moved AS ( + SELECT id FROM ai_chat_message + WHERE created_at < NOW() - INTERVAL '6 months' + LIMIT 10000 +) INSERT INTO ai_chat_message_history -SELECT * FROM ai_chat_message -WHERE created_at < DATE_SUB(NOW(), INTERVAL 6 MONTH) -LIMIT 10000; +SELECT * FROM ai_chat_message WHERE id IN (SELECT id FROM moved); -- 3. 删除已归档数据(分批执行) DELETE FROM ai_chat_message -WHERE id IN ( - SELECT id FROM ai_chat_message_history -) -LIMIT 10000; +WHERE id IN (SELECT id FROM ai_chat_message_history) + AND created_at < NOW() - INTERVAL '6 months'; -- 4. 验证归档完成 SELECT COUNT(*) FROM ai_chat_message -WHERE created_at < DATE_SUB(NOW(), INTERVAL 6 MONTH); +WHERE created_at < NOW() - INTERVAL '6 months'; ``` --- From 80580b5a4eac0f186bbcbe32b63ff9259b34facb Mon Sep 17 00:00:00 2001 From: mingming Date: Fri, 5 Jun 2026 18:27:33 +0800 Subject: [PATCH 06/28] feat(provider): translate health upsert to PostgreSQL ON CONFLICT Co-Authored-By: Claude Opus 4.8 (1M context) --- .../com/eify/provider/domain/entity/ProviderHealth.java | 2 +- .../com/eify/provider/mapper/ProviderHealthMapper.java | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/eify-provider/src/main/java/com/eify/provider/domain/entity/ProviderHealth.java b/eify-provider/src/main/java/com/eify/provider/domain/entity/ProviderHealth.java index 3b92150..99a58ed 100644 --- a/eify-provider/src/main/java/com/eify/provider/domain/entity/ProviderHealth.java +++ b/eify-provider/src/main/java/com/eify/provider/domain/entity/ProviderHealth.java @@ -14,7 +14,7 @@ * 对应数据库表:provider_health *

* 注意:此表使用 provider_id 唯一索引,每个供应商只有一条健康记录, - * 更新时使用 ON DUPLICATE KEY UPDATE 实现原地更新 + * 更新时使用 ON CONFLICT 实现原地更新 */ @Data @TableName("provider_health") diff --git a/eify-provider/src/main/java/com/eify/provider/mapper/ProviderHealthMapper.java b/eify-provider/src/main/java/com/eify/provider/mapper/ProviderHealthMapper.java index 2143370..f38cfd8 100644 --- a/eify-provider/src/main/java/com/eify/provider/mapper/ProviderHealthMapper.java +++ b/eify-provider/src/main/java/com/eify/provider/mapper/ProviderHealthMapper.java @@ -12,14 +12,14 @@ public interface ProviderHealthMapper extends BaseMapper { /** - * 使用 ON DUPLICATE KEY UPDATE 原地更新健康状态。 + * 使用 ON CONFLICT 原地更新健康状态。 * provider_health 表以 provider_id 为唯一索引。 */ @Insert("INSERT INTO provider_health (provider_id, status, error_message, last_check_at, updated_at) " + "VALUES (#{providerId}, #{status}, #{errorMessage}, #{lastCheckAt}, NOW()) " + - "ON DUPLICATE KEY UPDATE status = VALUES(status), " + - "error_message = VALUES(error_message), " + - "last_check_at = VALUES(last_check_at), " + + "ON CONFLICT (provider_id) DO UPDATE SET status = EXCLUDED.status, " + + "error_message = EXCLUDED.error_message, " + + "last_check_at = EXCLUDED.last_check_at, " + "updated_at = NOW()") int upsertHealth(ProviderHealth health); } From 75a4282350f6c80209a05e87027ee304bb21d669 Mon Sep 17 00:00:00 2001 From: mingming Date: Fri, 5 Jun 2026 18:35:40 +0800 Subject: [PATCH 07/28] feat(agent): translate agent_knowledge batch upsert to ON CONFLICT Co-Authored-By: Claude Opus 4.8 (1M context) --- .../main/java/com/eify/agent/mapper/AgentKnowledgeMapper.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eify-agent/src/main/java/com/eify/agent/mapper/AgentKnowledgeMapper.java b/eify-agent/src/main/java/com/eify/agent/mapper/AgentKnowledgeMapper.java index a7d5516..a373c71 100644 --- a/eify-agent/src/main/java/com/eify/agent/mapper/AgentKnowledgeMapper.java +++ b/eify-agent/src/main/java/com/eify/agent/mapper/AgentKnowledgeMapper.java @@ -27,7 +27,7 @@ public interface AgentKnowledgeMapper extends BaseMapper { @Insert("") + "ON CONFLICT (agent_id, knowledge_id) DO UPDATE SET deleted = 0, updated_at = NOW()") int upsertKnowledgeIds(@Param("agentId") Long agentId, @Param("knowledgeIds") List knowledgeIds); @Update("