From db40d04dc5d39fe090155aee007942fd2c2c8b37 Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Fri, 5 Jun 2026 01:02:23 +0800 Subject: [PATCH] =?UTF-8?q?refactor(db):=20=E7=BB=9F=E4=B8=80=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=BA=93=20schema=20=E2=80=94=20=E8=BD=AF=E5=88=A0?= =?UTF-8?q?=E9=99=A4=E3=80=81=E5=91=BD=E5=90=8D=E8=A7=84=E8=8C=83=E3=80=81?= =?UTF-8?q?=E7=BA=A6=E6=9D=9F=E6=A0=87=E5=87=86=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 全表新增 deleted_at 列,统一软删除替代硬删除+archived_at - models.model_id 重命名为 external_id,消除语义混淆 - conversations.model_id 改为可空(模型为建议而非绑定) - messages 新增 updated_at,移除 CASCADE 改为 DAO 层级联 - 移除 DB 层 UNIQUE 约束,改为应用层检查(配合软删除) - 新增 helpers.ts(baseColumns + 构造层防御)、ESLint 规则、契约测试 - 迁移 0004 补全 CHECK 约束(providers.type/materials.status/messages.role) - DAO 层全面重写:级联软删除、应用层唯一、provider 删除保护 - 路由/前端/测试全量适配 externalId 重命名及类型变更 --- docs/development/README.md | 3 + docs/development/backend.md | 19 +- drizzle/0004_db_schema_standardization.sql | 109 ++++ drizzle/meta/0004_snapshot.json | 530 ++++++++++++++++++ drizzle/meta/_journal.json | 7 + eslint.config.js | 19 + src/server/db/connection.ts | 37 +- src/server/db/conversations.ts | 74 ++- src/server/db/helpers.ts | 12 + src/server/db/materials.ts | 27 +- src/server/db/migrate.ts | 34 +- src/server/db/models.ts | 148 +++-- src/server/db/projects.ts | 138 +++-- src/server/db/providers.ts | 100 ++-- src/server/db/schema.ts | 48 +- src/server/routes/chat/send.ts | 16 +- src/server/routes/models/create.ts | 4 +- src/server/routes/models/test.ts | 8 +- src/shared/api.ts | 12 +- .../models/components/ModelFormModal.tsx | 16 +- src/web/shared/hooks/use-models.ts | 4 +- tests/server/db/models.test.ts | 41 +- tests/server/db/projects.test.ts | 10 +- tests/server/db/schema.test.ts | 154 +++++ tests/server/db/soft-delete.test.ts | 241 ++++++++ tests/server/routes/chat.test.ts | 12 +- tests/server/routes/models.test.ts | 20 +- tests/server/routes/providers.test.ts | 2 +- tests/web/components/ChatPage.test.tsx | 3 +- tests/web/components/ChatPanel.test.tsx | 2 +- tests/web/components/ResourceTable.test.tsx | 4 +- tests/web/features/inbox/InboxPage.test.tsx | 1 - tests/web/hooks/use-models.test.ts | 12 +- tests/web/hooks/use-projects.test.ts | 1 - tests/web/routes/models.test.tsx | 8 +- tests/web/routes/projects.test.tsx | 7 +- tests/web/routes/workbench.test.tsx | 5 +- 37 files changed, 1564 insertions(+), 324 deletions(-) create mode 100644 drizzle/0004_db_schema_standardization.sql create mode 100644 drizzle/meta/0004_snapshot.json create mode 100644 src/server/db/helpers.ts create mode 100644 tests/server/db/schema.test.ts create mode 100644 tests/server/db/soft-delete.test.ts diff --git a/docs/development/README.md b/docs/development/README.md index cf7cd99..78ca71c 100644 --- a/docs/development/README.md +++ b/docs/development/README.md @@ -217,6 +217,9 @@ features// - 输入输出类型来自 `src/shared/api.ts`。 - 列表查询使用 `paginateQuery()`,不重复实现分页。 - 列名 snake_case,TS 类型 camelCase,Drizzle schema 映射。 +- 软删除:所有业务表使用 `deleted_at` 列,通过 `notDeleted(table)` 或 `paginateQuery({ softDelete })` 过滤。`deleted_at` 不暴露到 API 层。 +- 唯一性:无数据库级 UNIQUE 约束,DAO 层应用校验(同字段 + `deleted_at IS NULL`)。 +- 表定义:通过 `helpers.ts` 的 `baseColumns` 展开 id/created_at/updated_at/deleted_at,禁止直接 `sqliteTable()`(ESLint 强制)。 ### AI 调用层 diff --git a/docs/development/backend.md b/docs/development/backend.md index 45baa06..12a28c3 100644 --- a/docs/development/backend.md +++ b/docs/development/backend.md @@ -20,9 +20,14 @@ SQLite + bun:sqlite + Drizzle ORM。 -- `src/server/db/schema.ts`:Drizzle 表结构,列名 snake_case,TS 类型 camelCase。 -- `src/server/db/connection.ts`:`createDatabase(dataDir, logger)` 打开 `alfred.db`,PRAGMA:foreign_keys=ON、journal_mode=WAL、busy_timeout=5000。`wrap(db)` 转为 Drizzle 实例。`paginateQuery()` 分页工具。 -- Migration:开发期 `drizzle-kit generate` 产出到 `drizzle/`;生产期嵌入可执行文件,启动时自动应用。备份到 `/backups/`,事务中执行,失败回滚。 +- `src/server/db/schema.ts`:Drizzle 表结构,列名 snake_case,TS 类型 camelCase。所有业务表通过 `helpers.ts` 的 `baseColumns` 获取 id/created_at/updated_at/deleted_at。 +- `src/server/db/helpers.ts`:`baseColumns` 常量(id、createdAt、updatedAt、deletedAt)+ Drizzle 构建器再导出。`src/server/db/` 内禁止直接从 `drizzle-orm/sqlite-core` 导入 `sqliteTable`(ESLint 强制)。 +- `src/server/db/connection.ts`:`createDatabase(dataDir, logger)` 打开 `alfred.db`,PRAGMA:foreign_keys=ON、journal_mode=WAL、busy_timeout=5000。`wrap(db)` 转 Drizzle 实例(`DrizzleDB` 类型)。工具函数:`timestamp()`、`notDeleted(table)`、`softDeleteRecord(db, table, id)`、`paginateQuery()`(支持 `softDelete` 参数自动过滤已删除行)。 +- Migration:开发期 `drizzle-kit generate` 产出到 `drizzle/`;生产期嵌入可执行文件,启动时自动应用。备份到 `/backups/`,事务中执行(迁移期间临时关闭外键检查),失败回滚。 + +### 软删除 + +所有业务表(projects、providers、models、conversations、materials、messages)使用 `deleted_at` 列实现软删除,不暴露给 API 层。DAO 查询通过 `notDeleted(table)` 或 `paginateQuery({ softDelete })` 自动过滤已删除行。唯一性校验在应用层完成(同名 + `deleted_at IS NULL`),无数据库级 UNIQUE 约束。级联软删除:删除项目 → 级联软删除会话(→ 消息)+ 素材;删除会话 → 级联软删除消息;删除供应商 → 需无未删除模型。 ### 数据访问函数 @@ -38,7 +43,7 @@ SQLite + bun:sqlite + Drizzle ORM。 ## AI 服务层 -- `src/server/ai/types.ts`:`AIProviderConfig`(name、type、baseUrl、apiKey)、`AIModelConfig`(providerId、modelId、capabilities)。 +- `src/server/ai/types.ts`:`AIProviderConfig`(name、type、baseUrl、apiKey)、`AIModelConfig`(providerId、modelId、capabilities)。注:AI 层 `modelId` 对应 DB 层 `Model.externalId`。 - `src/server/ai/registry.ts`: - `buildProviderRegistry(db)` — 从 DB 查询供应商构建 AI SDK Provider Registry,每次调用重建,不缓存。通过 `registry.languageModel('providerId:modelId')` 获取模型实例。 - `testProviderConnection(config, logger)` — 测试 Base URL 可达性 + `/models` 接口 @@ -57,7 +62,7 @@ SQLite + bun:sqlite + Drizzle ORM。 ### 连通性测试 - `POST /api/providers/test` — 用未保存配置测试,不写入 DB,不阻止保存。Base URL 不可达或 API Key 无效返回 `ok: false`;`/models` 不支持返回 `ok: true` + 提示。 -- `POST /api/models/test` — 用模型关联供应商 + modelId 测试。 +- `POST /api/models/test` — 用模型关联供应商 + externalId 测试。 ## 素材 API @@ -66,9 +71,9 @@ SQLite + bun:sqlite + Drizzle ORM。 | GET | `/api/projects/:id/materials` | 列出项目下素材(分页) | | POST | `/api/projects/:id/materials` | 创建素材 | | GET | `/api/projects/:id/materials/:mid` | 获取素材详情 | -| DELETE | `/api/projects/:id/materials/:mid` | 删除素材(硬删除) | +| DELETE | `/api/projects/:id/materials/:mid` | 删除素材(软删除) | -校验:description 必填非空,associatedDate 必填 YYYY-MM-DD,项目须存在且 active,素材归属校验不匹配返回 403。 +校验:description 必填非空,associatedDate 必填 YYYY-MM-DD,项目须存在且 active 且未删除,素材归属校验不匹配返回 403。 ## 聊天 API diff --git a/drizzle/0004_db_schema_standardization.sql b/drizzle/0004_db_schema_standardization.sql new file mode 100644 index 0000000..d7555dc --- /dev/null +++ b/drizzle/0004_db_schema_standardization.sql @@ -0,0 +1,109 @@ +-- DB schema standardization migration +-- 1. Rename columns +ALTER TABLE `projects` RENAME COLUMN `archived_at` TO `deleted_at`; +ALTER TABLE `models` RENAME COLUMN `model_id` TO `external_id`; + +-- 2. Add deleted_at to remaining tables +ALTER TABLE `providers` ADD COLUMN `deleted_at` text; +ALTER TABLE `models` ADD COLUMN `deleted_at` text; +ALTER TABLE `conversations` ADD COLUMN `deleted_at` text; +ALTER TABLE `materials` ADD COLUMN `deleted_at` text; +ALTER TABLE `messages` ADD COLUMN `deleted_at` text; + +-- 3. Add updated_at to messages +ALTER TABLE `messages` ADD COLUMN `updated_at` text NOT NULL DEFAULT ''; + +-- 4. Drop unique indexes (enforcement moves to app layer) +DROP INDEX IF EXISTS `projects_name_unique`; +DROP INDEX IF EXISTS `providers_name_unique`; +DROP INDEX IF EXISTS `models_provider_id_model_id_unique`; + +-- 5. Rebuild messages table (FK cascade → no action, add updated_at + deleted_at in-table, add CHECK on role) +CREATE TABLE `messages_new` ( + `id` text PRIMARY KEY NOT NULL, + `conversation_id` text NOT NULL, + `role` text NOT NULL CHECK (`role` IN ('assistant', 'system', 'user')), + `content` text NOT NULL DEFAULT '', + `parts` text, + `created_at` text NOT NULL, + `updated_at` text NOT NULL DEFAULT '', + `deleted_at` text, + FOREIGN KEY (`conversation_id`) REFERENCES `conversations`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +INSERT INTO `messages_new` (`id`, `conversation_id`, `role`, `content`, `parts`, `created_at`, `updated_at`, `deleted_at`) +SELECT `id`, `conversation_id`, `role`, `content`, `parts`, `created_at`, '', NULL FROM `messages`; +--> statement-breakpoint +DROP TABLE `messages`; +--> statement-breakpoint +ALTER TABLE `messages_new` RENAME TO `messages`; +--> statement-breakpoint +CREATE INDEX `messages_conversation_id_idx` ON `messages` (`conversation_id`); +--> statement-breakpoint + +-- 6. Rebuild conversations table (model_id nullable, add deleted_at in-table) +CREATE TABLE `conversations_new` ( + `id` text PRIMARY KEY NOT NULL, + `project_id` text NOT NULL, + `model_id` text, + `title` text NOT NULL DEFAULT '新会话', + `created_at` text NOT NULL, + `updated_at` text NOT NULL, + `deleted_at` text, + FOREIGN KEY (`model_id`) REFERENCES `models`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +INSERT INTO `conversations_new` (`id`, `project_id`, `model_id`, `title`, `created_at`, `updated_at`, `deleted_at`) +SELECT `id`, `project_id`, `model_id`, `title`, `created_at`, `updated_at`, NULL FROM `conversations`; +--> statement-breakpoint +DROP TABLE `conversations`; +--> statement-breakpoint +ALTER TABLE `conversations_new` RENAME TO `conversations`; +--> statement-breakpoint +CREATE INDEX `conversations_project_id_idx` ON `conversations` (`project_id`); +--> statement-breakpoint +CREATE INDEX `conversations_model_id_idx` ON `conversations` (`model_id`); +--> statement-breakpoint + +-- 7. Rebuild providers table (add deleted_at in-table, add CHECK on type) +CREATE TABLE `providers_new` ( + `id` text PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `type` text NOT NULL DEFAULT 'openai-compatible' CHECK (`type` IN ('anthropic', 'openai', 'openai-compatible')), + `api_key` text NOT NULL, + `base_url` text NOT NULL, + `created_at` text NOT NULL, + `updated_at` text NOT NULL, + `deleted_at` text +); +--> statement-breakpoint +INSERT INTO `providers_new` (`id`, `name`, `type`, `api_key`, `base_url`, `created_at`, `updated_at`, `deleted_at`) +SELECT `id`, `name`, `type`, `api_key`, `base_url`, `created_at`, `updated_at`, NULL FROM `providers`; +--> statement-breakpoint +DROP TABLE `providers`; +--> statement-breakpoint +ALTER TABLE `providers_new` RENAME TO `providers`; +--> statement-breakpoint + +-- 8. Rebuild materials table (add deleted_at in-table, add CHECK on status) +CREATE TABLE `materials_new` ( + `id` text PRIMARY KEY NOT NULL, + `project_id` text NOT NULL, + `associated_date` text NOT NULL, + `description` text NOT NULL, + `status` text NOT NULL DEFAULT 'pending' CHECK (`status` IN ('pending', 'approved', 'discarded')), + `created_at` text NOT NULL, + `updated_at` text NOT NULL, + `deleted_at` text, + FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +INSERT INTO `materials_new` (`id`, `project_id`, `associated_date`, `description`, `status`, `created_at`, `updated_at`, `deleted_at`) +SELECT `id`, `project_id`, `associated_date`, `description`, `status`, `created_at`, `updated_at`, NULL FROM `materials`; +--> statement-breakpoint +DROP TABLE `materials`; +--> statement-breakpoint +ALTER TABLE `materials_new` RENAME TO `materials`; +--> statement-breakpoint +CREATE INDEX `materials_project_id_idx` ON `materials` (`project_id`); diff --git a/drizzle/meta/0004_snapshot.json b/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000..f1b05c7 --- /dev/null +++ b/drizzle/meta/0004_snapshot.json @@ -0,0 +1,530 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "b0da1e89-0647-40e1-9739-6bcd14cf5a2e", + "prevId": "340f6d1a-081b-413d-a289-f39592ece0a2", + "tables": { + "conversations": { + "name": "conversations", + "columns": { + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'新会话'" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "conversations_project_id_idx": { + "name": "conversations_project_id_idx", + "columns": ["project_id"], + "isUnique": false + }, + "conversations_model_id_idx": { + "name": "conversations_model_id_idx", + "columns": ["model_id"], + "isUnique": false + } + }, + "foreignKeys": { + "conversations_model_id_models_id_fk": { + "name": "conversations_model_id_models_id_fk", + "tableFrom": "conversations", + "tableTo": "models", + "columnsFrom": ["model_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "conversations_project_id_projects_id_fk": { + "name": "conversations_project_id_projects_id_fk", + "tableFrom": "conversations", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "materials": { + "name": "materials", + "columns": { + "associated_date": { + "name": "associated_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "materials_project_id_idx": { + "name": "materials_project_id_idx", + "columns": ["project_id"], + "isUnique": false + } + }, + "foreignKeys": { + "materials_project_id_projects_id_fk": { + "name": "materials_project_id_projects_id_fk", + "tableFrom": "materials", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "messages": { + "name": "messages", + "columns": { + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "parts": { + "name": "parts", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + } + }, + "indexes": { + "messages_conversation_id_idx": { + "name": "messages_conversation_id_idx", + "columns": ["conversation_id"], + "isUnique": false + } + }, + "foreignKeys": { + "messages_conversation_id_conversations_id_fk": { + "name": "messages_conversation_id_conversations_id_fk", + "tableFrom": "messages", + "tableTo": "conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "models": { + "name": "models", + "columns": { + "capabilities": { + "name": "capabilities", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "context_length": { + "name": "context_length", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "max_output_tokens": { + "name": "max_output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "models_provider_id_idx": { + "name": "models_provider_id_idx", + "columns": ["provider_id"], + "isUnique": false + } + }, + "foreignKeys": { + "models_provider_id_providers_id_fk": { + "name": "models_provider_id_providers_id_fk", + "tableFrom": "models", + "tableTo": "providers", + "columnsFrom": ["provider_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "providers": { + "name": "providers", + "columns": { + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'openai-compatible'" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schema_migrations": { + "name": "schema_migrations", + "columns": { + "applied_at": { + "name": "applied_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 1eb0b5e..a4a7fe9 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1780463734721, "tag": "0003_lying_cassandra_nova", "breakpoints": true + }, + { + "idx": 4, + "version": "6", + "when": 1780587528226, + "tag": "0004_db_schema_standardization", + "breakpoints": true } ] } diff --git a/eslint.config.js b/eslint.config.js index bce5e73..57d64a4 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -95,6 +95,25 @@ export default tseslint.config( "import/no-named-as-default-member": "off", }, }, + { + files: ["src/server/db/**/*.ts"], + ignores: ["src/server/db/helpers.ts"], + rules: { + "no-restricted-imports": [ + "error", + { + paths: [ + { + importNames: ["sqliteTable"], + message: + "请从 ./helpers.ts 导入 sqliteTable,并在列定义中展开 baseColumns。参见 src/server/db/helpers.ts。", + name: "drizzle-orm/sqlite-core", + }, + ], + }, + ], + }, + }, { files: ["src/server/**/*.ts"], ignores: ["src/server/logger.ts"], diff --git a/src/server/db/connection.ts b/src/server/db/connection.ts index 7a5f642..21b8eb5 100644 --- a/src/server/db/connection.ts +++ b/src/server/db/connection.ts @@ -1,8 +1,8 @@ -import type { SQL } from "drizzle-orm"; +import type { Column, SQL } from "drizzle-orm"; import type { SQLiteTable } from "drizzle-orm/sqlite-core"; import Database from "bun:sqlite"; -import { and, sql } from "drizzle-orm"; +import { and, eq, isNull, sql } from "drizzle-orm"; import { drizzle } from "drizzle-orm/bun-sqlite"; import { join } from "node:path"; @@ -10,6 +10,8 @@ import type { Logger } from "../logger"; const DB_FILENAME = "alfred.db"; +export type DrizzleDB = ReturnType; + export interface PaginateResult { items: T[]; page: number; @@ -30,6 +32,10 @@ export function createDatabase(dataDir: string, logger: Logger): Database { return db; } +export function notDeleted(table: { deletedAt: Column }): SQL { + return isNull(table.deletedAt); +} + export function paginateQuery( raw: Database, table: T, @@ -39,11 +45,16 @@ export function paginateQuery( orderBy?: (table: T) => SQL | undefined; page: number; pageSize: number; + softDelete?: Column; }, ): PaginateResult { const db = wrap(raw); - const where = options.conditions?.filter((c): c is SQL => c !== undefined); - const whereClause = where && where.length > 0 ? and(...where) : undefined; + const conditions = [...(options.conditions ?? [])]; + if (options.softDelete) { + conditions.push(isNull(options.softDelete)); + } + const where = conditions.filter((c): c is SQL => c !== undefined); + const whereClause = where.length > 0 ? and(...where) : undefined; const countResult = db .select({ count: sql`count(*)` }) @@ -70,6 +81,24 @@ export function paginateQuery( }; } +export function softDeleteRecord( + db: DrizzleDB, + table: T, + id: string, +): T["$inferSelect"] | undefined { + const now = timestamp(); + return db + .update(table) + .set({ deletedAt: now, updatedAt: now } as Partial) + .where(eq((table as unknown as { id: Column }).id, id)) + .returning() + .get(); +} + +export function timestamp(): string { + return new Date().toISOString(); +} + export function wrap(raw: Database) { return drizzle(raw); } diff --git a/src/server/db/conversations.ts b/src/server/db/conversations.ts index c7ae1fd..7ca3618 100644 --- a/src/server/db/conversations.ts +++ b/src/server/db/conversations.ts @@ -1,33 +1,36 @@ import type Database from "bun:sqlite"; -import { desc, eq } from "drizzle-orm"; +import { and, desc, eq, isNull } from "drizzle-orm"; import type { Conversation, Message, UpdateConversationRequest } from "../../shared/api"; import type { Logger } from "../logger"; -import { paginateQuery, wrap } from "./connection"; +import { notDeleted, paginateQuery, timestamp, wrap } from "./connection"; import { conversations, messages, models } from "./schema"; export function createConversation( raw: Database, projectId: string, - logger: Logger, + _logger: Logger, defaultModelId?: string, ): { conversation: Conversation } | { error: string; status: number } { const db = wrap(raw); - let modelId = defaultModelId; - if (!modelId) { - const firstModel = db.select().from(models).limit(1).get(); - if (!firstModel) return { error: "没有可用的模型,请先配置模型", status: 400 }; - modelId = firstModel.id; - } else { - const model = db.select().from(models).where(eq(models.id, modelId)).get(); + let modelId: null | string = defaultModelId ?? null; + if (defaultModelId) { + const model = db + .select() + .from(models) + .where(and(eq(models.id, defaultModelId), notDeleted(models))) + .get(); if (!model) return { error: "模型不存在", status: 400 }; + } else { + const firstModel = db.select().from(models).where(notDeleted(models)).limit(1).get(); + if (firstModel) modelId = firstModel.id; } const id = crypto.randomUUID(); - const now = new Date().toISOString(); + const now = timestamp(); db.insert(conversations) .values({ @@ -56,7 +59,7 @@ export function createMessage( ): Message { const db = wrap(raw); const id = crypto.randomUUID(); - const now = new Date().toISOString(); + const now = timestamp(); db.insert(messages) .values({ @@ -66,6 +69,7 @@ export function createMessage( id, parts: data.parts ?? null, role: data.role, + updatedAt: now, }) .run(); @@ -84,7 +88,7 @@ export function createMessages( _logger: Logger, ): Message[] { const db = wrap(raw); - const now = new Date().toISOString(); + const now = timestamp(); const results: Message[] = []; for (const item of data) { @@ -97,6 +101,7 @@ export function createMessages( id, parts: item.parts ?? null, role: item.role, + updatedAt: now, }) .run(); const row = db.select().from(messages).where(eq(messages.id, id)).get(); @@ -112,11 +117,23 @@ export function deleteConversation( _logger: Logger, ): { error: string; status: number } | { success: true } { const db = wrap(raw); - const existing = db.select().from(conversations).where(eq(conversations.id, id)).get(); + const existing = db + .select() + .from(conversations) + .where(and(eq(conversations.id, id), notDeleted(conversations))) + .get(); if (!existing) return { error: "会话不存在", status: 404 }; - db.delete(messages).where(eq(messages.conversationId, id)).run(); - db.delete(conversations).where(eq(conversations.id, id)).run(); + const now = timestamp(); + + db.transaction((tx) => { + tx.update(messages) + .set({ deletedAt: now, updatedAt: now }) + .where(and(eq(messages.conversationId, id), isNull(messages.deletedAt))) + .run(); + tx.update(conversations).set({ deletedAt: now, updatedAt: now }).where(eq(conversations.id, id)).run(); + }); + return { success: true }; } @@ -125,7 +142,11 @@ export function getConversation( id: string, ): { conversation: Conversation } | { error: string; status: number } { const db = wrap(raw); - const row = db.select().from(conversations).where(eq(conversations.id, id)).get(); + const row = db + .select() + .from(conversations) + .where(and(eq(conversations.id, id), notDeleted(conversations))) + .get(); if (!row) return { error: "会话不存在", status: 404 }; return { conversation: toConversation(row) }; } @@ -141,6 +162,7 @@ export function listConversations( orderBy: () => desc(conversations.updatedAt), page: options.page, pageSize: options.pageSize, + softDelete: conversations.deletedAt, }); } @@ -155,6 +177,7 @@ export function listMessages( orderBy: () => desc(messages.createdAt), page: options.page, pageSize: options.pageSize, + softDelete: messages.deletedAt, }); } @@ -165,13 +188,21 @@ export function updateConversation( _logger: Logger, ): { conversation: Conversation } | { error: string; status: number } { const db = wrap(raw); - const existing = db.select().from(conversations).where(eq(conversations.id, id)).get(); + const existing = db + .select() + .from(conversations) + .where(and(eq(conversations.id, id), notDeleted(conversations))) + .get(); if (!existing) return { error: "会话不存在", status: 404 }; - const updates: { modelId?: string; title?: string; updatedAt: string } = { updatedAt: new Date().toISOString() }; + const updates: { modelId?: null | string; title?: string; updatedAt: string } = { updatedAt: timestamp() }; if (data.modelId !== undefined) { - const model = db.select().from(models).where(eq(models.id, data.modelId)).get(); + const model = db + .select() + .from(models) + .where(and(eq(models.id, data.modelId), notDeleted(models))) + .get(); if (!model) return { error: "模型不存在", status: 400 }; updates.modelId = data.modelId; } @@ -188,7 +219,7 @@ export function updateConversation( export function updateConversationTimestamp(raw: Database, id: string): void { const db = wrap(raw); - db.update(conversations).set({ updatedAt: new Date().toISOString() }).where(eq(conversations.id, id)).run(); + db.update(conversations).set({ updatedAt: timestamp() }).where(eq(conversations.id, id)).run(); } function toConversation(row: typeof conversations.$inferSelect): Conversation { @@ -210,5 +241,6 @@ function toMessage(row: typeof messages.$inferSelect): Message { id: row.id, parts: row.parts, role: row.role, + updatedAt: row.updatedAt, }; } diff --git a/src/server/db/helpers.ts b/src/server/db/helpers.ts new file mode 100644 index 0000000..d232338 --- /dev/null +++ b/src/server/db/helpers.ts @@ -0,0 +1,12 @@ +import { index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"; + +export { index, integer, sqliteTable, text, uniqueIndex }; + +export const baseColumns = { + createdAt: text("created_at").notNull(), + deletedAt: text("deleted_at"), + id: text("id") + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + updatedAt: text("updated_at").notNull(), +}; diff --git a/src/server/db/materials.ts b/src/server/db/materials.ts index 9529934..56248b1 100644 --- a/src/server/db/materials.ts +++ b/src/server/db/materials.ts @@ -1,11 +1,11 @@ import type Database from "bun:sqlite"; -import { desc, eq } from "drizzle-orm"; +import { and, desc, eq } from "drizzle-orm"; import type { CreateMaterialRequest, Material, MaterialStatus } from "../../shared/api"; import type { Logger } from "../logger"; -import { paginateQuery, wrap } from "./connection"; +import { notDeleted, paginateQuery, softDeleteRecord, timestamp, wrap } from "./connection"; import { materials, projects } from "./schema"; export function createMaterial( @@ -15,7 +15,11 @@ export function createMaterial( _logger: Logger, ): { error: string; status: number } | { material: Material } { const db = wrap(raw); - const project = db.select().from(projects).where(eq(projects.id, projectId)).get(); + const project = db + .select() + .from(projects) + .where(and(eq(projects.id, projectId), notDeleted(projects))) + .get(); if (!project) return { error: "项目不存在", status: 404 }; if (project.status === "archived") return { error: "已归档项目不可操作", status: 409 }; @@ -28,7 +32,7 @@ export function createMaterial( } const id = crypto.randomUUID(); - const now = new Date().toISOString(); + const now = timestamp(); db.insert(materials) .values({ @@ -53,11 +57,15 @@ export function deleteMaterial( _logger: Logger, ): { error: string; status: number } | { success: true } { const db = wrap(raw); - const row = db.select().from(materials).where(eq(materials.id, materialId)).get(); + const row = db + .select() + .from(materials) + .where(and(eq(materials.id, materialId), notDeleted(materials))) + .get(); if (!row) return { error: "素材不存在", status: 404 }; if (row.projectId !== projectId) return { error: "素材不属于该项目", status: 403 }; - db.delete(materials).where(eq(materials.id, materialId)).run(); + softDeleteRecord(db, materials, materialId); return { success: true }; } @@ -67,7 +75,11 @@ export function getMaterial( materialId: string, ): { error: string; status: number } | { material: Material } { const db = wrap(raw); - const row = db.select().from(materials).where(eq(materials.id, materialId)).get(); + const row = db + .select() + .from(materials) + .where(and(eq(materials.id, materialId), notDeleted(materials))) + .get(); if (!row) return { error: "素材不存在", status: 404 }; if (row.projectId !== projectId) return { error: "素材不属于该项目", status: 403 }; @@ -91,6 +103,7 @@ export function listMaterials( orderBy: () => desc(materials.createdAt), page: options.page, pageSize: options.pageSize, + softDelete: materials.deletedAt, }); } diff --git a/src/server/db/migrate.ts b/src/server/db/migrate.ts index c8108f0..d36c260 100644 --- a/src/server/db/migrate.ts +++ b/src/server/db/migrate.ts @@ -33,19 +33,29 @@ export function runMigrations(db: Database, migrations: MigrationRecord[], dataD const insertApplied = db.prepare("INSERT INTO schema_migrations (id, checksum, applied_at) VALUES (?, ?, ?)"); - db.transaction(() => { - for (const migration of pending) { - try { - logger.info({ id: migration.id }, "执行 migration"); - db.exec(migration.sql); - insertApplied.run(migration.id, migration.checksum, new Date().toISOString()); - } catch (e: unknown) { - const msg = e instanceof Error ? e.message : String(e); - logger.error({ error: msg, id: migration.id }, "migration 执行失败"); - throw e; + db.exec("PRAGMA foreign_keys = OFF"); + try { + db.transaction(() => { + for (const migration of pending) { + try { + logger.info({ id: migration.id }, "执行 migration"); + db.exec(migration.sql); + insertApplied.run(migration.id, migration.checksum, new Date().toISOString()); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + logger.error({ error: msg, id: migration.id }, "migration 执行失败"); + throw e; + } } - } - })(); + })(); + } finally { + db.exec("PRAGMA foreign_keys = ON"); + } + + const violations = db.query("PRAGMA foreign_key_check").all(); + if (violations.length > 0) { + logger.error({ violations }, "迁移后外键完整性检查失败"); + } logger.info({ count: pending.length }, "migration 全部执行完成"); } diff --git a/src/server/db/models.ts b/src/server/db/models.ts index c28f18b..f0bc0a9 100644 --- a/src/server/db/models.ts +++ b/src/server/db/models.ts @@ -1,59 +1,61 @@ import type Database from "bun:sqlite"; -import { asc, desc, eq, like, or, sql } from "drizzle-orm"; +import { and, asc, desc, eq, isNull, like, ne, or, sql } from "drizzle-orm"; import type { CreateModelRequest, Model, ModelCapability, SortOrder, UpdateModelRequest } from "../../shared/api"; import type { Logger } from "../logger"; -import { paginateQuery, wrap } from "./connection"; +import { notDeleted, paginateQuery, softDeleteRecord, timestamp, wrap } from "./connection"; import { models, providers } from "./schema"; export function createModel( raw: Database, request: CreateModelRequest, - logger: Logger, + _logger: Logger, ): { error: string; status: number } | { model: Model } { const db = wrap(raw); - const provider = db.select().from(providers).where(eq(providers.id, request.providerId)).get(); + const provider = db + .select() + .from(providers) + .where(and(eq(providers.id, request.providerId), notDeleted(providers))) + .get(); if (!provider) return { error: "供应商不存在", status: 400 }; const name = request.name.trim(); if (!name) return { error: "模型名称不能为空", status: 400 }; - const modelId = request.modelId.trim(); - if (!modelId) return { error: "模型 ID 不能为空", status: 400 }; + const externalId = request.externalId.trim(); + if (!externalId) return { error: "模型 ID 不能为空", status: 400 }; const capabilities = request.capabilities; if (!capabilities || capabilities.length === 0) { return { error: "至少选择一个能力标签", status: 400 }; } - const id = crypto.randomUUID(); - const now = new Date().toISOString(); + const duplicate = db + .select({ id: models.id }) + .from(models) + .where(and(eq(models.providerId, request.providerId), eq(models.externalId, externalId), notDeleted(models))) + .get(); + if (duplicate) return { error: "该供应商下模型 ID 已存在", status: 409 }; - try { - db.insert(models) - .values({ - capabilities: JSON.stringify(capabilities), - contextLength: request.contextLength ?? null, - createdAt: now, - id, - maxOutputTokens: request.maxOutputTokens ?? null, - modelId, - name, - providerId: request.providerId, - updatedAt: now, - }) - .run(); - } catch (e: unknown) { - const msg = e instanceof Error ? e.message : String(e); - if (msg.includes("UNIQUE constraint")) { - return { error: "该供应商下模型 ID 已存在", status: 409 }; - } - logger.error({ error: msg, operation: "create", table: "models" }, "数据库操作失败"); - throw e; - } + const id = crypto.randomUUID(); + const now = timestamp(); + + db.insert(models) + .values({ + capabilities: JSON.stringify(capabilities), + contextLength: request.contextLength ?? null, + createdAt: now, + externalId, + id, + maxOutputTokens: request.maxOutputTokens ?? null, + name, + providerId: request.providerId, + updatedAt: now, + }) + .run(); const row = db.select().from(models).where(eq(models.id, id)).get(); return { model: toModel(row!) }; @@ -65,16 +67,24 @@ export function deleteModel( _logger: Logger, ): { error: string; status: number } | { success: true } { const db = wrap(raw); - const existing = db.select().from(models).where(eq(models.id, id)).get(); + const existing = db + .select() + .from(models) + .where(and(eq(models.id, id), notDeleted(models))) + .get(); if (!existing) return { error: "模型不存在", status: 404 }; - db.delete(models).where(eq(models.id, id)).run(); + softDeleteRecord(db, models, id); return { success: true }; } export function getModel(raw: Database, id: string): { error: string; status: number } | { model: Model } { const db = wrap(raw); - const row = db.select().from(models).where(eq(models.id, id)).get(); + const row = db + .select() + .from(models) + .where(and(eq(models.id, id), notDeleted(models))) + .get(); if (!row) return { error: "模型不存在", status: 404 }; return { model: toModel(row) }; @@ -85,7 +95,7 @@ export function getModelsByProviderId(raw: Database, providerId: string): number const result = db .select({ count: sql`count(*)` }) .from(models) - .where(eq(models.providerId, providerId)) + .where(and(eq(models.providerId, providerId), isNull(models.deletedAt))) .get(); return Number(result?.count ?? 0); } @@ -96,20 +106,28 @@ export function getModelWithProvider( ): | { error: string; status: number } | { - model: { modelId: string; name: string; providerId: string }; + model: { externalId: string; name: string; providerId: string }; provider: { apiKey: string; baseUrl: string; id: string; type: string }; } { const db = wrap(raw); - const row = db.select().from(models).where(eq(models.id, modelId)).get(); + const row = db + .select() + .from(models) + .where(and(eq(models.id, modelId), notDeleted(models))) + .get(); if (!row) return { error: "模型不存在", status: 404 }; - const providerRow = db.select().from(providers).where(eq(providers.id, row.providerId)).get(); + const providerRow = db + .select() + .from(providers) + .where(and(eq(providers.id, row.providerId), notDeleted(providers))) + .get(); if (!providerRow) return { error: "供应商不存在", status: 404 }; return { model: { - modelId: row.modelId, + externalId: row.externalId, name: row.name, providerId: row.providerId, }, @@ -142,7 +160,7 @@ export function listModels( if (options.keyword) { const pattern = `%${options.keyword}%`; - conditions.push(or(like(models.name, pattern), like(models.modelId, pattern))!); + conditions.push(or(like(models.name, pattern), like(models.externalId, pattern))!); } if (options.capabilities) { @@ -157,6 +175,7 @@ export function listModels( orderBy: orderByFn, page: options.page, pageSize: options.pageSize, + softDelete: models.deletedAt, }); } @@ -164,14 +183,18 @@ export function updateModel( raw: Database, id: string, request: UpdateModelRequest, - logger: Logger, + _logger: Logger, ): { error: string; status: number } | { model: Model } { const db = wrap(raw); - const existing = db.select().from(models).where(eq(models.id, id)).get(); + const existing = db + .select() + .from(models) + .where(and(eq(models.id, id), notDeleted(models))) + .get(); if (!existing) return { error: "模型不存在", status: 404 }; const updates: Partial = { - updatedAt: new Date().toISOString(), + updatedAt: timestamp(), }; const name = request.name?.trim(); @@ -180,14 +203,32 @@ export function updateModel( updates.name = name; } - const modelId = request.modelId?.trim(); - if (modelId === "") return { error: "模型 ID 不能为空", status: 400 }; - if (modelId !== undefined) { - updates.modelId = modelId; + const externalId = request.externalId?.trim(); + if (externalId === "") return { error: "模型 ID 不能为空", status: 400 }; + if (externalId !== undefined) { + const providerId = request.providerId ?? existing.providerId; + const duplicate = db + .select({ id: models.id }) + .from(models) + .where( + and( + eq(models.providerId, providerId), + eq(models.externalId, externalId), + notDeleted(models), + ne(models.id, id), + ), + ) + .get(); + if (duplicate) return { error: "该供应商下模型 ID 已存在", status: 409 }; + updates.externalId = externalId; } if (request.providerId !== undefined) { - const provider = db.select().from(providers).where(eq(providers.id, request.providerId)).get(); + const provider = db + .select() + .from(providers) + .where(and(eq(providers.id, request.providerId), notDeleted(providers))) + .get(); if (!provider) return { error: "供应商不存在", status: 400 }; updates.providerId = request.providerId; } @@ -211,16 +252,7 @@ export function updateModel( return { model: toModel(existing) }; } - try { - db.update(models).set(updates).where(eq(models.id, id)).run(); - } catch (e: unknown) { - const msg = e instanceof Error ? e.message : String(e); - if (msg.includes("UNIQUE constraint")) { - return { error: "该供应商下模型 ID 已存在", status: 409 }; - } - logger.error({ error: msg, operation: "update", table: "models" }, "数据库操作失败"); - throw e; - } + db.update(models).set(updates).where(eq(models.id, id)).run(); const updated = db.select().from(models).where(eq(models.id, id)).get(); return { model: toModel(updated!) }; @@ -242,9 +274,9 @@ function toModel(row: typeof models.$inferSelect): Model { capabilities: JSON.parse(row.capabilities) as ModelCapability[], contextLength: row.contextLength, createdAt: row.createdAt, + externalId: row.externalId, id: row.id, maxOutputTokens: row.maxOutputTokens, - modelId: row.modelId, name: row.name, providerId: row.providerId, updatedAt: row.updatedAt, diff --git a/src/server/db/projects.ts b/src/server/db/projects.ts index 465b5e9..a58136a 100644 --- a/src/server/db/projects.ts +++ b/src/server/db/projects.ts @@ -1,12 +1,12 @@ import type Database from "bun:sqlite"; -import { asc, desc, eq, like, or } from "drizzle-orm"; +import { and, asc, desc, eq, inArray, isNull, like, ne, or } from "drizzle-orm"; import type { CreateProjectRequest, Project, ProjectStatus, SortOrder, UpdateProjectRequest } from "../../shared/api"; import type { Logger } from "../logger"; -import { paginateQuery, wrap } from "./connection"; -import { projects } from "./schema"; +import { notDeleted, paginateQuery, timestamp, wrap } from "./connection"; +import { conversations, materials, messages, projects } from "./schema"; export function archiveProject( raw: Database, @@ -14,12 +14,16 @@ export function archiveProject( _logger: Logger, ): { error: string; status: number } | { project: Project } { const db = wrap(raw); - const existing = db.select().from(projects).where(eq(projects.id, id)).get(); + const existing = db + .select() + .from(projects) + .where(and(eq(projects.id, id), notDeleted(projects))) + .get(); if (!existing) return { error: "项目不存在", status: 404 }; if (existing.status === "archived") return { error: "项目已归档", status: 409 }; - const now = new Date().toISOString(); - db.update(projects).set({ archivedAt: now, status: "archived", updatedAt: now }).where(eq(projects.id, id)).run(); + const now = timestamp(); + db.update(projects).set({ status: "archived", updatedAt: now }).where(eq(projects.id, id)).run(); const updated = db.select().from(projects).where(eq(projects.id, id)).get(); return { project: toProject(updated!) }; @@ -28,37 +32,34 @@ export function archiveProject( export function createProject( raw: Database, request: CreateProjectRequest, - logger: Logger, + _logger: Logger, ): { error: string; status: number } | { project: Project } { const db = wrap(raw); const name = request.name.trim(); if (!name) return { error: "项目名称不能为空", status: 400 }; if (name.length > 10) return { error: "项目名称不能超过 10 个字符", status: 400 }; + const duplicate = db + .select({ id: projects.id }) + .from(projects) + .where(and(eq(projects.name, name), notDeleted(projects))) + .get(); + if (duplicate) return { error: "项目名称已存在", status: 409 }; + const description = (request.description ?? "").trim(); const id = crypto.randomUUID(); - const now = new Date().toISOString(); + const now = timestamp(); - try { - db.insert(projects) - .values({ - archivedAt: null, - createdAt: now, - description, - id, - name, - status: "active", - updatedAt: now, - }) - .run(); - } catch (e: unknown) { - const msg = e instanceof Error ? e.message : String(e); - if (msg.includes("UNIQUE constraint")) { - return { error: "项目名称已存在", status: 409 }; - } - logger.error({ error: msg, operation: "create", table: "projects" }, "数据库操作失败"); - throw e; - } + db.insert(projects) + .values({ + createdAt: now, + description, + id, + name, + status: "active", + updatedAt: now, + }) + .run(); const row = db.select().from(projects).where(eq(projects.id, id)).get(); return { project: toProject(row!) }; @@ -70,17 +71,53 @@ export function deleteProject( _logger: Logger, ): { error: string; status: number } | { success: true } { const db = wrap(raw); - const existing = db.select().from(projects).where(eq(projects.id, id)).get(); + const existing = db + .select() + .from(projects) + .where(and(eq(projects.id, id), notDeleted(projects))) + .get(); if (!existing) return { error: "项目不存在", status: 404 }; if (existing.status === "active") return { error: "活跃项目不可删除,请先归档", status: 409 }; - db.delete(projects).where(eq(projects.id, id)).run(); + const now = timestamp(); + + db.transaction((tx) => { + const convIds = tx + .select({ id: conversations.id }) + .from(conversations) + .where(and(eq(conversations.projectId, id), isNull(conversations.deletedAt))) + .all() + .map((r) => r.id); + + if (convIds.length > 0) { + tx.update(messages) + .set({ deletedAt: now, updatedAt: now }) + .where(and(inArray(messages.conversationId, convIds), isNull(messages.deletedAt))) + .run(); + tx.update(conversations) + .set({ deletedAt: now, updatedAt: now }) + .where(and(inArray(conversations.id, convIds), isNull(conversations.deletedAt))) + .run(); + } + + tx.update(materials) + .set({ deletedAt: now, updatedAt: now }) + .where(and(eq(materials.projectId, id), isNull(materials.deletedAt))) + .run(); + + tx.update(projects).set({ deletedAt: now, updatedAt: now }).where(eq(projects.id, id)).run(); + }); + return { success: true }; } export function getProject(raw: Database, id: string): { error: string; status: number } | { project: Project } { const db = wrap(raw); - const row = db.select().from(projects).where(eq(projects.id, id)).get(); + const row = db + .select() + .from(projects) + .where(and(eq(projects.id, id), notDeleted(projects))) + .get(); if (!row) return { error: "项目不存在", status: 404 }; return { project: toProject(row) }; @@ -116,6 +153,7 @@ export function listProjects( orderBy: orderByFn, page: options.page, pageSize: options.pageSize, + softDelete: projects.deletedAt, }); } @@ -125,12 +163,16 @@ export function restoreProject( _logger: Logger, ): { error: string; status: number } | { project: Project } { const db = wrap(raw); - const existing = db.select().from(projects).where(eq(projects.id, id)).get(); + const existing = db + .select() + .from(projects) + .where(and(eq(projects.id, id), notDeleted(projects))) + .get(); if (!existing) return { error: "项目不存在", status: 404 }; if (existing.status === "active") return { error: "项目已是活跃状态", status: 409 }; - const now = new Date().toISOString(); - db.update(projects).set({ archivedAt: null, status: "active", updatedAt: now }).where(eq(projects.id, id)).run(); + const now = timestamp(); + db.update(projects).set({ status: "active", updatedAt: now }).where(eq(projects.id, id)).run(); const updated = db.select().from(projects).where(eq(projects.id, id)).get(); return { project: toProject(updated!) }; @@ -140,10 +182,14 @@ export function updateProject( raw: Database, id: string, request: UpdateProjectRequest, - logger: Logger, + _logger: Logger, ): { error: string; status: number } | { project: Project } { const db = wrap(raw); - const existing = db.select().from(projects).where(eq(projects.id, id)).get(); + const existing = db + .select() + .from(projects) + .where(and(eq(projects.id, id), notDeleted(projects))) + .get(); if (!existing) return { error: "项目不存在", status: 404 }; if (existing.status === "archived") return { error: "已归档项目不可编辑", status: 409 }; @@ -152,10 +198,16 @@ export function updateProject( if (name !== undefined && name.length > 10) return { error: "项目名称不能超过 10 个字符", status: 400 }; const updates: Partial = { - updatedAt: new Date().toISOString(), + updatedAt: timestamp(), }; if (name !== undefined && name !== existing.name) { + const duplicate = db + .select({ id: projects.id }) + .from(projects) + .where(and(eq(projects.name, name), notDeleted(projects), ne(projects.id, id))) + .get(); + if (duplicate) return { error: "项目名称已存在", status: 409 }; updates.name = name; } @@ -168,16 +220,7 @@ export function updateProject( return { project: toProject(existing) }; } - try { - db.update(projects).set(updates).where(eq(projects.id, id)).run(); - } catch (e: unknown) { - const msg = e instanceof Error ? e.message : String(e); - if (msg.includes("UNIQUE constraint")) { - return { error: "项目名称已存在", status: 409 }; - } - logger.error({ error: msg, operation: "update", table: "projects" }, "数据库操作失败"); - throw e; - } + db.update(projects).set(updates).where(eq(projects.id, id)).run(); const updated = db.select().from(projects).where(eq(projects.id, id)).get(); return { project: toProject(updated!) }; @@ -196,7 +239,6 @@ function buildProjectOrderBy( function toProject(row: typeof projects.$inferSelect): Project { return { - archivedAt: row.archivedAt, createdAt: row.createdAt, description: row.description, id: row.id, diff --git a/src/server/db/providers.ts b/src/server/db/providers.ts index d0e1a43..68e4d4c 100644 --- a/src/server/db/providers.ts +++ b/src/server/db/providers.ts @@ -1,6 +1,6 @@ import type Database from "bun:sqlite"; -import { asc, desc, eq, like } from "drizzle-orm"; +import { and, asc, desc, eq, isNull, like, ne } from "drizzle-orm"; import type { CreateProviderRequest, @@ -11,13 +11,13 @@ import type { } from "../../shared/api"; import type { Logger } from "../logger"; -import { paginateQuery, wrap } from "./connection"; -import { providers } from "./schema"; +import { notDeleted, paginateQuery, softDeleteRecord, timestamp, wrap } from "./connection"; +import { models, providers } from "./schema"; export function createProvider( raw: Database, request: CreateProviderRequest, - logger: Logger, + _logger: Logger, ): { error: string; status: number } | { provider: Provider } { const db = wrap(raw); const name = request.name.trim(); @@ -29,29 +29,27 @@ export function createProvider( const apiKey = request.apiKey.trim(); if (!apiKey) return { error: "API Key 不能为空", status: 400 }; - const id = crypto.randomUUID(); - const now = new Date().toISOString(); + const duplicate = db + .select({ id: providers.id }) + .from(providers) + .where(and(eq(providers.name, name), notDeleted(providers))) + .get(); + if (duplicate) return { error: "供应商名称已存在", status: 409 }; - try { - db.insert(providers) - .values({ - apiKey, - baseUrl, - createdAt: now, - id, - name, - type: request.type, - updatedAt: now, - }) - .run(); - } catch (e: unknown) { - const msg = e instanceof Error ? e.message : String(e); - if (msg.includes("UNIQUE constraint")) { - return { error: "供应商名称已存在", status: 409 }; - } - logger.error({ error: msg, operation: "create", table: "providers" }, "数据库操作失败"); - throw e; - } + const id = crypto.randomUUID(); + const now = timestamp(); + + db.insert(providers) + .values({ + apiKey, + baseUrl, + createdAt: now, + id, + name, + type: request.type, + updatedAt: now, + }) + .run(); const row = db.select().from(providers).where(eq(providers.id, id)).get(); return { provider: toProvider(row!) }; @@ -63,16 +61,31 @@ export function deleteProvider( _logger: Logger, ): { error: string; status: number } | { success: true } { const db = wrap(raw); - const existing = db.select().from(providers).where(eq(providers.id, id)).get(); + const existing = db + .select() + .from(providers) + .where(and(eq(providers.id, id), notDeleted(providers))) + .get(); if (!existing) return { error: "供应商不存在", status: 404 }; - db.delete(providers).where(eq(providers.id, id)).run(); + const activeModels = db + .select({ id: models.id }) + .from(models) + .where(and(eq(models.providerId, id), isNull(models.deletedAt))) + .get(); + if (activeModels) return { error: "该供应商下仍有模型,无法删除", status: 409 }; + + softDeleteRecord(db, providers, id); return { success: true }; } export function getProvider(raw: Database, id: string): { error: string; status: number } | { provider: Provider } { const db = wrap(raw); - const row = db.select().from(providers).where(eq(providers.id, id)).get(); + const row = db + .select() + .from(providers) + .where(and(eq(providers.id, id), notDeleted(providers))) + .get(); if (!row) return { error: "供应商不存在", status: 404 }; return { provider: toProvider(row) }; @@ -83,6 +96,7 @@ export function listProviderOptions(raw: Database): ProviderOption[] { const rows = db .select({ id: providers.id, name: providers.name, type: providers.type }) .from(providers) + .where(notDeleted(providers)) .orderBy(desc(providers.createdAt)) .all(); @@ -112,6 +126,7 @@ export function listProviders( orderBy: orderByFn, page: options.page, pageSize: options.pageSize, + softDelete: providers.deletedAt, }); } @@ -119,19 +134,29 @@ export function updateProvider( raw: Database, id: string, request: UpdateProviderRequest, - logger: Logger, + _logger: Logger, ): { error: string; status: number } | { provider: Provider } { const db = wrap(raw); - const existing = db.select().from(providers).where(eq(providers.id, id)).get(); + const existing = db + .select() + .from(providers) + .where(and(eq(providers.id, id), notDeleted(providers))) + .get(); if (!existing) return { error: "供应商不存在", status: 404 }; const updates: Partial = { - updatedAt: new Date().toISOString(), + updatedAt: timestamp(), }; const name = request.name?.trim(); if (name === "") return { error: "供应商名称不能为空", status: 400 }; if (name !== undefined && name !== existing.name) { + const duplicate = db + .select({ id: providers.id }) + .from(providers) + .where(and(eq(providers.name, name), notDeleted(providers), ne(providers.id, id))) + .get(); + if (duplicate) return { error: "供应商名称已存在", status: 409 }; updates.name = name; } @@ -155,16 +180,7 @@ export function updateProvider( return { provider: toProvider(existing) }; } - try { - db.update(providers).set(updates).where(eq(providers.id, id)).run(); - } catch (e: unknown) { - const msg = e instanceof Error ? e.message : String(e); - if (msg.includes("UNIQUE constraint")) { - return { error: "供应商名称已存在", status: 409 }; - } - logger.error({ error: msg, operation: "update", table: "providers" }, "数据库操作失败"); - throw e; - } + db.update(providers).set(updates).where(eq(providers.id, id)).run(); const updated = db.select().from(providers).where(eq(providers.id, id)).get(); return { provider: toProvider(updated!) }; diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index c6984e7..0a161e9 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -1,81 +1,68 @@ -import { index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"; +import { baseColumns, index, integer, sqliteTable, text } from "./helpers"; export const projects = sqliteTable("projects", { - archivedAt: text("archived_at"), - createdAt: text("created_at").notNull(), + ...baseColumns, description: text("description").notNull().default(""), - id: text("id").primaryKey(), - name: text("name").notNull().unique(), + name: text("name").notNull(), status: text("status", { enum: ["active", "archived"] }) .notNull() .default("active"), - updatedAt: text("updated_at").notNull(), }); export const providers = sqliteTable("providers", { + ...baseColumns, apiKey: text("api_key").notNull(), baseUrl: text("base_url").notNull(), - createdAt: text("created_at").notNull(), - id: text("id").primaryKey(), - name: text("name").notNull().unique(), + name: text("name").notNull(), type: text("type", { enum: ["anthropic", "openai", "openai-compatible"] }) .notNull() .default("openai-compatible"), - updatedAt: text("updated_at").notNull(), }); export const models = sqliteTable( "models", { + ...baseColumns, capabilities: text("capabilities").notNull(), contextLength: integer("context_length"), - createdAt: text("created_at").notNull(), - id: text("id").primaryKey(), + externalId: text("external_id").notNull(), maxOutputTokens: integer("max_output_tokens"), - modelId: text("model_id").notNull(), name: text("name").notNull(), providerId: text("provider_id") .notNull() .references(() => providers.id), - updatedAt: text("updated_at").notNull(), }, - (table) => [ - uniqueIndex("models_provider_id_model_id_unique").on(table.providerId, table.modelId), - index("models_provider_id_idx").on(table.providerId), - ], + (table) => [index("models_provider_id_idx").on(table.providerId)], ); export const conversations = sqliteTable( "conversations", { - createdAt: text("created_at").notNull(), - id: text("id").primaryKey(), - modelId: text("model_id") - .notNull() - .references(() => models.id), + ...baseColumns, + modelId: text("model_id").references(() => models.id), projectId: text("project_id") .notNull() .references(() => projects.id), title: text("title").notNull().default("新会话"), - updatedAt: text("updated_at").notNull(), }, - (table) => [index("conversations_project_id_idx").on(table.projectId)], + (table) => [ + index("conversations_project_id_idx").on(table.projectId), + index("conversations_model_id_idx").on(table.modelId), + ], ); export const materials = sqliteTable( "materials", { + ...baseColumns, associatedDate: text("associated_date").notNull(), - createdAt: text("created_at").notNull(), description: text("description").notNull(), - id: text("id").primaryKey(), projectId: text("project_id") .notNull() .references(() => projects.id), status: text("status", { enum: ["pending", "approved", "discarded"] }) .notNull() .default("pending"), - updatedAt: text("updated_at").notNull(), }, (table) => [index("materials_project_id_idx").on(table.projectId)], ); @@ -83,12 +70,11 @@ export const materials = sqliteTable( export const messages = sqliteTable( "messages", { + ...baseColumns, content: text("content").notNull().default(""), conversationId: text("conversation_id") .notNull() - .references(() => conversations.id, { onDelete: "cascade" }), - createdAt: text("created_at").notNull(), - id: text("id").primaryKey(), + .references(() => conversations.id), parts: text("parts"), role: text("role", { enum: ["assistant", "system", "user"] }).notNull(), }, diff --git a/src/server/routes/chat/send.ts b/src/server/routes/chat/send.ts index de05010..b076ee6 100644 --- a/src/server/routes/chat/send.ts +++ b/src/server/routes/chat/send.ts @@ -13,7 +13,7 @@ import { updateConversation, updateConversationTimestamp, } from "../../db/conversations"; -import { getModelWithProvider } from "../../db/models"; +import { getModelWithProvider, listModels } from "../../db/models"; import { createApiError, jsonResponse } from "../../helpers"; import { validateIdParam } from "../../middleware"; @@ -79,13 +79,23 @@ export async function handleSendChat(req: Request, db: Database, mode: RuntimeMo let model; try { - const result = getModelWithProvider(db, conversation.modelId); + let effectiveModelId = conversation.modelId; + if (!effectiveModelId) { + const fallback = listModels(db, { page: 1, pageSize: 1 }); + const firstModel = fallback.items[0]; + if (!firstModel) { + return jsonResponse(createApiError("没有可用的模型,请先配置模型", 400), { mode, status: 400 }); + } + effectiveModelId = firstModel.id; + } + + const result = getModelWithProvider(db, effectiveModelId); if ("error" in result) { return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status }); } const registry = buildProviderRegistry(db); - model = registry.languageModel(`${result.provider.id}:${result.model.modelId}`); + model = registry.languageModel(`${result.provider.id}:${result.model.externalId}`); } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e); return jsonResponse(createApiError(`模型初始化失败:${msg}`, 500), { mode, status: 500 }); diff --git a/src/server/routes/models/create.ts b/src/server/routes/models/create.ts index 35bbe6e..c39a4fe 100644 --- a/src/server/routes/models/create.ts +++ b/src/server/routes/models/create.ts @@ -25,8 +25,8 @@ export async function handleCreateModel( return jsonResponse(createApiError("name is required", 400), { mode, status: 400 }); } - if (!body.modelId || typeof body.modelId !== "string") { - return jsonResponse(createApiError("modelId is required", 400), { mode, status: 400 }); + if (!body.externalId || typeof body.externalId !== "string") { + return jsonResponse(createApiError("externalId is required", 400), { mode, status: 400 }); } if (!body.providerId || typeof body.providerId !== "string") { diff --git a/src/server/routes/models/test.ts b/src/server/routes/models/test.ts index a80cb48..3b8515c 100644 --- a/src/server/routes/models/test.ts +++ b/src/server/routes/models/test.ts @@ -25,8 +25,8 @@ export async function handleTestModelConfig( return jsonResponse(createApiError("providerId is required", 400), { mode, status: 400 }); } - if (!body.modelId || typeof body.modelId !== "string") { - return jsonResponse(createApiError("modelId is required", 400), { mode, status: 400 }); + if (!body.externalId || typeof body.externalId !== "string") { + return jsonResponse(createApiError("externalId is required", 400), { mode, status: 400 }); } const providerResult = getProvider(db, body.providerId); @@ -41,7 +41,7 @@ export async function handleTestModelConfig( { apiKey: providerResult.provider.apiKey, baseUrl: providerResult.provider.baseUrl, - modelId: body.modelId, + modelId: body.externalId, name: providerResult.provider.name, type: providerResult.provider.type, }, @@ -50,7 +50,7 @@ export async function handleTestModelConfig( if (!testResult.ok) { logger.warn( - { message: testResult.message, modelId: body.modelId, providerId: body.providerId }, + { externalId: body.externalId, message: testResult.message, providerId: body.providerId }, "模型连接测试失败", ); } diff --git a/src/shared/api.ts b/src/shared/api.ts index c922d13..3373993 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -6,7 +6,7 @@ export interface ApiErrorResponse { export interface Conversation { createdAt: string; id: string; - modelId: string; + modelId: null | string; projectId: string; title: string; updatedAt: string; @@ -36,8 +36,8 @@ export interface CreateMaterialRequest { export interface CreateModelRequest { capabilities: ModelCapability[]; contextLength?: null | number; + externalId: string; maxOutputTokens?: null | number; - modelId: string; name: string; providerId: string; } @@ -94,6 +94,7 @@ export interface Message { id: string; parts: null | string; role: "assistant" | "system" | "user"; + updatedAt: string; } export interface MessageListResponse { @@ -114,9 +115,9 @@ export interface Model { capabilities: ModelCapability[]; contextLength: null | number; createdAt: string; + externalId: string; id: string; maxOutputTokens: null | number; - modelId: string; name: string; providerId: string; updatedAt: string; @@ -171,7 +172,6 @@ export interface ModelTestResultResponse { } export interface Project { - archivedAt: null | string; createdAt: string; description: string; id: string; @@ -238,15 +238,15 @@ export type ProviderType = "anthropic" | "openai" | "openai-compatible"; export type RuntimeMode = "development" | "production" | "test"; export interface TestModelRequest { - modelId: string; + externalId: string; providerId: string; } export interface UpdateModelRequest { capabilities?: ModelCapability[]; contextLength?: null | number; + externalId?: string; maxOutputTokens?: null | number; - modelId?: string; name?: string; providerId?: string; } diff --git a/src/web/features/models/components/ModelFormModal.tsx b/src/web/features/models/components/ModelFormModal.tsx index e5595a8..aab07d9 100644 --- a/src/web/features/models/components/ModelFormModal.tsx +++ b/src/web/features/models/components/ModelFormModal.tsx @@ -14,8 +14,8 @@ import type { interface FormValues { capabilities: ModelCapability[]; contextLength: null | number; + externalId: string; maxOutputTokens: null | number; - modelId: string; name: string; providerId: string; } @@ -70,8 +70,8 @@ export function ModelFormModal({ form.setFieldsValue({ capabilities: editingModel.capabilities, contextLength: editingModel.contextLength, + externalId: editingModel.externalId, maxOutputTokens: editingModel.maxOutputTokens, - modelId: editingModel.modelId, name: editingModel.name, providerId: editingModel.providerId, }); @@ -86,7 +86,7 @@ export function ModelFormModal({ if (editingModel) { const reqData: UpdateModelRequest = {}; if (values.name !== editingModel.name) reqData.name = values.name; - if (values.modelId !== editingModel.modelId) reqData.modelId = values.modelId; + if (values.externalId !== editingModel.externalId) reqData.externalId = values.externalId; if (values.providerId !== editingModel.providerId) reqData.providerId = values.providerId; const capsChanged = values.capabilities.length !== editingModel.capabilities.length || @@ -100,8 +100,8 @@ export function ModelFormModal({ const reqData: CreateModelRequest = { capabilities: values.capabilities, contextLength: values.contextLength ?? undefined, + externalId: values.externalId, maxOutputTokens: values.maxOutputTokens ?? undefined, - modelId: values.modelId, name: values.name, providerId: values.providerId, }; @@ -119,18 +119,18 @@ export function ModelFormModal({ const handleTest = async () => { if (!testModelConnection) return; const providerId: unknown = form.getFieldValue("providerId"); - const modelId: unknown = form.getFieldValue("modelId"); + const externalId: unknown = form.getFieldValue("externalId"); if (typeof providerId !== "string" || !providerId) { message.warning("请先选择供应商"); return; } - if (typeof modelId !== "string" || !modelId) { + if (typeof externalId !== "string" || !externalId) { message.warning("请先输入模型 ID"); return; } setTesting(true); try { - const result = await testModelConnection({ modelId, providerId }); + const result = await testModelConnection({ externalId, providerId }); if (result.ok) { message.success(result.message); } else { @@ -177,7 +177,7 @@ export function ModelFormModal({ diff --git a/src/web/shared/hooks/use-models.ts b/src/web/shared/hooks/use-models.ts index 891772e..62170f3 100644 --- a/src/web/shared/hooks/use-models.ts +++ b/src/web/shared/hooks/use-models.ts @@ -91,7 +91,7 @@ export function useCreateModel() { return useMutation({ mutationFn: createModel, onSuccess: (data) => { - logger.info("模型创建成功", { modelId: data.modelId, providerId: data.providerId }); + logger.info("模型创建成功", { externalId: data.externalId, providerId: data.providerId }); void queryClient.invalidateQueries({ queryKey: MODELS_KEY }); }, }); @@ -142,7 +142,7 @@ export function useUpdateModel() { return useMutation({ mutationFn: (args: { data: UpdateModelRequest; id: string }) => updateModel(args.id, args.data), onSuccess: (data) => { - logger.info("模型更新成功", { modelId: data.modelId, providerId: data.providerId }); + logger.info("模型更新成功", { externalId: data.externalId, providerId: data.providerId }); void queryClient.invalidateQueries({ queryKey: MODELS_KEY }); }, }); diff --git a/tests/server/db/models.test.ts b/tests/server/db/models.test.ts index 8355394..1849eba 100644 --- a/tests/server/db/models.test.ts +++ b/tests/server/db/models.test.ts @@ -41,17 +41,18 @@ describe("模型数据访问层", () => { db, { capabilities: ["text", "reasoning"], - modelId: "gpt-4o", + externalId: "gpt-4o", name: "GPT-4o", providerId, }, createNoopLogger(), ); expect("error" in result).toBe(false); - const model = (result as { model: { capabilities: string[]; modelId: string; name: string; providerId: string } }) - .model; + const model = ( + result as { model: { capabilities: string[]; externalId: string; name: string; providerId: string } } + ).model; expect(model.name).toBe("GPT-4o"); - expect(model.modelId).toBe("gpt-4o"); + expect(model.externalId).toBe("gpt-4o"); expect(model.providerId).toBe(providerId); expect(model.capabilities).toEqual(["text", "reasoning"]); }); @@ -63,7 +64,7 @@ describe("模型数据访问层", () => { db, { capabilities: ["text"], - modelId: "test", + externalId: "test", name: "Test", providerId: "nonexistent", }, @@ -77,10 +78,10 @@ describe("模型数据访问层", () => { test("同一供应商下模型 ID 唯一", () => { withDb((db) => { const providerId = seedProvider(db); - createModel(db, { capabilities: ["text"], modelId: "gpt-4o", name: "Model1", providerId }, createNoopLogger()); + createModel(db, { capabilities: ["text"], externalId: "gpt-4o", name: "Model1", providerId }, createNoopLogger()); const result = createModel( db, - { capabilities: ["text"], modelId: "gpt-4o", name: "Model2", providerId }, + { capabilities: ["text"], externalId: "gpt-4o", name: "Model2", providerId }, createNoopLogger(), ); expect("error" in result).toBe(true); @@ -94,12 +95,12 @@ describe("模型数据访问层", () => { const p2 = seedProvider(db, "P2"); const r1 = createModel( db, - { capabilities: ["text"], modelId: "same-id", name: "M1", providerId: p1 }, + { capabilities: ["text"], externalId: "same-id", name: "M1", providerId: p1 }, createNoopLogger(), ); const r2 = createModel( db, - { capabilities: ["text"], modelId: "same-id", name: "M2", providerId: p2 }, + { capabilities: ["text"], externalId: "same-id", name: "M2", providerId: p2 }, createNoopLogger(), ); expect("error" in r1).toBe(false); @@ -112,7 +113,7 @@ describe("模型数据访问层", () => { const providerId = seedProvider(db); const result = createModel( db, - { capabilities: [], modelId: "test", name: "Test", providerId }, + { capabilities: [], externalId: "test", name: "Test", providerId }, createNoopLogger(), ); expect("error" in result).toBe(true); @@ -124,9 +125,9 @@ describe("模型数据访问层", () => { withDb((db) => { const p1 = seedProvider(db, "P1"); const p2 = seedProvider(db, "P2"); - createModel(db, { capabilities: ["text"], modelId: "m1", name: "Alpha", providerId: p1 }, createNoopLogger()); - createModel(db, { capabilities: ["text"], modelId: "m2", name: "Beta", providerId: p1 }, createNoopLogger()); - createModel(db, { capabilities: ["text"], modelId: "m3", name: "Gamma", providerId: p2 }, createNoopLogger()); + createModel(db, { capabilities: ["text"], externalId: "m1", name: "Alpha", providerId: p1 }, createNoopLogger()); + createModel(db, { capabilities: ["text"], externalId: "m2", name: "Beta", providerId: p1 }, createNoopLogger()); + createModel(db, { capabilities: ["text"], externalId: "m3", name: "Gamma", providerId: p2 }, createNoopLogger()); const all = listModels(db, { page: 1, pageSize: 20 }); expect(all.total).toBe(3); @@ -144,7 +145,7 @@ describe("模型数据访问层", () => { const providerId = seedProvider(db); const created = createModel( db, - { capabilities: ["text"], modelId: "gpt-4o", name: "GPT-4o", providerId }, + { capabilities: ["text"], externalId: "gpt-4o", name: "GPT-4o", providerId }, createNoopLogger(), ); const id = (created as { model: { id: string } }).model.id; @@ -168,7 +169,7 @@ describe("模型数据访问层", () => { const providerId = seedProvider(db); const created = createModel( db, - { capabilities: ["text"], modelId: "gpt-4o", name: "原名", providerId }, + { capabilities: ["text"], externalId: "gpt-4o", name: "原名", providerId }, createNoopLogger(), ); const id = (created as { model: { id: string } }).model.id; @@ -186,7 +187,7 @@ describe("模型数据访问层", () => { const providerId = seedProvider(db); const created = createModel( db, - { capabilities: ["text"], modelId: "gpt-4o", name: "删除测试", providerId }, + { capabilities: ["text"], externalId: "gpt-4o", name: "删除测试", providerId }, createNoopLogger(), ); const id = (created as { model: { id: string } }).model.id; @@ -203,9 +204,9 @@ describe("模型数据访问层", () => { withDb((db) => { const p1 = seedProvider(db, "P1"); const p2 = seedProvider(db, "P2"); - createModel(db, { capabilities: ["text"], modelId: "m1", name: "M1", providerId: p1 }, createNoopLogger()); - createModel(db, { capabilities: ["text"], modelId: "m2", name: "M2", providerId: p1 }, createNoopLogger()); - createModel(db, { capabilities: ["text"], modelId: "m3", name: "M3", providerId: p2 }, createNoopLogger()); + createModel(db, { capabilities: ["text"], externalId: "m1", name: "M1", providerId: p1 }, createNoopLogger()); + createModel(db, { capabilities: ["text"], externalId: "m2", name: "M2", providerId: p1 }, createNoopLogger()); + createModel(db, { capabilities: ["text"], externalId: "m3", name: "M3", providerId: p2 }, createNoopLogger()); expect(getModelsByProviderId(db, p1)).toBe(2); expect(getModelsByProviderId(db, p2)).toBe(1); @@ -220,8 +221,8 @@ describe("模型数据访问层", () => { { capabilities: ["text"], contextLength: 128000, + externalId: "gpt-4o", maxOutputTokens: 4096, - modelId: "gpt-4o", name: "GPT-4o", providerId, }, diff --git a/tests/server/db/projects.test.ts b/tests/server/db/projects.test.ts index d7394d8..185e049 100644 --- a/tests/server/db/projects.test.ts +++ b/tests/server/db/projects.test.ts @@ -132,16 +132,13 @@ describe("项目数据访问层", () => { const result = archiveProject(db, id, createNoopLogger()); expect("error" in result).toBe(false); - const archived = (result as { project: { archivedAt: null | string; status: string } }).project; + const archived = (result as { project: { status: string } }).project; expect(archived.status).toBe("archived"); - expect(archived.archivedAt).not.toBeNull(); - const row = db.query("SELECT status, archived_at FROM projects WHERE id = ?").get(id) as { - archived_at: null | string; + const row = db.query("SELECT status FROM projects WHERE id = ?").get(id) as { status: string; }; expect(row.status).toBe("archived"); - expect(row.archived_at).not.toBeNull(); }); }); @@ -165,9 +162,8 @@ describe("项目数据访问层", () => { const result = restoreProject(db, id, createNoopLogger()); expect("error" in result).toBe(false); - const restored = (result as { project: { archivedAt: null | string; status: string } }).project; + const restored = (result as { project: { status: string } }).project; expect(restored.status).toBe("active"); - expect(restored.archivedAt).toBeNull(); }); }); diff --git a/tests/server/db/schema.test.ts b/tests/server/db/schema.test.ts new file mode 100644 index 0000000..156f6c3 --- /dev/null +++ b/tests/server/db/schema.test.ts @@ -0,0 +1,154 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; + +import type { TestDatabaseHandle } from "../../helpers"; + +import { createMigratedMemoryTestDatabase } from "../../helpers"; + +interface ForeignKey { + from: string; + id: number; + match: string; + on_delete: string; + on_update: string; + seq: number; + table: string; + to: string; +} + +interface IndexInfo { + cid: number; + name: string; + seq: number; +} + +interface IndexListEntry { + name: string; + origin: string; + partial: number; + seq: number; + unique: number; +} + +interface TableColumn { + cid: number; + dflt_value: null | string; + name: string; + notnull: number; + pk: number; + type: string; +} + +const BUSINESS_TABLES = ["conversations", "materials", "messages", "models", "projects", "providers"] as const; + +const CHECK_CONSTRAINTS: Record = { + materials: { column: "status", invalidValue: "'invalid_status'", validValue: "'pending'" }, + messages: { column: "role", invalidValue: "'invalid_role'", validValue: "'user'" }, + projects: { column: "status", invalidValue: "'invalid_status'", validValue: "'active'" }, + providers: { column: "type", invalidValue: "'invalid_type'", validValue: "'openai-compatible'" }, +} as const; + +const FK_INDEX_REQUIRED: Record = { + conversations: ["model_id", "project_id"], + materials: ["project_id"], + messages: ["conversation_id"], + models: ["provider_id"], +} as const; + +const TABLES_WITH_FK = Object.keys(FK_INDEX_REQUIRED); + +describe("schema 契约", () => { + let handle!: TestDatabaseHandle; + + beforeAll(() => { + handle = createMigratedMemoryTestDatabase("schema-contract"); + }); + + afterAll(() => { + handle.cleanup(); + }); + + describe("基础列(id / created_at / updated_at / deleted_at)", () => { + for (const table of BUSINESS_TABLES) { + test(`${table} 包含全部基础列且约束正确`, () => { + const columns = handle.db.query(`PRAGMA table_info(${table})`).all() as TableColumn[]; + const colMap = new Map(columns.map((c) => [c.name, c])); + + const idCol = colMap.get("id"); + expect(idCol, `${table} 缺少 id 列`).toBeDefined(); + expect(idCol!.pk, `${table}.id 必须为主键`).toBe(1); + + const createdAtCol = colMap.get("created_at"); + expect(createdAtCol, `${table} 缺少 created_at 列`).toBeDefined(); + expect(createdAtCol!.notnull, `${table}.created_at 必须 NOT NULL`).toBe(1); + + const updatedAtCol = colMap.get("updated_at"); + expect(updatedAtCol, `${table} 缺少 updated_at 列`).toBeDefined(); + expect(updatedAtCol!.notnull, `${table}.updated_at 必须 NOT NULL`).toBe(1); + + const deletedAtCol = colMap.get("deleted_at"); + expect(deletedAtCol, `${table} 缺少 deleted_at 列`).toBeDefined(); + expect(deletedAtCol!.notnull, `${table}.deleted_at 必须可空`).toBe(0); + expect(deletedAtCol!.pk, `${table}.deleted_at 不可为主键`).toBe(0); + }); + } + }); + + describe("外键索引", () => { + for (const table of TABLES_WITH_FK) { + const fkColumns = FK_INDEX_REQUIRED[table]!; + + test(`${table} 外键列 ${fkColumns.join(", ")} 均有索引`, () => { + const indexes = handle.db.query(`PRAGMA index_list(${table})`).all() as IndexListEntry[]; + + const indexedColumns = new Set(); + for (const idx of indexes) { + const idxCols = handle.db.query(`PRAGMA index_info(${idx.name})`).all() as IndexInfo[]; + for (const col of idxCols) { + indexedColumns.add(col.name); + } + } + + for (const fkCol of fkColumns) { + expect(indexedColumns, `${table}.${fkCol} 应有索引`).toContain(fkCol); + } + }); + } + }); + + describe("外键级联策略", () => { + for (const table of TABLES_WITH_FK) { + test(`${table} 所有外键使用 NO ACTION`, () => { + const fks = handle.db.query(`PRAGMA foreign_key_list(${table})`).all() as ForeignKey[]; + + expect(fks.length, `${table} 应至少有一个外键`).toBeGreaterThan(0); + + for (const fk of fks) { + expect( + fk.on_delete.toLowerCase(), + `${table}.${fk.from} → ${fk.table}.${fk.to} 应为 no action,实际为 ${fk.on_delete}`, + ).toBe("no action"); + } + }); + } + }); + + describe("CHECK 约束(枚举列)", () => { + for (const [table, spec] of Object.entries(CHECK_CONSTRAINTS)) { + test(`${table}.${spec.column} 拒绝非法枚举值`, () => { + handle.db.exec("SAVEPOINT check_test"); + try { + expect(() => { + handle.db + .query( + `INSERT INTO ${table} (id, ${spec.column}${table === "messages" ? ", conversation_id, created_at, updated_at" : table === "materials" ? ", project_id, associated_date, description, created_at, updated_at" : table === "providers" ? ", name, api_key, base_url, created_at, updated_at" : ", name, description, created_at, updated_at"}) VALUES ('__check_test__', ${spec.invalidValue}${table === "messages" ? ", 'conv-x', '2024-01-01', '2024-01-01'" : table === "materials" ? ", 'proj-x', '2024-01-01', '', '2024-01-01', '2024-01-01'" : table === "providers" ? ", 'p', '', '', '2024-01-01', '2024-01-01'" : ", 'pj', '', '2024-01-01', '2024-01-01'"})`, + ) + .run(); + }).toThrow(); + } finally { + handle.db.exec("ROLLBACK TO SAVEPOINT check_test"); + handle.db.exec("RELEASE SAVEPOINT check_test"); + } + }); + } + }); +}); diff --git a/tests/server/db/soft-delete.test.ts b/tests/server/db/soft-delete.test.ts new file mode 100644 index 0000000..3648864 --- /dev/null +++ b/tests/server/db/soft-delete.test.ts @@ -0,0 +1,241 @@ +import type Database from "bun:sqlite"; + +import { describe, expect, test } from "bun:test"; + +import { createConversation, createMessage, deleteConversation } from "../../../src/server/db/conversations"; +import { createMaterial, deleteMaterial, listMaterials } from "../../../src/server/db/materials"; +import { createModel, deleteModel, listModels } from "../../../src/server/db/models"; +import { createProject, deleteProject, listProjects, updateProject } from "../../../src/server/db/projects"; +import { createProvider, deleteProvider } from "../../../src/server/db/providers"; +import { createNoopLogger } from "../../../src/server/logger"; +import { createMigratedTestDatabase } from "../../helpers"; + +const log = createNoopLogger(); + +function withDb(callback: (db: Database) => void): void { + const handle = createMigratedTestDatabase("soft-delete-test"); + try { + callback(handle.db); + handle.close(); + } finally { + handle.cleanup(); + } +} + +describe("软删除与级联", () => { + describe("V1: 删除 project 级联软删 conversations + materials + messages", () => { + test("归档项目软删除后,关联会话/消息/素材均被软删", () => { + withDb((db) => { + const projectRes = createProject(db, { name: "P1" }, log); + const projectId = (projectRes as { project: { id: string } }).project.id; + + const providerRes = createProvider( + db, + { apiKey: "sk", baseUrl: "https://a.com", name: "Prov", type: "openai" }, + log, + ); + const modelRes = createModel( + db, + { + capabilities: ["text"], + externalId: "gpt-4", + name: "GPT", + providerId: (providerRes as { provider: { id: string } }).provider.id, + }, + log, + ); + const modelId = (modelRes as { model: { id: string } }).model.id; + + const convRes = createConversation(db, projectId, log, modelId); + const convId = (convRes as { conversation: { id: string } }).conversation.id; + createMessage(db, { content: "hi", conversationId: convId, role: "user" }, log); + + createMaterial(db, projectId, { associatedDate: "2024-01-01", description: "M1" }, log); + + db.exec("UPDATE projects SET status = 'archived' WHERE id = ?", [projectId]); + const del = deleteProject(db, projectId, log); + expect("error" in del).toBe(false); + + const projectRow = db.query("SELECT deleted_at FROM projects WHERE id = ?").get(projectId) as { + deleted_at: null | string; + }; + const convRow = db.query("SELECT deleted_at FROM conversations WHERE id = ?").get(convId) as { + deleted_at: null | string; + }; + const messageRow = db.query("SELECT deleted_at FROM messages WHERE conversation_id = ?").get(convId) as { + deleted_at: null | string; + }; + const materialRow = db.query("SELECT deleted_at FROM materials WHERE project_id = ?").get(projectId) as { + deleted_at: null | string; + }; + + expect(projectRow.deleted_at).not.toBeNull(); + expect(convRow.deleted_at).not.toBeNull(); + expect(messageRow.deleted_at).not.toBeNull(); + expect(materialRow.deleted_at).not.toBeNull(); + }); + }); + }); + + describe("V2: 删除 conversation 级联软删 messages", () => { + test("会话软删除后,其下消息均被软删", () => { + withDb((db) => { + const projectRes = createProject(db, { name: "P2" }, log); + const projectId = (projectRes as { project: { id: string } }).project.id; + + const providerRes = createProvider( + db, + { apiKey: "sk", baseUrl: "https://a.com", name: "Prov2", type: "openai" }, + log, + ); + const modelRes = createModel( + db, + { + capabilities: ["text"], + externalId: "claude", + name: "Claude", + providerId: (providerRes as { provider: { id: string } }).provider.id, + }, + log, + ); + const modelId = (modelRes as { model: { id: string } }).model.id; + + const convRes = createConversation(db, projectId, log, modelId); + const convId = (convRes as { conversation: { id: string } }).conversation.id; + createMessage(db, { content: "m1", conversationId: convId, role: "user" }, log); + createMessage(db, { content: "m2", conversationId: convId, role: "assistant" }, log); + + const del = deleteConversation(db, convId, log); + expect("error" in del).toBe(false); + + const messages = db.query("SELECT deleted_at FROM messages WHERE conversation_id = ?").all(convId) as Array<{ + deleted_at: null | string; + }>; + expect(messages.length).toBe(2); + expect(messages.every((m) => m.deleted_at !== null)).toBe(true); + }); + }); + }); + + describe("V3: paginateQuery softDelete 自动过滤已删除行", () => { + test("listProjects 自动排除软删除项目", () => { + withDb((db) => { + createProject(db, { name: "Alive1" }, log); + createProject(db, { name: "Alive2" }, log); + + const toDelete = createProject(db, { name: "Dying" }, log); + const dyingId = (toDelete as { project: { id: string } }).project.id; + db.exec("UPDATE projects SET status = 'archived' WHERE id = ?", [dyingId]); + deleteProject(db, dyingId, log); + + const result = listProjects(db, { page: 1, pageSize: 20 }); + expect(result.total).toBe(2); + expect(result.items.map((p) => p.name).sort()).toEqual(["Alive1", "Alive2"]); + }); + }); + + test("listMaterials 自动排除软删除素材", () => { + withDb((db) => { + const proj = createProject(db, { name: "PM" }, log); + const projectId = (proj as { project: { id: string } }).project.id; + + createMaterial(db, projectId, { associatedDate: "2024-01-01", description: "K1" }, log); + createMaterial(db, projectId, { associatedDate: "2024-01-02", description: "K2" }, log); + + const toDelete = createMaterial(db, projectId, { associatedDate: "2024-01-03", description: "K3" }, log); + const materialId = (toDelete as { material: { id: string } }).material.id; + deleteMaterial(db, projectId, materialId, log); + + const result = listMaterials(db, projectId, { page: 1, pageSize: 20 }); + expect(result.total).toBe(2); + expect(result.items.map((m) => m.description).sort()).toEqual(["K1", "K2"]); + }); + }); + + test("listModels 自动排除软删除模型", () => { + withDb((db) => { + const prov = createProvider(db, { apiKey: "sk", baseUrl: "https://x.com", name: "Px", type: "openai" }, log); + const providerId = (prov as { provider: { id: string } }).provider.id; + + createModel(db, { capabilities: ["text"], externalId: "a", name: "A", providerId }, log); + createModel(db, { capabilities: ["text"], externalId: "b", name: "B", providerId }, log); + + const dying = createModel(db, { capabilities: ["text"], externalId: "c", name: "C", providerId }, log); + deleteModel(db, (dying as { model: { id: string } }).model.id, log); + + const result = listModels(db, { page: 1, pageSize: 20 }); + expect(result.total).toBe(2); + expect(result.items.map((m) => m.name).sort()).toEqual(["A", "B"]); + }); + }); + }); + + describe("V4: 应用层唯一约束(软删后同名复活)", () => { + test("软删除项目后可以创建同名项目", () => { + withDb((db) => { + const first = createProject(db, { name: "SameName" }, log); + const firstId = (first as { project: { id: string } }).project.id; + + db.exec("UPDATE projects SET status = 'archived' WHERE id = ?", [firstId]); + deleteProject(db, firstId, log); + + const second = createProject(db, { name: "SameName" }, log); + expect("error" in second).toBe(false); + }); + }); + + test("未删除同名项目存在时创建失败(409)", () => { + withDb((db) => { + createProject(db, { name: "ClashName" }, log); + const result = createProject(db, { name: "ClashName" }, log); + expect("error" in result).toBe(true); + expect((result as unknown as { status: number }).status).toBe(409); + }); + }); + + test("更新项目名称不与自身冲突", () => { + withDb((db) => { + const created = createProject(db, { name: "SelfUpdate" }, log); + const id = (created as { project: { id: string } }).project.id; + const result = updateProject(db, id, { name: "SelfUpdate" }, log); + expect("error" in result).toBe(false); + }); + }); + }); + + describe("V5: 删除 provider 时阻止(存在未删除 model)", () => { + test("存在未删除 model 时删除 provider 返回错误", () => { + withDb((db) => { + const prov = createProvider( + db, + { apiKey: "sk", baseUrl: "https://p.com", name: "BlockProv", type: "openai" }, + log, + ); + const providerId = (prov as { provider: { id: string } }).provider.id; + + createModel(db, { capabilities: ["text"], externalId: "blocking-model", name: "BlockM", providerId }, log); + + const result = deleteProvider(db, providerId, log); + expect("error" in result).toBe(true); + expect((result as unknown as { status: number }).status).toBe(409); + }); + }); + + test("所有 model 已软删除后可以删除 provider", () => { + withDb((db) => { + const prov = createProvider( + db, + { apiKey: "sk", baseUrl: "https://p.com", name: "FreeProv", type: "openai" }, + log, + ); + const providerId = (prov as { provider: { id: string } }).provider.id; + + const m = createModel(db, { capabilities: ["text"], externalId: "free-model", name: "FreeM", providerId }, log); + deleteModel(db, (m as { model: { id: string } }).model.id, log); + + const result = deleteProvider(db, providerId, log); + expect("error" in result).toBe(false); + }); + }); + }); +}); diff --git a/tests/server/routes/chat.test.ts b/tests/server/routes/chat.test.ts index ee8ec5b..2513221 100644 --- a/tests/server/routes/chat.test.ts +++ b/tests/server/routes/chat.test.ts @@ -44,12 +44,12 @@ async function patchConversationViaHandler(req: Request, db: Database): Promise< return h(req, db, MODE, LOG); } -function seedModel(db: Database, providerId: string, modelName = "GPT-4o", modelId = "gpt-4o"): string { +function seedModel(db: Database, providerId: string, modelName = "GPT-4o", externalId = "gpt-4o"): string { const result = createModel( db, { capabilities: ["text"], - modelId, + externalId, name: modelName, providerId, }, @@ -111,7 +111,7 @@ describe("聊天 API 路由", () => { } }); - test("无可用模型时返回 400", async () => { + test("无可用模型时创建会话 modelId 为 null", async () => { const handle = createMigratedMemoryTestDatabase("chat-create-no-model"); try { const db = handle.db; @@ -123,9 +123,9 @@ describe("聊天 API 路由", () => { method: "POST", }); const res = await createConversationViaHandler(req, db); - expect(res.status).toBe(400); - const body = (await res.json()) as { error: string }; - expect(body.error).toContain("模型"); + expect(res.status).toBe(201); + const body = (await res.json()) as { conversation: Conversation }; + expect(body.conversation.modelId).toBeNull(); handle.close(); } finally { handle.cleanup(); diff --git a/tests/server/routes/models.test.ts b/tests/server/routes/models.test.ts index f587b7f..40150da 100644 --- a/tests/server/routes/models.test.ts +++ b/tests/server/routes/models.test.ts @@ -22,7 +22,7 @@ function createTestModel(db: Database, pName: string, providerId?: string): Mode db, { capabilities: ["text"], - modelId: pName.toLowerCase().replace(/[^a-z0-9-]/g, "-"), + externalId: pName.toLowerCase().replace(/[^a-z0-9-]/g, "-"), name: pName, providerId: pid, }, @@ -93,7 +93,7 @@ describe("models API routes", () => { const req = new Request("http://localhost/api/models", { body: JSON.stringify({ capabilities: ["text", "reasoning"], - modelId: "gpt-4o", + externalId: "gpt-4o", name: "GPT-4o", providerId, }), @@ -104,7 +104,7 @@ describe("models API routes", () => { expect(res.status).toBe(201); const body = (await res.json()) as { model: Model }; expect(body.model.name).toBe("GPT-4o"); - expect(body.model.modelId).toBe("gpt-4o"); + expect(body.model.externalId).toBe("gpt-4o"); }); }); @@ -142,10 +142,10 @@ describe("models API routes", () => { test("GET /api/models filter by capabilities", async () => { await withRouteDb(async (db) => { const p = seedProvider(db, "CapP"); - createModel(db, { capabilities: ["text"], modelId: "text-1", name: "TextModel", providerId: p }, LOG); + createModel(db, { capabilities: ["text"], externalId: "text-1", name: "TextModel", providerId: p }, LOG); createModel( db, - { capabilities: ["reasoning"], modelId: "reasoning-1", name: "ReasoningModel", providerId: p }, + { capabilities: ["reasoning"], externalId: "reasoning-1", name: "ReasoningModel", providerId: p }, LOG, ); @@ -228,7 +228,7 @@ describe("models API routes", () => { const req = new Request("http://localhost/api/models", { body: JSON.stringify({ capabilities: ["invalid-cap"], - modelId: "test", + externalId: "test", name: "Test", providerId, }), @@ -248,7 +248,7 @@ describe("models API routes", () => { body: JSON.stringify({ capabilities: ["text"], contextLength: 0, - modelId: "test", + externalId: "test", name: "Test", providerId, }), @@ -274,7 +274,7 @@ describe("models API routes", () => { const providerId = seedProvider(db); const req = new Request("http://localhost/api/models/test", { - body: JSON.stringify({ modelId: "gpt-4o", providerId }), + body: JSON.stringify({ externalId: "gpt-4o", providerId }), headers: { "Content-Type": "application/json" }, method: "POST", }); @@ -289,7 +289,7 @@ describe("models API routes", () => { test("POST /api/models/test 缺少 providerId 返回 400", async () => { await withRouteDb(async (db) => { const req = new Request("http://localhost/api/models/test", { - body: JSON.stringify({ modelId: "gpt-4o" }), + body: JSON.stringify({ externalId: "gpt-4o" }), headers: { "Content-Type": "application/json" }, method: "POST", }); @@ -301,7 +301,7 @@ describe("models API routes", () => { test("POST /api/models/test 不存在的供应商返回 404", async () => { await withRouteDb(async (db) => { const req = new Request("http://localhost/api/models/test", { - body: JSON.stringify({ modelId: "gpt-4o", providerId: "nonexistent" }), + body: JSON.stringify({ externalId: "gpt-4o", providerId: "nonexistent" }), headers: { "Content-Type": "application/json" }, method: "POST", }); diff --git a/tests/server/routes/providers.test.ts b/tests/server/routes/providers.test.ts index d21131b..ef79f73 100644 --- a/tests/server/routes/providers.test.ts +++ b/tests/server/routes/providers.test.ts @@ -236,7 +236,7 @@ describe("供应商 API 路由", () => { db, { capabilities: ["text"], - modelId: "gpt-4o", + externalId: "gpt-4o", name: "GPT-4o", providerId: provider.id, }, diff --git a/tests/web/components/ChatPage.test.tsx b/tests/web/components/ChatPage.test.tsx index 80ca9b7..235eb8d 100644 --- a/tests/web/components/ChatPage.test.tsx +++ b/tests/web/components/ChatPage.test.tsx @@ -11,7 +11,6 @@ import { installFetchMock, jsonResponse, renderWithProviders } from "../test-uti const PROJECT_ID = "proj-1"; const MOCK_PROJECT: Project = { - archivedAt: null, createdAt: "2026-01-01T00:00:00.000Z", description: "", id: PROJECT_ID, @@ -24,9 +23,9 @@ const TEXT_MODEL: Model = { capabilities: ["text"], contextLength: null, createdAt: "2024-01-01T00:00:00.000Z", + externalId: "gpt-4o", id: "model-1", maxOutputTokens: null, - modelId: "gpt-4o", name: "GPT-4o", providerId: "pv1", updatedAt: "2024-01-01T00:00:00.000Z", diff --git a/tests/web/components/ChatPanel.test.tsx b/tests/web/components/ChatPanel.test.tsx index d44942f..b377c6f 100644 --- a/tests/web/components/ChatPanel.test.tsx +++ b/tests/web/components/ChatPanel.test.tsx @@ -13,9 +13,9 @@ const TEXT_MODEL: Model = { capabilities: ["text"], contextLength: null, createdAt: "2024-01-01T00:00:00.000Z", + externalId: "gpt-4o", id: "model-1", maxOutputTokens: null, - modelId: "gpt-4o", name: "GPT-4o", providerId: "pv1", updatedAt: "2024-01-01T00:00:00.000Z", diff --git a/tests/web/components/ResourceTable.test.tsx b/tests/web/components/ResourceTable.test.tsx index a959855..71804e6 100644 --- a/tests/web/components/ResourceTable.test.tsx +++ b/tests/web/components/ResourceTable.test.tsx @@ -24,9 +24,9 @@ const ENABLED_MODEL: Model = { capabilities: ["text", "reasoning"], contextLength: 128000, createdAt: "2024-01-01T00:00:00.000Z", + externalId: "gpt-4o", id: "m1", maxOutputTokens: 4096, - modelId: "gpt-4o", name: "GPT-4o", providerId: "pv1", updatedAt: "2024-01-01T00:00:00.000Z", @@ -36,9 +36,9 @@ const DISABLED_MODEL: Model = { capabilities: ["text"], contextLength: null, createdAt: "2024-01-01T00:00:00.000Z", + externalId: "deepseek-chat", id: "m2", maxOutputTokens: null, - modelId: "deepseek-chat", name: "DeepSeek Chat", providerId: "pv2", updatedAt: "2024-01-01T00:00:00.000Z", diff --git a/tests/web/features/inbox/InboxPage.test.tsx b/tests/web/features/inbox/InboxPage.test.tsx index 8ca3244..30a72f7 100644 --- a/tests/web/features/inbox/InboxPage.test.tsx +++ b/tests/web/features/inbox/InboxPage.test.tsx @@ -9,7 +9,6 @@ import { ProjectContext } from "../../../../src/web/shared/hooks/use-current-pro import { installFetchMock, jsonResponse, renderWithProviders } from "../../test-utils"; const MOCK_PROJECT: Project = { - archivedAt: null, createdAt: "2026-01-01T00:00:00.000Z", description: "", id: "project-1", diff --git a/tests/web/hooks/use-models.test.ts b/tests/web/hooks/use-models.test.ts index c6a17b4..32b0a89 100644 --- a/tests/web/hooks/use-models.test.ts +++ b/tests/web/hooks/use-models.test.ts @@ -14,9 +14,9 @@ const MODEL = { capabilities: ["text"] as Array<"text">, contextLength: null, createdAt: "2024-01-01T00:00:00.000Z", + externalId: "gpt-4o", id: "m1", maxOutputTokens: null, - modelId: "gpt-4o", name: "GPT-4o", providerId: "pv1", updatedAt: "2024-01-01T00:00:00.000Z", @@ -59,7 +59,7 @@ describe("use-models request helpers", () => { await createModel({ capabilities: ["text"], - modelId: "gpt-4o", + externalId: "gpt-4o", name: "GPT-4o", providerId: "pv1", }); @@ -75,7 +75,7 @@ describe("use-models request helpers", () => { ]); expect(jsonBody(calls[0]?.body)).toEqual({ capabilities: ["text"], - modelId: "gpt-4o", + externalId: "gpt-4o", name: "GPT-4o", providerId: "pv1", }); @@ -86,7 +86,7 @@ describe("use-models request helpers", () => { installFetchMock(() => jsonResponse({ error: "模型名称已存在" }, { status: 409 })); await expectRejectsWithMessage( - () => createModel({ capabilities: ["text"], modelId: "gpt-4o", name: "重复", providerId: "pv1" }), + () => createModel({ capabilities: ["text"], externalId: "gpt-4o", name: "重复", providerId: "pv1" }), "模型名称已存在", ); }); @@ -100,12 +100,12 @@ describe("use-models request helpers", () => { test("testModelConnection 调用正确 URL 和 body", async () => { const calls = installFetchMock(() => jsonResponse({ modelTestResponse: { message: "模型连接成功", ok: true } })); - const result = await testModelConnection({ modelId: "gpt-4o", providerId: "pv1" }); + const result = await testModelConnection({ externalId: "gpt-4o", providerId: "pv1" }); expect(result.ok).toBe(true); expect(result.message).toBe("模型连接成功"); expect(calls[0]?.method).toBe("POST"); expect(calls[0]?.url).toBe("/api/models/test"); - expect(jsonBody(calls[0]?.body)).toEqual({ modelId: "gpt-4o", providerId: "pv1" }); + expect(jsonBody(calls[0]?.body)).toEqual({ externalId: "gpt-4o", providerId: "pv1" }); }); }); diff --git a/tests/web/hooks/use-projects.test.ts b/tests/web/hooks/use-projects.test.ts index 2f428c8..cd247e3 100644 --- a/tests/web/hooks/use-projects.test.ts +++ b/tests/web/hooks/use-projects.test.ts @@ -12,7 +12,6 @@ import { import { installFetchMock, jsonResponse } from "../test-utils"; const PROJECT = { - archivedAt: null, createdAt: "2024-01-01T00:00:00.000Z", description: "描述", id: "p1", diff --git a/tests/web/routes/models.test.tsx b/tests/web/routes/models.test.tsx index 0049367..fa3f9ad 100644 --- a/tests/web/routes/models.test.tsx +++ b/tests/web/routes/models.test.tsx @@ -32,9 +32,9 @@ const ENABLED_MODEL: Model = { capabilities: ["text", "reasoning"], contextLength: 128000, createdAt: "2024-01-01T00:00:00.000Z", + externalId: "gpt-4o", id: "m1", maxOutputTokens: 4096, - modelId: "gpt-4o", name: "GPT-4o", providerId: "pv1", updatedAt: "2024-01-01T00:00:00.000Z", @@ -190,7 +190,7 @@ describe("ModelFormModal", () => { await waitFor(() => expect(testModelConnection).toHaveBeenCalledWith({ - modelId: "gpt-4o", + externalId: "gpt-4o", providerId: "pv1", }), ); @@ -231,9 +231,9 @@ const TEST_MODEL: Model = { capabilities: ["text"], contextLength: 128000, createdAt: "2024-01-01T00:00:00.000Z", + externalId: "gpt-4o", id: "m1", maxOutputTokens: 4096, - modelId: "gpt-4o", name: "GPT-4o", providerId: "pv1", updatedAt: "2024-01-01T00:00:00.000Z", @@ -281,7 +281,7 @@ function createModelFetchMock() { if (url.pathname === "/api/models" && call.method === "GET") { const keyword = url.searchParams.get("keyword") ?? ""; - const items = keyword ? models.filter((m) => `${m.name}${m.modelId}`.includes(keyword)) : models; + const items = keyword ? models.filter((m) => `${m.name}${m.externalId}`.includes(keyword)) : models; return jsonResponse({ items, page: 1, pageSize: 20, total: items.length }); } diff --git a/tests/web/routes/projects.test.tsx b/tests/web/routes/projects.test.tsx index 96049e2..ca9ce3d 100644 --- a/tests/web/routes/projects.test.tsx +++ b/tests/web/routes/projects.test.tsx @@ -11,7 +11,6 @@ import { ProjectTable } from "../../../src/web/features/projects/components/Proj import { installFetchMock, jsonResponse, mockMetaResponse, renderWithProviders } from "../test-utils"; const ACTIVE_PROJECT: Project = { - archivedAt: null, createdAt: "2024-01-01T00:00:00.000Z", description: "活跃描述", id: "p1", @@ -21,7 +20,6 @@ const ACTIVE_PROJECT: Project = { }; const ARCHIVED_PROJECT: Project = { - archivedAt: "2024-01-02T00:00:00.000Z", createdAt: "2024-01-01T00:00:00.000Z", description: "归档描述", id: "p2", @@ -57,7 +55,6 @@ function createProjectFetchMock(initialProjects: Project[] = [ACTIVE_PROJECT, AR if (url.pathname === "/api/projects" && call.method === "POST") { const data = jsonBody(call.body) as { description?: string; name: string }; const created: Project = { - archivedAt: null, createdAt: "2024-01-03T00:00:00.000Z", description: data.description ?? "", id: "p-created", @@ -83,13 +80,13 @@ function createProjectFetchMock(initialProjects: Project[] = [ACTIVE_PROJECT, AR } if (call.method === "POST" && action === "archive") { - const archived = { ...project, archivedAt: "2024-01-04T00:00:00.000Z", status: "archived" as const }; + const archived = { ...project, status: "archived" as const }; projects = projects.map((item) => (item.id === id ? archived : item)); return jsonResponse({ project: archived }); } if (call.method === "POST" && action === "restore") { - const restored = { ...project, archivedAt: null, status: "active" as const }; + const restored = { ...project, status: "active" as const }; projects = projects.map((item) => (item.id === id ? restored : item)); return jsonResponse({ project: restored }); } diff --git a/tests/web/routes/workbench.test.tsx b/tests/web/routes/workbench.test.tsx index 6810d3d..3b64dbf 100644 --- a/tests/web/routes/workbench.test.tsx +++ b/tests/web/routes/workbench.test.tsx @@ -6,7 +6,6 @@ import { App } from "../../../src/web/app"; import { renderWithProviders } from "../test-utils"; const MOCK_PROJECT = { - archivedAt: null, createdAt: "2024-01-01T00:00:00.000Z", description: "测试项目", id: "test-project-id", @@ -15,7 +14,7 @@ const MOCK_PROJECT = { updatedAt: "2024-01-01T00:00:00.000Z", }; -function createMockHandler(overrides?: { archivedAt?: string; status?: "active" | "archived" }) { +function createMockHandler(overrides?: { status?: "active" | "archived" }) { const project = { ...MOCK_PROJECT, ...overrides }; const handler = (input: RequestInfo | URL) => { const url = input instanceof Request ? input.url : typeof input === "string" ? input : input.toString(); @@ -92,7 +91,7 @@ describe("Workbench 路由", () => { }); test("archived 项目显示不可访问", async () => { - createMockHandler({ archivedAt: "2024-06-01T00:00:00.000Z", status: "archived" }); + createMockHandler({ status: "archived" }); renderWithProviders(createElement(App), { initialRoute: `/workbench/${MOCK_PROJECT.id}`,