refactor(db): 统一数据库 schema — 软删除、命名规范、约束标准化
- 全表新增 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 重命名及类型变更
This commit is contained in:
@@ -217,6 +217,9 @@ features/<name>/
|
|||||||
- 输入输出类型来自 `src/shared/api.ts`。
|
- 输入输出类型来自 `src/shared/api.ts`。
|
||||||
- 列表查询使用 `paginateQuery()`,不重复实现分页。
|
- 列表查询使用 `paginateQuery()`,不重复实现分页。
|
||||||
- 列名 snake_case,TS 类型 camelCase,Drizzle schema 映射。
|
- 列名 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 调用层
|
### AI 调用层
|
||||||
|
|
||||||
|
|||||||
@@ -20,9 +20,14 @@
|
|||||||
|
|
||||||
SQLite + bun:sqlite + Drizzle ORM。
|
SQLite + bun:sqlite + Drizzle ORM。
|
||||||
|
|
||||||
- `src/server/db/schema.ts`:Drizzle 表结构,列名 snake_case,TS 类型 camelCase。
|
- `src/server/db/schema.ts`:Drizzle 表结构,列名 snake_case,TS 类型 camelCase。所有业务表通过 `helpers.ts` 的 `baseColumns` 获取 id/created_at/updated_at/deleted_at。
|
||||||
- `src/server/db/connection.ts`:`createDatabase(dataDir, logger)` 打开 `alfred.db`,PRAGMA:foreign_keys=ON、journal_mode=WAL、busy_timeout=5000。`wrap(db)` 转为 Drizzle 实例。`paginateQuery()` 分页工具。
|
- `src/server/db/helpers.ts`:`baseColumns` 常量(id、createdAt、updatedAt、deletedAt)+ Drizzle 构建器再导出。`src/server/db/` 内禁止直接从 `drizzle-orm/sqlite-core` 导入 `sqliteTable`(ESLint 强制)。
|
||||||
- Migration:开发期 `drizzle-kit generate` 产出到 `drizzle/`;生产期嵌入可执行文件,启动时自动应用。备份到 `<dataDir>/backups/`,事务中执行,失败回滚。
|
- `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/`;生产期嵌入可执行文件,启动时自动应用。备份到 `<dataDir>/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 服务层
|
## 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`:
|
- `src/server/ai/registry.ts`:
|
||||||
- `buildProviderRegistry(db)` — 从 DB 查询供应商构建 AI SDK Provider Registry,每次调用重建,不缓存。通过 `registry.languageModel('providerId:modelId')` 获取模型实例。
|
- `buildProviderRegistry(db)` — 从 DB 查询供应商构建 AI SDK Provider Registry,每次调用重建,不缓存。通过 `registry.languageModel('providerId:modelId')` 获取模型实例。
|
||||||
- `testProviderConnection(config, logger)` — 测试 Base URL 可达性 + `/models` 接口
|
- `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/providers/test` — 用未保存配置测试,不写入 DB,不阻止保存。Base URL 不可达或 API Key 无效返回 `ok: false`;`/models` 不支持返回 `ok: true` + 提示。
|
||||||
- `POST /api/models/test` — 用模型关联供应商 + modelId 测试。
|
- `POST /api/models/test` — 用模型关联供应商 + externalId 测试。
|
||||||
|
|
||||||
## 素材 API
|
## 素材 API
|
||||||
|
|
||||||
@@ -66,9 +71,9 @@ SQLite + bun:sqlite + Drizzle ORM。
|
|||||||
| GET | `/api/projects/:id/materials` | 列出项目下素材(分页) |
|
| GET | `/api/projects/:id/materials` | 列出项目下素材(分页) |
|
||||||
| POST | `/api/projects/:id/materials` | 创建素材 |
|
| POST | `/api/projects/:id/materials` | 创建素材 |
|
||||||
| GET | `/api/projects/:id/materials/:mid` | 获取素材详情 |
|
| 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
|
## 聊天 API
|
||||||
|
|
||||||
|
|||||||
109
drizzle/0004_db_schema_standardization.sql
Normal file
109
drizzle/0004_db_schema_standardization.sql
Normal file
@@ -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`);
|
||||||
530
drizzle/meta/0004_snapshot.json
Normal file
530
drizzle/meta/0004_snapshot.json
Normal file
@@ -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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,13 @@
|
|||||||
"when": 1780463734721,
|
"when": 1780463734721,
|
||||||
"tag": "0003_lying_cassandra_nova",
|
"tag": "0003_lying_cassandra_nova",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 4,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1780587528226,
|
||||||
|
"tag": "0004_db_schema_standardization",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,6 +95,25 @@ export default tseslint.config(
|
|||||||
"import/no-named-as-default-member": "off",
|
"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"],
|
files: ["src/server/**/*.ts"],
|
||||||
ignores: ["src/server/logger.ts"],
|
ignores: ["src/server/logger.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 type { SQLiteTable } from "drizzle-orm/sqlite-core";
|
||||||
|
|
||||||
import Database from "bun:sqlite";
|
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 { drizzle } from "drizzle-orm/bun-sqlite";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
@@ -10,6 +10,8 @@ import type { Logger } from "../logger";
|
|||||||
|
|
||||||
const DB_FILENAME = "alfred.db";
|
const DB_FILENAME = "alfred.db";
|
||||||
|
|
||||||
|
export type DrizzleDB = ReturnType<typeof wrap>;
|
||||||
|
|
||||||
export interface PaginateResult<T> {
|
export interface PaginateResult<T> {
|
||||||
items: T[];
|
items: T[];
|
||||||
page: number;
|
page: number;
|
||||||
@@ -30,6 +32,10 @@ export function createDatabase(dataDir: string, logger: Logger): Database {
|
|||||||
return db;
|
return db;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function notDeleted(table: { deletedAt: Column }): SQL {
|
||||||
|
return isNull(table.deletedAt);
|
||||||
|
}
|
||||||
|
|
||||||
export function paginateQuery<T extends SQLiteTable, R>(
|
export function paginateQuery<T extends SQLiteTable, R>(
|
||||||
raw: Database,
|
raw: Database,
|
||||||
table: T,
|
table: T,
|
||||||
@@ -39,11 +45,16 @@ export function paginateQuery<T extends SQLiteTable, R>(
|
|||||||
orderBy?: (table: T) => SQL | undefined;
|
orderBy?: (table: T) => SQL | undefined;
|
||||||
page: number;
|
page: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
|
softDelete?: Column;
|
||||||
},
|
},
|
||||||
): PaginateResult<R> {
|
): PaginateResult<R> {
|
||||||
const db = wrap(raw);
|
const db = wrap(raw);
|
||||||
const where = options.conditions?.filter((c): c is SQL => c !== undefined);
|
const conditions = [...(options.conditions ?? [])];
|
||||||
const whereClause = where && where.length > 0 ? and(...where) : undefined;
|
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
|
const countResult = db
|
||||||
.select({ count: sql<number>`count(*)` })
|
.select({ count: sql<number>`count(*)` })
|
||||||
@@ -70,6 +81,24 @@ export function paginateQuery<T extends SQLiteTable, R>(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function softDeleteRecord<T extends SQLiteTable>(
|
||||||
|
db: DrizzleDB,
|
||||||
|
table: T,
|
||||||
|
id: string,
|
||||||
|
): T["$inferSelect"] | undefined {
|
||||||
|
const now = timestamp();
|
||||||
|
return db
|
||||||
|
.update(table)
|
||||||
|
.set({ deletedAt: now, updatedAt: now } as Partial<T["$inferInsert"]>)
|
||||||
|
.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) {
|
export function wrap(raw: Database) {
|
||||||
return drizzle(raw);
|
return drizzle(raw);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,36 @@
|
|||||||
import type Database from "bun:sqlite";
|
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 { Conversation, Message, UpdateConversationRequest } from "../../shared/api";
|
||||||
import type { Logger } from "../logger";
|
import type { Logger } from "../logger";
|
||||||
|
|
||||||
import { paginateQuery, wrap } from "./connection";
|
import { notDeleted, paginateQuery, timestamp, wrap } from "./connection";
|
||||||
import { conversations, messages, models } from "./schema";
|
import { conversations, messages, models } from "./schema";
|
||||||
|
|
||||||
export function createConversation(
|
export function createConversation(
|
||||||
raw: Database,
|
raw: Database,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
logger: Logger,
|
_logger: Logger,
|
||||||
defaultModelId?: string,
|
defaultModelId?: string,
|
||||||
): { conversation: Conversation } | { error: string; status: number } {
|
): { conversation: Conversation } | { error: string; status: number } {
|
||||||
const db = wrap(raw);
|
const db = wrap(raw);
|
||||||
|
|
||||||
let modelId = defaultModelId;
|
let modelId: null | string = defaultModelId ?? null;
|
||||||
if (!modelId) {
|
if (defaultModelId) {
|
||||||
const firstModel = db.select().from(models).limit(1).get();
|
const model = db
|
||||||
if (!firstModel) return { error: "没有可用的模型,请先配置模型", status: 400 };
|
.select()
|
||||||
modelId = firstModel.id;
|
.from(models)
|
||||||
} else {
|
.where(and(eq(models.id, defaultModelId), notDeleted(models)))
|
||||||
const model = db.select().from(models).where(eq(models.id, modelId)).get();
|
.get();
|
||||||
if (!model) return { error: "模型不存在", status: 400 };
|
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 id = crypto.randomUUID();
|
||||||
const now = new Date().toISOString();
|
const now = timestamp();
|
||||||
|
|
||||||
db.insert(conversations)
|
db.insert(conversations)
|
||||||
.values({
|
.values({
|
||||||
@@ -56,7 +59,7 @@ export function createMessage(
|
|||||||
): Message {
|
): Message {
|
||||||
const db = wrap(raw);
|
const db = wrap(raw);
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
const now = new Date().toISOString();
|
const now = timestamp();
|
||||||
|
|
||||||
db.insert(messages)
|
db.insert(messages)
|
||||||
.values({
|
.values({
|
||||||
@@ -66,6 +69,7 @@ export function createMessage(
|
|||||||
id,
|
id,
|
||||||
parts: data.parts ?? null,
|
parts: data.parts ?? null,
|
||||||
role: data.role,
|
role: data.role,
|
||||||
|
updatedAt: now,
|
||||||
})
|
})
|
||||||
.run();
|
.run();
|
||||||
|
|
||||||
@@ -84,7 +88,7 @@ export function createMessages(
|
|||||||
_logger: Logger,
|
_logger: Logger,
|
||||||
): Message[] {
|
): Message[] {
|
||||||
const db = wrap(raw);
|
const db = wrap(raw);
|
||||||
const now = new Date().toISOString();
|
const now = timestamp();
|
||||||
const results: Message[] = [];
|
const results: Message[] = [];
|
||||||
|
|
||||||
for (const item of data) {
|
for (const item of data) {
|
||||||
@@ -97,6 +101,7 @@ export function createMessages(
|
|||||||
id,
|
id,
|
||||||
parts: item.parts ?? null,
|
parts: item.parts ?? null,
|
||||||
role: item.role,
|
role: item.role,
|
||||||
|
updatedAt: now,
|
||||||
})
|
})
|
||||||
.run();
|
.run();
|
||||||
const row = db.select().from(messages).where(eq(messages.id, id)).get();
|
const row = db.select().from(messages).where(eq(messages.id, id)).get();
|
||||||
@@ -112,11 +117,23 @@ export function deleteConversation(
|
|||||||
_logger: Logger,
|
_logger: Logger,
|
||||||
): { error: string; status: number } | { success: true } {
|
): { error: string; status: number } | { success: true } {
|
||||||
const db = wrap(raw);
|
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 };
|
if (!existing) return { error: "会话不存在", status: 404 };
|
||||||
|
|
||||||
db.delete(messages).where(eq(messages.conversationId, id)).run();
|
const now = timestamp();
|
||||||
db.delete(conversations).where(eq(conversations.id, id)).run();
|
|
||||||
|
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 };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,7 +142,11 @@ export function getConversation(
|
|||||||
id: string,
|
id: string,
|
||||||
): { conversation: Conversation } | { error: string; status: number } {
|
): { conversation: Conversation } | { error: string; status: number } {
|
||||||
const db = wrap(raw);
|
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 };
|
if (!row) return { error: "会话不存在", status: 404 };
|
||||||
return { conversation: toConversation(row) };
|
return { conversation: toConversation(row) };
|
||||||
}
|
}
|
||||||
@@ -141,6 +162,7 @@ export function listConversations(
|
|||||||
orderBy: () => desc(conversations.updatedAt),
|
orderBy: () => desc(conversations.updatedAt),
|
||||||
page: options.page,
|
page: options.page,
|
||||||
pageSize: options.pageSize,
|
pageSize: options.pageSize,
|
||||||
|
softDelete: conversations.deletedAt,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,6 +177,7 @@ export function listMessages(
|
|||||||
orderBy: () => desc(messages.createdAt),
|
orderBy: () => desc(messages.createdAt),
|
||||||
page: options.page,
|
page: options.page,
|
||||||
pageSize: options.pageSize,
|
pageSize: options.pageSize,
|
||||||
|
softDelete: messages.deletedAt,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,13 +188,21 @@ export function updateConversation(
|
|||||||
_logger: Logger,
|
_logger: Logger,
|
||||||
): { conversation: Conversation } | { error: string; status: number } {
|
): { conversation: Conversation } | { error: string; status: number } {
|
||||||
const db = wrap(raw);
|
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 };
|
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) {
|
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 };
|
if (!model) return { error: "模型不存在", status: 400 };
|
||||||
updates.modelId = data.modelId;
|
updates.modelId = data.modelId;
|
||||||
}
|
}
|
||||||
@@ -188,7 +219,7 @@ export function updateConversation(
|
|||||||
|
|
||||||
export function updateConversationTimestamp(raw: Database, id: string): void {
|
export function updateConversationTimestamp(raw: Database, id: string): void {
|
||||||
const db = wrap(raw);
|
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 {
|
function toConversation(row: typeof conversations.$inferSelect): Conversation {
|
||||||
@@ -210,5 +241,6 @@ function toMessage(row: typeof messages.$inferSelect): Message {
|
|||||||
id: row.id,
|
id: row.id,
|
||||||
parts: row.parts,
|
parts: row.parts,
|
||||||
role: row.role,
|
role: row.role,
|
||||||
|
updatedAt: row.updatedAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
12
src/server/db/helpers.ts
Normal file
12
src/server/db/helpers.ts
Normal file
@@ -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(),
|
||||||
|
};
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import type Database from "bun:sqlite";
|
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 { CreateMaterialRequest, Material, MaterialStatus } from "../../shared/api";
|
||||||
import type { Logger } from "../logger";
|
import type { Logger } from "../logger";
|
||||||
|
|
||||||
import { paginateQuery, wrap } from "./connection";
|
import { notDeleted, paginateQuery, softDeleteRecord, timestamp, wrap } from "./connection";
|
||||||
import { materials, projects } from "./schema";
|
import { materials, projects } from "./schema";
|
||||||
|
|
||||||
export function createMaterial(
|
export function createMaterial(
|
||||||
@@ -15,7 +15,11 @@ export function createMaterial(
|
|||||||
_logger: Logger,
|
_logger: Logger,
|
||||||
): { error: string; status: number } | { material: Material } {
|
): { error: string; status: number } | { material: Material } {
|
||||||
const db = wrap(raw);
|
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) return { error: "项目不存在", status: 404 };
|
||||||
if (project.status === "archived") return { error: "已归档项目不可操作", status: 409 };
|
if (project.status === "archived") return { error: "已归档项目不可操作", status: 409 };
|
||||||
|
|
||||||
@@ -28,7 +32,7 @@ export function createMaterial(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
const now = new Date().toISOString();
|
const now = timestamp();
|
||||||
|
|
||||||
db.insert(materials)
|
db.insert(materials)
|
||||||
.values({
|
.values({
|
||||||
@@ -53,11 +57,15 @@ export function deleteMaterial(
|
|||||||
_logger: Logger,
|
_logger: Logger,
|
||||||
): { error: string; status: number } | { success: true } {
|
): { error: string; status: number } | { success: true } {
|
||||||
const db = wrap(raw);
|
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) return { error: "素材不存在", status: 404 };
|
||||||
if (row.projectId !== projectId) return { error: "素材不属于该项目", status: 403 };
|
if (row.projectId !== projectId) return { error: "素材不属于该项目", status: 403 };
|
||||||
|
|
||||||
db.delete(materials).where(eq(materials.id, materialId)).run();
|
softDeleteRecord(db, materials, materialId);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +75,11 @@ export function getMaterial(
|
|||||||
materialId: string,
|
materialId: string,
|
||||||
): { error: string; status: number } | { material: Material } {
|
): { error: string; status: number } | { material: Material } {
|
||||||
const db = wrap(raw);
|
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) return { error: "素材不存在", status: 404 };
|
||||||
if (row.projectId !== projectId) return { error: "素材不属于该项目", status: 403 };
|
if (row.projectId !== projectId) return { error: "素材不属于该项目", status: 403 };
|
||||||
|
|
||||||
@@ -91,6 +103,7 @@ export function listMaterials(
|
|||||||
orderBy: () => desc(materials.createdAt),
|
orderBy: () => desc(materials.createdAt),
|
||||||
page: options.page,
|
page: options.page,
|
||||||
pageSize: options.pageSize,
|
pageSize: options.pageSize,
|
||||||
|
softDelete: materials.deletedAt,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 (?, ?, ?)");
|
const insertApplied = db.prepare("INSERT INTO schema_migrations (id, checksum, applied_at) VALUES (?, ?, ?)");
|
||||||
|
|
||||||
db.transaction(() => {
|
db.exec("PRAGMA foreign_keys = OFF");
|
||||||
for (const migration of pending) {
|
try {
|
||||||
try {
|
db.transaction(() => {
|
||||||
logger.info({ id: migration.id }, "执行 migration");
|
for (const migration of pending) {
|
||||||
db.exec(migration.sql);
|
try {
|
||||||
insertApplied.run(migration.id, migration.checksum, new Date().toISOString());
|
logger.info({ id: migration.id }, "执行 migration");
|
||||||
} catch (e: unknown) {
|
db.exec(migration.sql);
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
insertApplied.run(migration.id, migration.checksum, new Date().toISOString());
|
||||||
logger.error({ error: msg, id: migration.id }, "migration 执行失败");
|
} catch (e: unknown) {
|
||||||
throw e;
|
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 全部执行完成");
|
logger.info({ count: pending.length }, "migration 全部执行完成");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,59 +1,61 @@
|
|||||||
import type Database from "bun:sqlite";
|
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 { CreateModelRequest, Model, ModelCapability, SortOrder, UpdateModelRequest } from "../../shared/api";
|
||||||
import type { Logger } from "../logger";
|
import type { Logger } from "../logger";
|
||||||
|
|
||||||
import { paginateQuery, wrap } from "./connection";
|
import { notDeleted, paginateQuery, softDeleteRecord, timestamp, wrap } from "./connection";
|
||||||
import { models, providers } from "./schema";
|
import { models, providers } from "./schema";
|
||||||
|
|
||||||
export function createModel(
|
export function createModel(
|
||||||
raw: Database,
|
raw: Database,
|
||||||
request: CreateModelRequest,
|
request: CreateModelRequest,
|
||||||
logger: Logger,
|
_logger: Logger,
|
||||||
): { error: string; status: number } | { model: Model } {
|
): { error: string; status: number } | { model: Model } {
|
||||||
const db = wrap(raw);
|
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 };
|
if (!provider) return { error: "供应商不存在", status: 400 };
|
||||||
|
|
||||||
const name = request.name.trim();
|
const name = request.name.trim();
|
||||||
if (!name) return { error: "模型名称不能为空", status: 400 };
|
if (!name) return { error: "模型名称不能为空", status: 400 };
|
||||||
|
|
||||||
const modelId = request.modelId.trim();
|
const externalId = request.externalId.trim();
|
||||||
if (!modelId) return { error: "模型 ID 不能为空", status: 400 };
|
if (!externalId) return { error: "模型 ID 不能为空", status: 400 };
|
||||||
|
|
||||||
const capabilities = request.capabilities;
|
const capabilities = request.capabilities;
|
||||||
if (!capabilities || capabilities.length === 0) {
|
if (!capabilities || capabilities.length === 0) {
|
||||||
return { error: "至少选择一个能力标签", status: 400 };
|
return { error: "至少选择一个能力标签", status: 400 };
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = crypto.randomUUID();
|
const duplicate = db
|
||||||
const now = new Date().toISOString();
|
.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 {
|
const id = crypto.randomUUID();
|
||||||
db.insert(models)
|
const now = timestamp();
|
||||||
.values({
|
|
||||||
capabilities: JSON.stringify(capabilities),
|
db.insert(models)
|
||||||
contextLength: request.contextLength ?? null,
|
.values({
|
||||||
createdAt: now,
|
capabilities: JSON.stringify(capabilities),
|
||||||
id,
|
contextLength: request.contextLength ?? null,
|
||||||
maxOutputTokens: request.maxOutputTokens ?? null,
|
createdAt: now,
|
||||||
modelId,
|
externalId,
|
||||||
name,
|
id,
|
||||||
providerId: request.providerId,
|
maxOutputTokens: request.maxOutputTokens ?? null,
|
||||||
updatedAt: now,
|
name,
|
||||||
})
|
providerId: request.providerId,
|
||||||
.run();
|
updatedAt: now,
|
||||||
} catch (e: unknown) {
|
})
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
.run();
|
||||||
if (msg.includes("UNIQUE constraint")) {
|
|
||||||
return { error: "该供应商下模型 ID 已存在", status: 409 };
|
|
||||||
}
|
|
||||||
logger.error({ error: msg, operation: "create", table: "models" }, "数据库操作失败");
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
|
|
||||||
const row = db.select().from(models).where(eq(models.id, id)).get();
|
const row = db.select().from(models).where(eq(models.id, id)).get();
|
||||||
return { model: toModel(row!) };
|
return { model: toModel(row!) };
|
||||||
@@ -65,16 +67,24 @@ export function deleteModel(
|
|||||||
_logger: Logger,
|
_logger: Logger,
|
||||||
): { error: string; status: number } | { success: true } {
|
): { error: string; status: number } | { success: true } {
|
||||||
const db = wrap(raw);
|
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 };
|
if (!existing) return { error: "模型不存在", status: 404 };
|
||||||
|
|
||||||
db.delete(models).where(eq(models.id, id)).run();
|
softDeleteRecord(db, models, id);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getModel(raw: Database, id: string): { error: string; status: number } | { model: Model } {
|
export function getModel(raw: Database, id: string): { error: string; status: number } | { model: Model } {
|
||||||
const db = wrap(raw);
|
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 };
|
if (!row) return { error: "模型不存在", status: 404 };
|
||||||
return { model: toModel(row) };
|
return { model: toModel(row) };
|
||||||
@@ -85,7 +95,7 @@ export function getModelsByProviderId(raw: Database, providerId: string): number
|
|||||||
const result = db
|
const result = db
|
||||||
.select({ count: sql<number>`count(*)` })
|
.select({ count: sql<number>`count(*)` })
|
||||||
.from(models)
|
.from(models)
|
||||||
.where(eq(models.providerId, providerId))
|
.where(and(eq(models.providerId, providerId), isNull(models.deletedAt)))
|
||||||
.get();
|
.get();
|
||||||
return Number(result?.count ?? 0);
|
return Number(result?.count ?? 0);
|
||||||
}
|
}
|
||||||
@@ -96,20 +106,28 @@ export function getModelWithProvider(
|
|||||||
):
|
):
|
||||||
| { error: string; status: number }
|
| { 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 };
|
provider: { apiKey: string; baseUrl: string; id: string; type: string };
|
||||||
} {
|
} {
|
||||||
const db = wrap(raw);
|
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 };
|
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 };
|
if (!providerRow) return { error: "供应商不存在", status: 404 };
|
||||||
|
|
||||||
return {
|
return {
|
||||||
model: {
|
model: {
|
||||||
modelId: row.modelId,
|
externalId: row.externalId,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
providerId: row.providerId,
|
providerId: row.providerId,
|
||||||
},
|
},
|
||||||
@@ -142,7 +160,7 @@ export function listModels(
|
|||||||
|
|
||||||
if (options.keyword) {
|
if (options.keyword) {
|
||||||
const pattern = `%${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) {
|
if (options.capabilities) {
|
||||||
@@ -157,6 +175,7 @@ export function listModels(
|
|||||||
orderBy: orderByFn,
|
orderBy: orderByFn,
|
||||||
page: options.page,
|
page: options.page,
|
||||||
pageSize: options.pageSize,
|
pageSize: options.pageSize,
|
||||||
|
softDelete: models.deletedAt,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,14 +183,18 @@ export function updateModel(
|
|||||||
raw: Database,
|
raw: Database,
|
||||||
id: string,
|
id: string,
|
||||||
request: UpdateModelRequest,
|
request: UpdateModelRequest,
|
||||||
logger: Logger,
|
_logger: Logger,
|
||||||
): { error: string; status: number } | { model: Model } {
|
): { error: string; status: number } | { model: Model } {
|
||||||
const db = wrap(raw);
|
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 };
|
if (!existing) return { error: "模型不存在", status: 404 };
|
||||||
|
|
||||||
const updates: Partial<typeof models.$inferInsert> = {
|
const updates: Partial<typeof models.$inferInsert> = {
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: timestamp(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const name = request.name?.trim();
|
const name = request.name?.trim();
|
||||||
@@ -180,14 +203,32 @@ export function updateModel(
|
|||||||
updates.name = name;
|
updates.name = name;
|
||||||
}
|
}
|
||||||
|
|
||||||
const modelId = request.modelId?.trim();
|
const externalId = request.externalId?.trim();
|
||||||
if (modelId === "") return { error: "模型 ID 不能为空", status: 400 };
|
if (externalId === "") return { error: "模型 ID 不能为空", status: 400 };
|
||||||
if (modelId !== undefined) {
|
if (externalId !== undefined) {
|
||||||
updates.modelId = modelId;
|
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) {
|
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 };
|
if (!provider) return { error: "供应商不存在", status: 400 };
|
||||||
updates.providerId = request.providerId;
|
updates.providerId = request.providerId;
|
||||||
}
|
}
|
||||||
@@ -211,16 +252,7 @@ export function updateModel(
|
|||||||
return { model: toModel(existing) };
|
return { model: toModel(existing) };
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
db.update(models).set(updates).where(eq(models.id, id)).run();
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = db.select().from(models).where(eq(models.id, id)).get();
|
const updated = db.select().from(models).where(eq(models.id, id)).get();
|
||||||
return { model: toModel(updated!) };
|
return { model: toModel(updated!) };
|
||||||
@@ -242,9 +274,9 @@ function toModel(row: typeof models.$inferSelect): Model {
|
|||||||
capabilities: JSON.parse(row.capabilities) as ModelCapability[],
|
capabilities: JSON.parse(row.capabilities) as ModelCapability[],
|
||||||
contextLength: row.contextLength,
|
contextLength: row.contextLength,
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
|
externalId: row.externalId,
|
||||||
id: row.id,
|
id: row.id,
|
||||||
maxOutputTokens: row.maxOutputTokens,
|
maxOutputTokens: row.maxOutputTokens,
|
||||||
modelId: row.modelId,
|
|
||||||
name: row.name,
|
name: row.name,
|
||||||
providerId: row.providerId,
|
providerId: row.providerId,
|
||||||
updatedAt: row.updatedAt,
|
updatedAt: row.updatedAt,
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import type Database from "bun:sqlite";
|
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 { CreateProjectRequest, Project, ProjectStatus, SortOrder, UpdateProjectRequest } from "../../shared/api";
|
||||||
import type { Logger } from "../logger";
|
import type { Logger } from "../logger";
|
||||||
|
|
||||||
import { paginateQuery, wrap } from "./connection";
|
import { notDeleted, paginateQuery, timestamp, wrap } from "./connection";
|
||||||
import { projects } from "./schema";
|
import { conversations, materials, messages, projects } from "./schema";
|
||||||
|
|
||||||
export function archiveProject(
|
export function archiveProject(
|
||||||
raw: Database,
|
raw: Database,
|
||||||
@@ -14,12 +14,16 @@ export function archiveProject(
|
|||||||
_logger: Logger,
|
_logger: Logger,
|
||||||
): { error: string; status: number } | { project: Project } {
|
): { error: string; status: number } | { project: Project } {
|
||||||
const db = wrap(raw);
|
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) return { error: "项目不存在", status: 404 };
|
||||||
if (existing.status === "archived") return { error: "项目已归档", status: 409 };
|
if (existing.status === "archived") return { error: "项目已归档", status: 409 };
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = timestamp();
|
||||||
db.update(projects).set({ archivedAt: now, status: "archived", updatedAt: now }).where(eq(projects.id, id)).run();
|
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();
|
const updated = db.select().from(projects).where(eq(projects.id, id)).get();
|
||||||
return { project: toProject(updated!) };
|
return { project: toProject(updated!) };
|
||||||
@@ -28,37 +32,34 @@ export function archiveProject(
|
|||||||
export function createProject(
|
export function createProject(
|
||||||
raw: Database,
|
raw: Database,
|
||||||
request: CreateProjectRequest,
|
request: CreateProjectRequest,
|
||||||
logger: Logger,
|
_logger: Logger,
|
||||||
): { error: string; status: number } | { project: Project } {
|
): { error: string; status: number } | { project: Project } {
|
||||||
const db = wrap(raw);
|
const db = wrap(raw);
|
||||||
const name = request.name.trim();
|
const name = request.name.trim();
|
||||||
if (!name) return { error: "项目名称不能为空", status: 400 };
|
if (!name) return { error: "项目名称不能为空", status: 400 };
|
||||||
if (name.length > 10) return { error: "项目名称不能超过 10 个字符", 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 description = (request.description ?? "").trim();
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
const now = new Date().toISOString();
|
const now = timestamp();
|
||||||
|
|
||||||
try {
|
db.insert(projects)
|
||||||
db.insert(projects)
|
.values({
|
||||||
.values({
|
createdAt: now,
|
||||||
archivedAt: null,
|
description,
|
||||||
createdAt: now,
|
id,
|
||||||
description,
|
name,
|
||||||
id,
|
status: "active",
|
||||||
name,
|
updatedAt: now,
|
||||||
status: "active",
|
})
|
||||||
updatedAt: now,
|
.run();
|
||||||
})
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const row = db.select().from(projects).where(eq(projects.id, id)).get();
|
const row = db.select().from(projects).where(eq(projects.id, id)).get();
|
||||||
return { project: toProject(row!) };
|
return { project: toProject(row!) };
|
||||||
@@ -70,17 +71,53 @@ export function deleteProject(
|
|||||||
_logger: Logger,
|
_logger: Logger,
|
||||||
): { error: string; status: number } | { success: true } {
|
): { error: string; status: number } | { success: true } {
|
||||||
const db = wrap(raw);
|
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) return { error: "项目不存在", status: 404 };
|
||||||
if (existing.status === "active") return { error: "活跃项目不可删除,请先归档", status: 409 };
|
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 };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getProject(raw: Database, id: string): { error: string; status: number } | { project: Project } {
|
export function getProject(raw: Database, id: string): { error: string; status: number } | { project: Project } {
|
||||||
const db = wrap(raw);
|
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 };
|
if (!row) return { error: "项目不存在", status: 404 };
|
||||||
return { project: toProject(row) };
|
return { project: toProject(row) };
|
||||||
@@ -116,6 +153,7 @@ export function listProjects(
|
|||||||
orderBy: orderByFn,
|
orderBy: orderByFn,
|
||||||
page: options.page,
|
page: options.page,
|
||||||
pageSize: options.pageSize,
|
pageSize: options.pageSize,
|
||||||
|
softDelete: projects.deletedAt,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,12 +163,16 @@ export function restoreProject(
|
|||||||
_logger: Logger,
|
_logger: Logger,
|
||||||
): { error: string; status: number } | { project: Project } {
|
): { error: string; status: number } | { project: Project } {
|
||||||
const db = wrap(raw);
|
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) return { error: "项目不存在", status: 404 };
|
||||||
if (existing.status === "active") return { error: "项目已是活跃状态", status: 409 };
|
if (existing.status === "active") return { error: "项目已是活跃状态", status: 409 };
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = timestamp();
|
||||||
db.update(projects).set({ archivedAt: null, status: "active", updatedAt: now }).where(eq(projects.id, id)).run();
|
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();
|
const updated = db.select().from(projects).where(eq(projects.id, id)).get();
|
||||||
return { project: toProject(updated!) };
|
return { project: toProject(updated!) };
|
||||||
@@ -140,10 +182,14 @@ export function updateProject(
|
|||||||
raw: Database,
|
raw: Database,
|
||||||
id: string,
|
id: string,
|
||||||
request: UpdateProjectRequest,
|
request: UpdateProjectRequest,
|
||||||
logger: Logger,
|
_logger: Logger,
|
||||||
): { error: string; status: number } | { project: Project } {
|
): { error: string; status: number } | { project: Project } {
|
||||||
const db = wrap(raw);
|
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) return { error: "项目不存在", status: 404 };
|
||||||
if (existing.status === "archived") return { error: "已归档项目不可编辑", status: 409 };
|
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 };
|
if (name !== undefined && name.length > 10) return { error: "项目名称不能超过 10 个字符", status: 400 };
|
||||||
|
|
||||||
const updates: Partial<typeof projects.$inferInsert> = {
|
const updates: Partial<typeof projects.$inferInsert> = {
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: timestamp(),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (name !== undefined && name !== existing.name) {
|
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;
|
updates.name = name;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,16 +220,7 @@ export function updateProject(
|
|||||||
return { project: toProject(existing) };
|
return { project: toProject(existing) };
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
db.update(projects).set(updates).where(eq(projects.id, id)).run();
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = db.select().from(projects).where(eq(projects.id, id)).get();
|
const updated = db.select().from(projects).where(eq(projects.id, id)).get();
|
||||||
return { project: toProject(updated!) };
|
return { project: toProject(updated!) };
|
||||||
@@ -196,7 +239,6 @@ function buildProjectOrderBy(
|
|||||||
|
|
||||||
function toProject(row: typeof projects.$inferSelect): Project {
|
function toProject(row: typeof projects.$inferSelect): Project {
|
||||||
return {
|
return {
|
||||||
archivedAt: row.archivedAt,
|
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
description: row.description,
|
description: row.description,
|
||||||
id: row.id,
|
id: row.id,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type Database from "bun:sqlite";
|
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 {
|
import type {
|
||||||
CreateProviderRequest,
|
CreateProviderRequest,
|
||||||
@@ -11,13 +11,13 @@ import type {
|
|||||||
} from "../../shared/api";
|
} from "../../shared/api";
|
||||||
import type { Logger } from "../logger";
|
import type { Logger } from "../logger";
|
||||||
|
|
||||||
import { paginateQuery, wrap } from "./connection";
|
import { notDeleted, paginateQuery, softDeleteRecord, timestamp, wrap } from "./connection";
|
||||||
import { providers } from "./schema";
|
import { models, providers } from "./schema";
|
||||||
|
|
||||||
export function createProvider(
|
export function createProvider(
|
||||||
raw: Database,
|
raw: Database,
|
||||||
request: CreateProviderRequest,
|
request: CreateProviderRequest,
|
||||||
logger: Logger,
|
_logger: Logger,
|
||||||
): { error: string; status: number } | { provider: Provider } {
|
): { error: string; status: number } | { provider: Provider } {
|
||||||
const db = wrap(raw);
|
const db = wrap(raw);
|
||||||
const name = request.name.trim();
|
const name = request.name.trim();
|
||||||
@@ -29,29 +29,27 @@ export function createProvider(
|
|||||||
const apiKey = request.apiKey.trim();
|
const apiKey = request.apiKey.trim();
|
||||||
if (!apiKey) return { error: "API Key 不能为空", status: 400 };
|
if (!apiKey) return { error: "API Key 不能为空", status: 400 };
|
||||||
|
|
||||||
const id = crypto.randomUUID();
|
const duplicate = db
|
||||||
const now = new Date().toISOString();
|
.select({ id: providers.id })
|
||||||
|
.from(providers)
|
||||||
|
.where(and(eq(providers.name, name), notDeleted(providers)))
|
||||||
|
.get();
|
||||||
|
if (duplicate) return { error: "供应商名称已存在", status: 409 };
|
||||||
|
|
||||||
try {
|
const id = crypto.randomUUID();
|
||||||
db.insert(providers)
|
const now = timestamp();
|
||||||
.values({
|
|
||||||
apiKey,
|
db.insert(providers)
|
||||||
baseUrl,
|
.values({
|
||||||
createdAt: now,
|
apiKey,
|
||||||
id,
|
baseUrl,
|
||||||
name,
|
createdAt: now,
|
||||||
type: request.type,
|
id,
|
||||||
updatedAt: now,
|
name,
|
||||||
})
|
type: request.type,
|
||||||
.run();
|
updatedAt: now,
|
||||||
} catch (e: unknown) {
|
})
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
.run();
|
||||||
if (msg.includes("UNIQUE constraint")) {
|
|
||||||
return { error: "供应商名称已存在", status: 409 };
|
|
||||||
}
|
|
||||||
logger.error({ error: msg, operation: "create", table: "providers" }, "数据库操作失败");
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
|
|
||||||
const row = db.select().from(providers).where(eq(providers.id, id)).get();
|
const row = db.select().from(providers).where(eq(providers.id, id)).get();
|
||||||
return { provider: toProvider(row!) };
|
return { provider: toProvider(row!) };
|
||||||
@@ -63,16 +61,31 @@ export function deleteProvider(
|
|||||||
_logger: Logger,
|
_logger: Logger,
|
||||||
): { error: string; status: number } | { success: true } {
|
): { error: string; status: number } | { success: true } {
|
||||||
const db = wrap(raw);
|
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 };
|
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 };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getProvider(raw: Database, id: string): { error: string; status: number } | { provider: Provider } {
|
export function getProvider(raw: Database, id: string): { error: string; status: number } | { provider: Provider } {
|
||||||
const db = wrap(raw);
|
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 };
|
if (!row) return { error: "供应商不存在", status: 404 };
|
||||||
return { provider: toProvider(row) };
|
return { provider: toProvider(row) };
|
||||||
@@ -83,6 +96,7 @@ export function listProviderOptions(raw: Database): ProviderOption[] {
|
|||||||
const rows = db
|
const rows = db
|
||||||
.select({ id: providers.id, name: providers.name, type: providers.type })
|
.select({ id: providers.id, name: providers.name, type: providers.type })
|
||||||
.from(providers)
|
.from(providers)
|
||||||
|
.where(notDeleted(providers))
|
||||||
.orderBy(desc(providers.createdAt))
|
.orderBy(desc(providers.createdAt))
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
@@ -112,6 +126,7 @@ export function listProviders(
|
|||||||
orderBy: orderByFn,
|
orderBy: orderByFn,
|
||||||
page: options.page,
|
page: options.page,
|
||||||
pageSize: options.pageSize,
|
pageSize: options.pageSize,
|
||||||
|
softDelete: providers.deletedAt,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,19 +134,29 @@ export function updateProvider(
|
|||||||
raw: Database,
|
raw: Database,
|
||||||
id: string,
|
id: string,
|
||||||
request: UpdateProviderRequest,
|
request: UpdateProviderRequest,
|
||||||
logger: Logger,
|
_logger: Logger,
|
||||||
): { error: string; status: number } | { provider: Provider } {
|
): { error: string; status: number } | { provider: Provider } {
|
||||||
const db = wrap(raw);
|
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 };
|
if (!existing) return { error: "供应商不存在", status: 404 };
|
||||||
|
|
||||||
const updates: Partial<typeof providers.$inferInsert> = {
|
const updates: Partial<typeof providers.$inferInsert> = {
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: timestamp(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const name = request.name?.trim();
|
const name = request.name?.trim();
|
||||||
if (name === "") return { error: "供应商名称不能为空", status: 400 };
|
if (name === "") return { error: "供应商名称不能为空", status: 400 };
|
||||||
if (name !== undefined && name !== existing.name) {
|
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;
|
updates.name = name;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,16 +180,7 @@ export function updateProvider(
|
|||||||
return { provider: toProvider(existing) };
|
return { provider: toProvider(existing) };
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
db.update(providers).set(updates).where(eq(providers.id, id)).run();
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = db.select().from(providers).where(eq(providers.id, id)).get();
|
const updated = db.select().from(providers).where(eq(providers.id, id)).get();
|
||||||
return { provider: toProvider(updated!) };
|
return { provider: toProvider(updated!) };
|
||||||
|
|||||||
@@ -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", {
|
export const projects = sqliteTable("projects", {
|
||||||
archivedAt: text("archived_at"),
|
...baseColumns,
|
||||||
createdAt: text("created_at").notNull(),
|
|
||||||
description: text("description").notNull().default(""),
|
description: text("description").notNull().default(""),
|
||||||
id: text("id").primaryKey(),
|
name: text("name").notNull(),
|
||||||
name: text("name").notNull().unique(),
|
|
||||||
status: text("status", { enum: ["active", "archived"] })
|
status: text("status", { enum: ["active", "archived"] })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default("active"),
|
.default("active"),
|
||||||
updatedAt: text("updated_at").notNull(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const providers = sqliteTable("providers", {
|
export const providers = sqliteTable("providers", {
|
||||||
|
...baseColumns,
|
||||||
apiKey: text("api_key").notNull(),
|
apiKey: text("api_key").notNull(),
|
||||||
baseUrl: text("base_url").notNull(),
|
baseUrl: text("base_url").notNull(),
|
||||||
createdAt: text("created_at").notNull(),
|
name: text("name").notNull(),
|
||||||
id: text("id").primaryKey(),
|
|
||||||
name: text("name").notNull().unique(),
|
|
||||||
type: text("type", { enum: ["anthropic", "openai", "openai-compatible"] })
|
type: text("type", { enum: ["anthropic", "openai", "openai-compatible"] })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default("openai-compatible"),
|
.default("openai-compatible"),
|
||||||
updatedAt: text("updated_at").notNull(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const models = sqliteTable(
|
export const models = sqliteTable(
|
||||||
"models",
|
"models",
|
||||||
{
|
{
|
||||||
|
...baseColumns,
|
||||||
capabilities: text("capabilities").notNull(),
|
capabilities: text("capabilities").notNull(),
|
||||||
contextLength: integer("context_length"),
|
contextLength: integer("context_length"),
|
||||||
createdAt: text("created_at").notNull(),
|
externalId: text("external_id").notNull(),
|
||||||
id: text("id").primaryKey(),
|
|
||||||
maxOutputTokens: integer("max_output_tokens"),
|
maxOutputTokens: integer("max_output_tokens"),
|
||||||
modelId: text("model_id").notNull(),
|
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
providerId: text("provider_id")
|
providerId: text("provider_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => providers.id),
|
.references(() => providers.id),
|
||||||
updatedAt: text("updated_at").notNull(),
|
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [index("models_provider_id_idx").on(table.providerId)],
|
||||||
uniqueIndex("models_provider_id_model_id_unique").on(table.providerId, table.modelId),
|
|
||||||
index("models_provider_id_idx").on(table.providerId),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
export const conversations = sqliteTable(
|
export const conversations = sqliteTable(
|
||||||
"conversations",
|
"conversations",
|
||||||
{
|
{
|
||||||
createdAt: text("created_at").notNull(),
|
...baseColumns,
|
||||||
id: text("id").primaryKey(),
|
modelId: text("model_id").references(() => models.id),
|
||||||
modelId: text("model_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => models.id),
|
|
||||||
projectId: text("project_id")
|
projectId: text("project_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => projects.id),
|
.references(() => projects.id),
|
||||||
title: text("title").notNull().default("新会话"),
|
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(
|
export const materials = sqliteTable(
|
||||||
"materials",
|
"materials",
|
||||||
{
|
{
|
||||||
|
...baseColumns,
|
||||||
associatedDate: text("associated_date").notNull(),
|
associatedDate: text("associated_date").notNull(),
|
||||||
createdAt: text("created_at").notNull(),
|
|
||||||
description: text("description").notNull(),
|
description: text("description").notNull(),
|
||||||
id: text("id").primaryKey(),
|
|
||||||
projectId: text("project_id")
|
projectId: text("project_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => projects.id),
|
.references(() => projects.id),
|
||||||
status: text("status", { enum: ["pending", "approved", "discarded"] })
|
status: text("status", { enum: ["pending", "approved", "discarded"] })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default("pending"),
|
.default("pending"),
|
||||||
updatedAt: text("updated_at").notNull(),
|
|
||||||
},
|
},
|
||||||
(table) => [index("materials_project_id_idx").on(table.projectId)],
|
(table) => [index("materials_project_id_idx").on(table.projectId)],
|
||||||
);
|
);
|
||||||
@@ -83,12 +70,11 @@ export const materials = sqliteTable(
|
|||||||
export const messages = sqliteTable(
|
export const messages = sqliteTable(
|
||||||
"messages",
|
"messages",
|
||||||
{
|
{
|
||||||
|
...baseColumns,
|
||||||
content: text("content").notNull().default(""),
|
content: text("content").notNull().default(""),
|
||||||
conversationId: text("conversation_id")
|
conversationId: text("conversation_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => conversations.id, { onDelete: "cascade" }),
|
.references(() => conversations.id),
|
||||||
createdAt: text("created_at").notNull(),
|
|
||||||
id: text("id").primaryKey(),
|
|
||||||
parts: text("parts"),
|
parts: text("parts"),
|
||||||
role: text("role", { enum: ["assistant", "system", "user"] }).notNull(),
|
role: text("role", { enum: ["assistant", "system", "user"] }).notNull(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
updateConversation,
|
updateConversation,
|
||||||
updateConversationTimestamp,
|
updateConversationTimestamp,
|
||||||
} from "../../db/conversations";
|
} from "../../db/conversations";
|
||||||
import { getModelWithProvider } from "../../db/models";
|
import { getModelWithProvider, listModels } from "../../db/models";
|
||||||
import { createApiError, jsonResponse } from "../../helpers";
|
import { createApiError, jsonResponse } from "../../helpers";
|
||||||
import { validateIdParam } from "../../middleware";
|
import { validateIdParam } from "../../middleware";
|
||||||
|
|
||||||
@@ -79,13 +79,23 @@ export async function handleSendChat(req: Request, db: Database, mode: RuntimeMo
|
|||||||
|
|
||||||
let model;
|
let model;
|
||||||
try {
|
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) {
|
if ("error" in result) {
|
||||||
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
|
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
|
||||||
}
|
}
|
||||||
|
|
||||||
const registry = buildProviderRegistry(db);
|
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) {
|
} catch (e: unknown) {
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
return jsonResponse(createApiError(`模型初始化失败:${msg}`, 500), { mode, status: 500 });
|
return jsonResponse(createApiError(`模型初始化失败:${msg}`, 500), { mode, status: 500 });
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ export async function handleCreateModel(
|
|||||||
return jsonResponse(createApiError("name is required", 400), { mode, status: 400 });
|
return jsonResponse(createApiError("name is required", 400), { mode, status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!body.modelId || typeof body.modelId !== "string") {
|
if (!body.externalId || typeof body.externalId !== "string") {
|
||||||
return jsonResponse(createApiError("modelId is required", 400), { mode, status: 400 });
|
return jsonResponse(createApiError("externalId is required", 400), { mode, status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!body.providerId || typeof body.providerId !== "string") {
|
if (!body.providerId || typeof body.providerId !== "string") {
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ export async function handleTestModelConfig(
|
|||||||
return jsonResponse(createApiError("providerId is required", 400), { mode, status: 400 });
|
return jsonResponse(createApiError("providerId is required", 400), { mode, status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!body.modelId || typeof body.modelId !== "string") {
|
if (!body.externalId || typeof body.externalId !== "string") {
|
||||||
return jsonResponse(createApiError("modelId is required", 400), { mode, status: 400 });
|
return jsonResponse(createApiError("externalId is required", 400), { mode, status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const providerResult = getProvider(db, body.providerId);
|
const providerResult = getProvider(db, body.providerId);
|
||||||
@@ -41,7 +41,7 @@ export async function handleTestModelConfig(
|
|||||||
{
|
{
|
||||||
apiKey: providerResult.provider.apiKey,
|
apiKey: providerResult.provider.apiKey,
|
||||||
baseUrl: providerResult.provider.baseUrl,
|
baseUrl: providerResult.provider.baseUrl,
|
||||||
modelId: body.modelId,
|
modelId: body.externalId,
|
||||||
name: providerResult.provider.name,
|
name: providerResult.provider.name,
|
||||||
type: providerResult.provider.type,
|
type: providerResult.provider.type,
|
||||||
},
|
},
|
||||||
@@ -50,7 +50,7 @@ export async function handleTestModelConfig(
|
|||||||
|
|
||||||
if (!testResult.ok) {
|
if (!testResult.ok) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
{ message: testResult.message, modelId: body.modelId, providerId: body.providerId },
|
{ externalId: body.externalId, message: testResult.message, providerId: body.providerId },
|
||||||
"模型连接测试失败",
|
"模型连接测试失败",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export interface ApiErrorResponse {
|
|||||||
export interface Conversation {
|
export interface Conversation {
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
id: string;
|
id: string;
|
||||||
modelId: string;
|
modelId: null | string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
title: string;
|
title: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
@@ -36,8 +36,8 @@ export interface CreateMaterialRequest {
|
|||||||
export interface CreateModelRequest {
|
export interface CreateModelRequest {
|
||||||
capabilities: ModelCapability[];
|
capabilities: ModelCapability[];
|
||||||
contextLength?: null | number;
|
contextLength?: null | number;
|
||||||
|
externalId: string;
|
||||||
maxOutputTokens?: null | number;
|
maxOutputTokens?: null | number;
|
||||||
modelId: string;
|
|
||||||
name: string;
|
name: string;
|
||||||
providerId: string;
|
providerId: string;
|
||||||
}
|
}
|
||||||
@@ -94,6 +94,7 @@ export interface Message {
|
|||||||
id: string;
|
id: string;
|
||||||
parts: null | string;
|
parts: null | string;
|
||||||
role: "assistant" | "system" | "user";
|
role: "assistant" | "system" | "user";
|
||||||
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MessageListResponse {
|
export interface MessageListResponse {
|
||||||
@@ -114,9 +115,9 @@ export interface Model {
|
|||||||
capabilities: ModelCapability[];
|
capabilities: ModelCapability[];
|
||||||
contextLength: null | number;
|
contextLength: null | number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
externalId: string;
|
||||||
id: string;
|
id: string;
|
||||||
maxOutputTokens: null | number;
|
maxOutputTokens: null | number;
|
||||||
modelId: string;
|
|
||||||
name: string;
|
name: string;
|
||||||
providerId: string;
|
providerId: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
@@ -171,7 +172,6 @@ export interface ModelTestResultResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Project {
|
export interface Project {
|
||||||
archivedAt: null | string;
|
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
description: string;
|
description: string;
|
||||||
id: string;
|
id: string;
|
||||||
@@ -238,15 +238,15 @@ export type ProviderType = "anthropic" | "openai" | "openai-compatible";
|
|||||||
export type RuntimeMode = "development" | "production" | "test";
|
export type RuntimeMode = "development" | "production" | "test";
|
||||||
|
|
||||||
export interface TestModelRequest {
|
export interface TestModelRequest {
|
||||||
modelId: string;
|
externalId: string;
|
||||||
providerId: string;
|
providerId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateModelRequest {
|
export interface UpdateModelRequest {
|
||||||
capabilities?: ModelCapability[];
|
capabilities?: ModelCapability[];
|
||||||
contextLength?: null | number;
|
contextLength?: null | number;
|
||||||
|
externalId?: string;
|
||||||
maxOutputTokens?: null | number;
|
maxOutputTokens?: null | number;
|
||||||
modelId?: string;
|
|
||||||
name?: string;
|
name?: string;
|
||||||
providerId?: string;
|
providerId?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import type {
|
|||||||
interface FormValues {
|
interface FormValues {
|
||||||
capabilities: ModelCapability[];
|
capabilities: ModelCapability[];
|
||||||
contextLength: null | number;
|
contextLength: null | number;
|
||||||
|
externalId: string;
|
||||||
maxOutputTokens: null | number;
|
maxOutputTokens: null | number;
|
||||||
modelId: string;
|
|
||||||
name: string;
|
name: string;
|
||||||
providerId: string;
|
providerId: string;
|
||||||
}
|
}
|
||||||
@@ -70,8 +70,8 @@ export function ModelFormModal({
|
|||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
capabilities: editingModel.capabilities,
|
capabilities: editingModel.capabilities,
|
||||||
contextLength: editingModel.contextLength,
|
contextLength: editingModel.contextLength,
|
||||||
|
externalId: editingModel.externalId,
|
||||||
maxOutputTokens: editingModel.maxOutputTokens,
|
maxOutputTokens: editingModel.maxOutputTokens,
|
||||||
modelId: editingModel.modelId,
|
|
||||||
name: editingModel.name,
|
name: editingModel.name,
|
||||||
providerId: editingModel.providerId,
|
providerId: editingModel.providerId,
|
||||||
});
|
});
|
||||||
@@ -86,7 +86,7 @@ export function ModelFormModal({
|
|||||||
if (editingModel) {
|
if (editingModel) {
|
||||||
const reqData: UpdateModelRequest = {};
|
const reqData: UpdateModelRequest = {};
|
||||||
if (values.name !== editingModel.name) reqData.name = values.name;
|
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;
|
if (values.providerId !== editingModel.providerId) reqData.providerId = values.providerId;
|
||||||
const capsChanged =
|
const capsChanged =
|
||||||
values.capabilities.length !== editingModel.capabilities.length ||
|
values.capabilities.length !== editingModel.capabilities.length ||
|
||||||
@@ -100,8 +100,8 @@ export function ModelFormModal({
|
|||||||
const reqData: CreateModelRequest = {
|
const reqData: CreateModelRequest = {
|
||||||
capabilities: values.capabilities,
|
capabilities: values.capabilities,
|
||||||
contextLength: values.contextLength ?? undefined,
|
contextLength: values.contextLength ?? undefined,
|
||||||
|
externalId: values.externalId,
|
||||||
maxOutputTokens: values.maxOutputTokens ?? undefined,
|
maxOutputTokens: values.maxOutputTokens ?? undefined,
|
||||||
modelId: values.modelId,
|
|
||||||
name: values.name,
|
name: values.name,
|
||||||
providerId: values.providerId,
|
providerId: values.providerId,
|
||||||
};
|
};
|
||||||
@@ -119,18 +119,18 @@ export function ModelFormModal({
|
|||||||
const handleTest = async () => {
|
const handleTest = async () => {
|
||||||
if (!testModelConnection) return;
|
if (!testModelConnection) return;
|
||||||
const providerId: unknown = form.getFieldValue("providerId");
|
const providerId: unknown = form.getFieldValue("providerId");
|
||||||
const modelId: unknown = form.getFieldValue("modelId");
|
const externalId: unknown = form.getFieldValue("externalId");
|
||||||
if (typeof providerId !== "string" || !providerId) {
|
if (typeof providerId !== "string" || !providerId) {
|
||||||
message.warning("请先选择供应商");
|
message.warning("请先选择供应商");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (typeof modelId !== "string" || !modelId) {
|
if (typeof externalId !== "string" || !externalId) {
|
||||||
message.warning("请先输入模型 ID");
|
message.warning("请先输入模型 ID");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setTesting(true);
|
setTesting(true);
|
||||||
try {
|
try {
|
||||||
const result = await testModelConnection({ modelId, providerId });
|
const result = await testModelConnection({ externalId, providerId });
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
message.success(result.message);
|
message.success(result.message);
|
||||||
} else {
|
} else {
|
||||||
@@ -177,7 +177,7 @@ export function ModelFormModal({
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label="模型 ID"
|
label="模型 ID"
|
||||||
name="modelId"
|
name="externalId"
|
||||||
rules={[{ message: "请输入模型 ID", required: true, whitespace: true }]}
|
rules={[{ message: "请输入模型 ID", required: true, whitespace: true }]}
|
||||||
>
|
>
|
||||||
<Input placeholder="gpt-4o, claude-3-opus-20240229, deepseek-chat 等" />
|
<Input placeholder="gpt-4o, claude-3-opus-20240229, deepseek-chat 等" />
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export function useCreateModel() {
|
|||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: createModel,
|
mutationFn: createModel,
|
||||||
onSuccess: (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 });
|
void queryClient.invalidateQueries({ queryKey: MODELS_KEY });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -142,7 +142,7 @@ export function useUpdateModel() {
|
|||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (args: { data: UpdateModelRequest; id: string }) => updateModel(args.id, args.data),
|
mutationFn: (args: { data: UpdateModelRequest; id: string }) => updateModel(args.id, args.data),
|
||||||
onSuccess: (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 });
|
void queryClient.invalidateQueries({ queryKey: MODELS_KEY });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -41,17 +41,18 @@ describe("模型数据访问层", () => {
|
|||||||
db,
|
db,
|
||||||
{
|
{
|
||||||
capabilities: ["text", "reasoning"],
|
capabilities: ["text", "reasoning"],
|
||||||
modelId: "gpt-4o",
|
externalId: "gpt-4o",
|
||||||
name: "GPT-4o",
|
name: "GPT-4o",
|
||||||
providerId,
|
providerId,
|
||||||
},
|
},
|
||||||
createNoopLogger(),
|
createNoopLogger(),
|
||||||
);
|
);
|
||||||
expect("error" in result).toBe(false);
|
expect("error" in result).toBe(false);
|
||||||
const model = (result as { model: { capabilities: string[]; modelId: string; name: string; providerId: string } })
|
const model = (
|
||||||
.model;
|
result as { model: { capabilities: string[]; externalId: string; name: string; providerId: string } }
|
||||||
|
).model;
|
||||||
expect(model.name).toBe("GPT-4o");
|
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.providerId).toBe(providerId);
|
||||||
expect(model.capabilities).toEqual(["text", "reasoning"]);
|
expect(model.capabilities).toEqual(["text", "reasoning"]);
|
||||||
});
|
});
|
||||||
@@ -63,7 +64,7 @@ describe("模型数据访问层", () => {
|
|||||||
db,
|
db,
|
||||||
{
|
{
|
||||||
capabilities: ["text"],
|
capabilities: ["text"],
|
||||||
modelId: "test",
|
externalId: "test",
|
||||||
name: "Test",
|
name: "Test",
|
||||||
providerId: "nonexistent",
|
providerId: "nonexistent",
|
||||||
},
|
},
|
||||||
@@ -77,10 +78,10 @@ describe("模型数据访问层", () => {
|
|||||||
test("同一供应商下模型 ID 唯一", () => {
|
test("同一供应商下模型 ID 唯一", () => {
|
||||||
withDb((db) => {
|
withDb((db) => {
|
||||||
const providerId = seedProvider(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(
|
const result = createModel(
|
||||||
db,
|
db,
|
||||||
{ capabilities: ["text"], modelId: "gpt-4o", name: "Model2", providerId },
|
{ capabilities: ["text"], externalId: "gpt-4o", name: "Model2", providerId },
|
||||||
createNoopLogger(),
|
createNoopLogger(),
|
||||||
);
|
);
|
||||||
expect("error" in result).toBe(true);
|
expect("error" in result).toBe(true);
|
||||||
@@ -94,12 +95,12 @@ describe("模型数据访问层", () => {
|
|||||||
const p2 = seedProvider(db, "P2");
|
const p2 = seedProvider(db, "P2");
|
||||||
const r1 = createModel(
|
const r1 = createModel(
|
||||||
db,
|
db,
|
||||||
{ capabilities: ["text"], modelId: "same-id", name: "M1", providerId: p1 },
|
{ capabilities: ["text"], externalId: "same-id", name: "M1", providerId: p1 },
|
||||||
createNoopLogger(),
|
createNoopLogger(),
|
||||||
);
|
);
|
||||||
const r2 = createModel(
|
const r2 = createModel(
|
||||||
db,
|
db,
|
||||||
{ capabilities: ["text"], modelId: "same-id", name: "M2", providerId: p2 },
|
{ capabilities: ["text"], externalId: "same-id", name: "M2", providerId: p2 },
|
||||||
createNoopLogger(),
|
createNoopLogger(),
|
||||||
);
|
);
|
||||||
expect("error" in r1).toBe(false);
|
expect("error" in r1).toBe(false);
|
||||||
@@ -112,7 +113,7 @@ describe("模型数据访问层", () => {
|
|||||||
const providerId = seedProvider(db);
|
const providerId = seedProvider(db);
|
||||||
const result = createModel(
|
const result = createModel(
|
||||||
db,
|
db,
|
||||||
{ capabilities: [], modelId: "test", name: "Test", providerId },
|
{ capabilities: [], externalId: "test", name: "Test", providerId },
|
||||||
createNoopLogger(),
|
createNoopLogger(),
|
||||||
);
|
);
|
||||||
expect("error" in result).toBe(true);
|
expect("error" in result).toBe(true);
|
||||||
@@ -124,9 +125,9 @@ describe("模型数据访问层", () => {
|
|||||||
withDb((db) => {
|
withDb((db) => {
|
||||||
const p1 = seedProvider(db, "P1");
|
const p1 = seedProvider(db, "P1");
|
||||||
const p2 = seedProvider(db, "P2");
|
const p2 = seedProvider(db, "P2");
|
||||||
createModel(db, { capabilities: ["text"], modelId: "m1", name: "Alpha", providerId: p1 }, createNoopLogger());
|
createModel(db, { capabilities: ["text"], externalId: "m1", name: "Alpha", providerId: p1 }, createNoopLogger());
|
||||||
createModel(db, { capabilities: ["text"], modelId: "m2", name: "Beta", providerId: p1 }, createNoopLogger());
|
createModel(db, { capabilities: ["text"], externalId: "m2", name: "Beta", providerId: p1 }, createNoopLogger());
|
||||||
createModel(db, { capabilities: ["text"], modelId: "m3", name: "Gamma", providerId: p2 }, createNoopLogger());
|
createModel(db, { capabilities: ["text"], externalId: "m3", name: "Gamma", providerId: p2 }, createNoopLogger());
|
||||||
|
|
||||||
const all = listModels(db, { page: 1, pageSize: 20 });
|
const all = listModels(db, { page: 1, pageSize: 20 });
|
||||||
expect(all.total).toBe(3);
|
expect(all.total).toBe(3);
|
||||||
@@ -144,7 +145,7 @@ describe("模型数据访问层", () => {
|
|||||||
const providerId = seedProvider(db);
|
const providerId = seedProvider(db);
|
||||||
const created = createModel(
|
const created = createModel(
|
||||||
db,
|
db,
|
||||||
{ capabilities: ["text"], modelId: "gpt-4o", name: "GPT-4o", providerId },
|
{ capabilities: ["text"], externalId: "gpt-4o", name: "GPT-4o", providerId },
|
||||||
createNoopLogger(),
|
createNoopLogger(),
|
||||||
);
|
);
|
||||||
const id = (created as { model: { id: string } }).model.id;
|
const id = (created as { model: { id: string } }).model.id;
|
||||||
@@ -168,7 +169,7 @@ describe("模型数据访问层", () => {
|
|||||||
const providerId = seedProvider(db);
|
const providerId = seedProvider(db);
|
||||||
const created = createModel(
|
const created = createModel(
|
||||||
db,
|
db,
|
||||||
{ capabilities: ["text"], modelId: "gpt-4o", name: "原名", providerId },
|
{ capabilities: ["text"], externalId: "gpt-4o", name: "原名", providerId },
|
||||||
createNoopLogger(),
|
createNoopLogger(),
|
||||||
);
|
);
|
||||||
const id = (created as { model: { id: string } }).model.id;
|
const id = (created as { model: { id: string } }).model.id;
|
||||||
@@ -186,7 +187,7 @@ describe("模型数据访问层", () => {
|
|||||||
const providerId = seedProvider(db);
|
const providerId = seedProvider(db);
|
||||||
const created = createModel(
|
const created = createModel(
|
||||||
db,
|
db,
|
||||||
{ capabilities: ["text"], modelId: "gpt-4o", name: "删除测试", providerId },
|
{ capabilities: ["text"], externalId: "gpt-4o", name: "删除测试", providerId },
|
||||||
createNoopLogger(),
|
createNoopLogger(),
|
||||||
);
|
);
|
||||||
const id = (created as { model: { id: string } }).model.id;
|
const id = (created as { model: { id: string } }).model.id;
|
||||||
@@ -203,9 +204,9 @@ describe("模型数据访问层", () => {
|
|||||||
withDb((db) => {
|
withDb((db) => {
|
||||||
const p1 = seedProvider(db, "P1");
|
const p1 = seedProvider(db, "P1");
|
||||||
const p2 = seedProvider(db, "P2");
|
const p2 = seedProvider(db, "P2");
|
||||||
createModel(db, { capabilities: ["text"], modelId: "m1", name: "M1", providerId: p1 }, createNoopLogger());
|
createModel(db, { capabilities: ["text"], externalId: "m1", name: "M1", providerId: p1 }, createNoopLogger());
|
||||||
createModel(db, { capabilities: ["text"], modelId: "m2", name: "M2", providerId: p1 }, createNoopLogger());
|
createModel(db, { capabilities: ["text"], externalId: "m2", name: "M2", providerId: p1 }, createNoopLogger());
|
||||||
createModel(db, { capabilities: ["text"], modelId: "m3", name: "M3", providerId: p2 }, createNoopLogger());
|
createModel(db, { capabilities: ["text"], externalId: "m3", name: "M3", providerId: p2 }, createNoopLogger());
|
||||||
|
|
||||||
expect(getModelsByProviderId(db, p1)).toBe(2);
|
expect(getModelsByProviderId(db, p1)).toBe(2);
|
||||||
expect(getModelsByProviderId(db, p2)).toBe(1);
|
expect(getModelsByProviderId(db, p2)).toBe(1);
|
||||||
@@ -220,8 +221,8 @@ describe("模型数据访问层", () => {
|
|||||||
{
|
{
|
||||||
capabilities: ["text"],
|
capabilities: ["text"],
|
||||||
contextLength: 128000,
|
contextLength: 128000,
|
||||||
|
externalId: "gpt-4o",
|
||||||
maxOutputTokens: 4096,
|
maxOutputTokens: 4096,
|
||||||
modelId: "gpt-4o",
|
|
||||||
name: "GPT-4o",
|
name: "GPT-4o",
|
||||||
providerId,
|
providerId,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -132,16 +132,13 @@ describe("项目数据访问层", () => {
|
|||||||
const result = archiveProject(db, id, createNoopLogger());
|
const result = archiveProject(db, id, createNoopLogger());
|
||||||
expect("error" in result).toBe(false);
|
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.status).toBe("archived");
|
||||||
expect(archived.archivedAt).not.toBeNull();
|
|
||||||
|
|
||||||
const row = db.query("SELECT status, archived_at FROM projects WHERE id = ?").get(id) as {
|
const row = db.query("SELECT status FROM projects WHERE id = ?").get(id) as {
|
||||||
archived_at: null | string;
|
|
||||||
status: string;
|
status: string;
|
||||||
};
|
};
|
||||||
expect(row.status).toBe("archived");
|
expect(row.status).toBe("archived");
|
||||||
expect(row.archived_at).not.toBeNull();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -165,9 +162,8 @@ describe("项目数据访问层", () => {
|
|||||||
const result = restoreProject(db, id, createNoopLogger());
|
const result = restoreProject(db, id, createNoopLogger());
|
||||||
expect("error" in result).toBe(false);
|
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.status).toBe("active");
|
||||||
expect(restored.archivedAt).toBeNull();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
154
tests/server/db/schema.test.ts
Normal file
154
tests/server/db/schema.test.ts
Normal file
@@ -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<string, { column: string; invalidValue: string; validValue: string }> = {
|
||||||
|
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<string, readonly string[]> = {
|
||||||
|
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<string>();
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
241
tests/server/db/soft-delete.test.ts
Normal file
241
tests/server/db/soft-delete.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -44,12 +44,12 @@ async function patchConversationViaHandler(req: Request, db: Database): Promise<
|
|||||||
return h(req, db, MODE, LOG);
|
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(
|
const result = createModel(
|
||||||
db,
|
db,
|
||||||
{
|
{
|
||||||
capabilities: ["text"],
|
capabilities: ["text"],
|
||||||
modelId,
|
externalId,
|
||||||
name: modelName,
|
name: modelName,
|
||||||
providerId,
|
providerId,
|
||||||
},
|
},
|
||||||
@@ -111,7 +111,7 @@ describe("聊天 API 路由", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("无可用模型时返回 400", async () => {
|
test("无可用模型时创建会话 modelId 为 null", async () => {
|
||||||
const handle = createMigratedMemoryTestDatabase("chat-create-no-model");
|
const handle = createMigratedMemoryTestDatabase("chat-create-no-model");
|
||||||
try {
|
try {
|
||||||
const db = handle.db;
|
const db = handle.db;
|
||||||
@@ -123,9 +123,9 @@ describe("聊天 API 路由", () => {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
});
|
});
|
||||||
const res = await createConversationViaHandler(req, db);
|
const res = await createConversationViaHandler(req, db);
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(201);
|
||||||
const body = (await res.json()) as { error: string };
|
const body = (await res.json()) as { conversation: Conversation };
|
||||||
expect(body.error).toContain("模型");
|
expect(body.conversation.modelId).toBeNull();
|
||||||
handle.close();
|
handle.close();
|
||||||
} finally {
|
} finally {
|
||||||
handle.cleanup();
|
handle.cleanup();
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ function createTestModel(db: Database, pName: string, providerId?: string): Mode
|
|||||||
db,
|
db,
|
||||||
{
|
{
|
||||||
capabilities: ["text"],
|
capabilities: ["text"],
|
||||||
modelId: pName.toLowerCase().replace(/[^a-z0-9-]/g, "-"),
|
externalId: pName.toLowerCase().replace(/[^a-z0-9-]/g, "-"),
|
||||||
name: pName,
|
name: pName,
|
||||||
providerId: pid,
|
providerId: pid,
|
||||||
},
|
},
|
||||||
@@ -93,7 +93,7 @@ describe("models API routes", () => {
|
|||||||
const req = new Request("http://localhost/api/models", {
|
const req = new Request("http://localhost/api/models", {
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
capabilities: ["text", "reasoning"],
|
capabilities: ["text", "reasoning"],
|
||||||
modelId: "gpt-4o",
|
externalId: "gpt-4o",
|
||||||
name: "GPT-4o",
|
name: "GPT-4o",
|
||||||
providerId,
|
providerId,
|
||||||
}),
|
}),
|
||||||
@@ -104,7 +104,7 @@ describe("models API routes", () => {
|
|||||||
expect(res.status).toBe(201);
|
expect(res.status).toBe(201);
|
||||||
const body = (await res.json()) as { model: Model };
|
const body = (await res.json()) as { model: Model };
|
||||||
expect(body.model.name).toBe("GPT-4o");
|
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 () => {
|
test("GET /api/models filter by capabilities", async () => {
|
||||||
await withRouteDb(async (db) => {
|
await withRouteDb(async (db) => {
|
||||||
const p = seedProvider(db, "CapP");
|
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(
|
createModel(
|
||||||
db,
|
db,
|
||||||
{ capabilities: ["reasoning"], modelId: "reasoning-1", name: "ReasoningModel", providerId: p },
|
{ capabilities: ["reasoning"], externalId: "reasoning-1", name: "ReasoningModel", providerId: p },
|
||||||
LOG,
|
LOG,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -228,7 +228,7 @@ describe("models API routes", () => {
|
|||||||
const req = new Request("http://localhost/api/models", {
|
const req = new Request("http://localhost/api/models", {
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
capabilities: ["invalid-cap"],
|
capabilities: ["invalid-cap"],
|
||||||
modelId: "test",
|
externalId: "test",
|
||||||
name: "Test",
|
name: "Test",
|
||||||
providerId,
|
providerId,
|
||||||
}),
|
}),
|
||||||
@@ -248,7 +248,7 @@ describe("models API routes", () => {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
capabilities: ["text"],
|
capabilities: ["text"],
|
||||||
contextLength: 0,
|
contextLength: 0,
|
||||||
modelId: "test",
|
externalId: "test",
|
||||||
name: "Test",
|
name: "Test",
|
||||||
providerId,
|
providerId,
|
||||||
}),
|
}),
|
||||||
@@ -274,7 +274,7 @@ describe("models API routes", () => {
|
|||||||
const providerId = seedProvider(db);
|
const providerId = seedProvider(db);
|
||||||
|
|
||||||
const req = new Request("http://localhost/api/models/test", {
|
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" },
|
headers: { "Content-Type": "application/json" },
|
||||||
method: "POST",
|
method: "POST",
|
||||||
});
|
});
|
||||||
@@ -289,7 +289,7 @@ describe("models API routes", () => {
|
|||||||
test("POST /api/models/test 缺少 providerId 返回 400", async () => {
|
test("POST /api/models/test 缺少 providerId 返回 400", async () => {
|
||||||
await withRouteDb(async (db) => {
|
await withRouteDb(async (db) => {
|
||||||
const req = new Request("http://localhost/api/models/test", {
|
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" },
|
headers: { "Content-Type": "application/json" },
|
||||||
method: "POST",
|
method: "POST",
|
||||||
});
|
});
|
||||||
@@ -301,7 +301,7 @@ describe("models API routes", () => {
|
|||||||
test("POST /api/models/test 不存在的供应商返回 404", async () => {
|
test("POST /api/models/test 不存在的供应商返回 404", async () => {
|
||||||
await withRouteDb(async (db) => {
|
await withRouteDb(async (db) => {
|
||||||
const req = new Request("http://localhost/api/models/test", {
|
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" },
|
headers: { "Content-Type": "application/json" },
|
||||||
method: "POST",
|
method: "POST",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -236,7 +236,7 @@ describe("供应商 API 路由", () => {
|
|||||||
db,
|
db,
|
||||||
{
|
{
|
||||||
capabilities: ["text"],
|
capabilities: ["text"],
|
||||||
modelId: "gpt-4o",
|
externalId: "gpt-4o",
|
||||||
name: "GPT-4o",
|
name: "GPT-4o",
|
||||||
providerId: provider.id,
|
providerId: provider.id,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import { installFetchMock, jsonResponse, renderWithProviders } from "../test-uti
|
|||||||
const PROJECT_ID = "proj-1";
|
const PROJECT_ID = "proj-1";
|
||||||
|
|
||||||
const MOCK_PROJECT: Project = {
|
const MOCK_PROJECT: Project = {
|
||||||
archivedAt: null,
|
|
||||||
createdAt: "2026-01-01T00:00:00.000Z",
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
description: "",
|
description: "",
|
||||||
id: PROJECT_ID,
|
id: PROJECT_ID,
|
||||||
@@ -24,9 +23,9 @@ const TEXT_MODEL: Model = {
|
|||||||
capabilities: ["text"],
|
capabilities: ["text"],
|
||||||
contextLength: null,
|
contextLength: null,
|
||||||
createdAt: "2024-01-01T00:00:00.000Z",
|
createdAt: "2024-01-01T00:00:00.000Z",
|
||||||
|
externalId: "gpt-4o",
|
||||||
id: "model-1",
|
id: "model-1",
|
||||||
maxOutputTokens: null,
|
maxOutputTokens: null,
|
||||||
modelId: "gpt-4o",
|
|
||||||
name: "GPT-4o",
|
name: "GPT-4o",
|
||||||
providerId: "pv1",
|
providerId: "pv1",
|
||||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ const TEXT_MODEL: Model = {
|
|||||||
capabilities: ["text"],
|
capabilities: ["text"],
|
||||||
contextLength: null,
|
contextLength: null,
|
||||||
createdAt: "2024-01-01T00:00:00.000Z",
|
createdAt: "2024-01-01T00:00:00.000Z",
|
||||||
|
externalId: "gpt-4o",
|
||||||
id: "model-1",
|
id: "model-1",
|
||||||
maxOutputTokens: null,
|
maxOutputTokens: null,
|
||||||
modelId: "gpt-4o",
|
|
||||||
name: "GPT-4o",
|
name: "GPT-4o",
|
||||||
providerId: "pv1",
|
providerId: "pv1",
|
||||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||||
|
|||||||
@@ -24,9 +24,9 @@ const ENABLED_MODEL: Model = {
|
|||||||
capabilities: ["text", "reasoning"],
|
capabilities: ["text", "reasoning"],
|
||||||
contextLength: 128000,
|
contextLength: 128000,
|
||||||
createdAt: "2024-01-01T00:00:00.000Z",
|
createdAt: "2024-01-01T00:00:00.000Z",
|
||||||
|
externalId: "gpt-4o",
|
||||||
id: "m1",
|
id: "m1",
|
||||||
maxOutputTokens: 4096,
|
maxOutputTokens: 4096,
|
||||||
modelId: "gpt-4o",
|
|
||||||
name: "GPT-4o",
|
name: "GPT-4o",
|
||||||
providerId: "pv1",
|
providerId: "pv1",
|
||||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||||
@@ -36,9 +36,9 @@ const DISABLED_MODEL: Model = {
|
|||||||
capabilities: ["text"],
|
capabilities: ["text"],
|
||||||
contextLength: null,
|
contextLength: null,
|
||||||
createdAt: "2024-01-01T00:00:00.000Z",
|
createdAt: "2024-01-01T00:00:00.000Z",
|
||||||
|
externalId: "deepseek-chat",
|
||||||
id: "m2",
|
id: "m2",
|
||||||
maxOutputTokens: null,
|
maxOutputTokens: null,
|
||||||
modelId: "deepseek-chat",
|
|
||||||
name: "DeepSeek Chat",
|
name: "DeepSeek Chat",
|
||||||
providerId: "pv2",
|
providerId: "pv2",
|
||||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { ProjectContext } from "../../../../src/web/shared/hooks/use-current-pro
|
|||||||
import { installFetchMock, jsonResponse, renderWithProviders } from "../../test-utils";
|
import { installFetchMock, jsonResponse, renderWithProviders } from "../../test-utils";
|
||||||
|
|
||||||
const MOCK_PROJECT: Project = {
|
const MOCK_PROJECT: Project = {
|
||||||
archivedAt: null,
|
|
||||||
createdAt: "2026-01-01T00:00:00.000Z",
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
description: "",
|
description: "",
|
||||||
id: "project-1",
|
id: "project-1",
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ const MODEL = {
|
|||||||
capabilities: ["text"] as Array<"text">,
|
capabilities: ["text"] as Array<"text">,
|
||||||
contextLength: null,
|
contextLength: null,
|
||||||
createdAt: "2024-01-01T00:00:00.000Z",
|
createdAt: "2024-01-01T00:00:00.000Z",
|
||||||
|
externalId: "gpt-4o",
|
||||||
id: "m1",
|
id: "m1",
|
||||||
maxOutputTokens: null,
|
maxOutputTokens: null,
|
||||||
modelId: "gpt-4o",
|
|
||||||
name: "GPT-4o",
|
name: "GPT-4o",
|
||||||
providerId: "pv1",
|
providerId: "pv1",
|
||||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||||
@@ -59,7 +59,7 @@ describe("use-models request helpers", () => {
|
|||||||
|
|
||||||
await createModel({
|
await createModel({
|
||||||
capabilities: ["text"],
|
capabilities: ["text"],
|
||||||
modelId: "gpt-4o",
|
externalId: "gpt-4o",
|
||||||
name: "GPT-4o",
|
name: "GPT-4o",
|
||||||
providerId: "pv1",
|
providerId: "pv1",
|
||||||
});
|
});
|
||||||
@@ -75,7 +75,7 @@ describe("use-models request helpers", () => {
|
|||||||
]);
|
]);
|
||||||
expect(jsonBody(calls[0]?.body)).toEqual({
|
expect(jsonBody(calls[0]?.body)).toEqual({
|
||||||
capabilities: ["text"],
|
capabilities: ["text"],
|
||||||
modelId: "gpt-4o",
|
externalId: "gpt-4o",
|
||||||
name: "GPT-4o",
|
name: "GPT-4o",
|
||||||
providerId: "pv1",
|
providerId: "pv1",
|
||||||
});
|
});
|
||||||
@@ -86,7 +86,7 @@ describe("use-models request helpers", () => {
|
|||||||
installFetchMock(() => jsonResponse({ error: "模型名称已存在" }, { status: 409 }));
|
installFetchMock(() => jsonResponse({ error: "模型名称已存在" }, { status: 409 }));
|
||||||
|
|
||||||
await expectRejectsWithMessage(
|
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 () => {
|
test("testModelConnection 调用正确 URL 和 body", async () => {
|
||||||
const calls = installFetchMock(() => jsonResponse({ modelTestResponse: { message: "模型连接成功", ok: true } }));
|
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.ok).toBe(true);
|
||||||
expect(result.message).toBe("模型连接成功");
|
expect(result.message).toBe("模型连接成功");
|
||||||
expect(calls[0]?.method).toBe("POST");
|
expect(calls[0]?.method).toBe("POST");
|
||||||
expect(calls[0]?.url).toBe("/api/models/test");
|
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" });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
import { installFetchMock, jsonResponse } from "../test-utils";
|
import { installFetchMock, jsonResponse } from "../test-utils";
|
||||||
|
|
||||||
const PROJECT = {
|
const PROJECT = {
|
||||||
archivedAt: null,
|
|
||||||
createdAt: "2024-01-01T00:00:00.000Z",
|
createdAt: "2024-01-01T00:00:00.000Z",
|
||||||
description: "描述",
|
description: "描述",
|
||||||
id: "p1",
|
id: "p1",
|
||||||
|
|||||||
@@ -32,9 +32,9 @@ const ENABLED_MODEL: Model = {
|
|||||||
capabilities: ["text", "reasoning"],
|
capabilities: ["text", "reasoning"],
|
||||||
contextLength: 128000,
|
contextLength: 128000,
|
||||||
createdAt: "2024-01-01T00:00:00.000Z",
|
createdAt: "2024-01-01T00:00:00.000Z",
|
||||||
|
externalId: "gpt-4o",
|
||||||
id: "m1",
|
id: "m1",
|
||||||
maxOutputTokens: 4096,
|
maxOutputTokens: 4096,
|
||||||
modelId: "gpt-4o",
|
|
||||||
name: "GPT-4o",
|
name: "GPT-4o",
|
||||||
providerId: "pv1",
|
providerId: "pv1",
|
||||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||||
@@ -190,7 +190,7 @@ describe("ModelFormModal", () => {
|
|||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(testModelConnection).toHaveBeenCalledWith({
|
expect(testModelConnection).toHaveBeenCalledWith({
|
||||||
modelId: "gpt-4o",
|
externalId: "gpt-4o",
|
||||||
providerId: "pv1",
|
providerId: "pv1",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -231,9 +231,9 @@ const TEST_MODEL: Model = {
|
|||||||
capabilities: ["text"],
|
capabilities: ["text"],
|
||||||
contextLength: 128000,
|
contextLength: 128000,
|
||||||
createdAt: "2024-01-01T00:00:00.000Z",
|
createdAt: "2024-01-01T00:00:00.000Z",
|
||||||
|
externalId: "gpt-4o",
|
||||||
id: "m1",
|
id: "m1",
|
||||||
maxOutputTokens: 4096,
|
maxOutputTokens: 4096,
|
||||||
modelId: "gpt-4o",
|
|
||||||
name: "GPT-4o",
|
name: "GPT-4o",
|
||||||
providerId: "pv1",
|
providerId: "pv1",
|
||||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||||
@@ -281,7 +281,7 @@ function createModelFetchMock() {
|
|||||||
|
|
||||||
if (url.pathname === "/api/models" && call.method === "GET") {
|
if (url.pathname === "/api/models" && call.method === "GET") {
|
||||||
const keyword = url.searchParams.get("keyword") ?? "";
|
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 });
|
return jsonResponse({ items, page: 1, pageSize: 20, total: items.length });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import { ProjectTable } from "../../../src/web/features/projects/components/Proj
|
|||||||
import { installFetchMock, jsonResponse, mockMetaResponse, renderWithProviders } from "../test-utils";
|
import { installFetchMock, jsonResponse, mockMetaResponse, renderWithProviders } from "../test-utils";
|
||||||
|
|
||||||
const ACTIVE_PROJECT: Project = {
|
const ACTIVE_PROJECT: Project = {
|
||||||
archivedAt: null,
|
|
||||||
createdAt: "2024-01-01T00:00:00.000Z",
|
createdAt: "2024-01-01T00:00:00.000Z",
|
||||||
description: "活跃描述",
|
description: "活跃描述",
|
||||||
id: "p1",
|
id: "p1",
|
||||||
@@ -21,7 +20,6 @@ const ACTIVE_PROJECT: Project = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ARCHIVED_PROJECT: Project = {
|
const ARCHIVED_PROJECT: Project = {
|
||||||
archivedAt: "2024-01-02T00:00:00.000Z",
|
|
||||||
createdAt: "2024-01-01T00:00:00.000Z",
|
createdAt: "2024-01-01T00:00:00.000Z",
|
||||||
description: "归档描述",
|
description: "归档描述",
|
||||||
id: "p2",
|
id: "p2",
|
||||||
@@ -57,7 +55,6 @@ function createProjectFetchMock(initialProjects: Project[] = [ACTIVE_PROJECT, AR
|
|||||||
if (url.pathname === "/api/projects" && call.method === "POST") {
|
if (url.pathname === "/api/projects" && call.method === "POST") {
|
||||||
const data = jsonBody(call.body) as { description?: string; name: string };
|
const data = jsonBody(call.body) as { description?: string; name: string };
|
||||||
const created: Project = {
|
const created: Project = {
|
||||||
archivedAt: null,
|
|
||||||
createdAt: "2024-01-03T00:00:00.000Z",
|
createdAt: "2024-01-03T00:00:00.000Z",
|
||||||
description: data.description ?? "",
|
description: data.description ?? "",
|
||||||
id: "p-created",
|
id: "p-created",
|
||||||
@@ -83,13 +80,13 @@ function createProjectFetchMock(initialProjects: Project[] = [ACTIVE_PROJECT, AR
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (call.method === "POST" && action === "archive") {
|
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));
|
projects = projects.map((item) => (item.id === id ? archived : item));
|
||||||
return jsonResponse({ project: archived });
|
return jsonResponse({ project: archived });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (call.method === "POST" && action === "restore") {
|
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));
|
projects = projects.map((item) => (item.id === id ? restored : item));
|
||||||
return jsonResponse({ project: restored });
|
return jsonResponse({ project: restored });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { App } from "../../../src/web/app";
|
|||||||
import { renderWithProviders } from "../test-utils";
|
import { renderWithProviders } from "../test-utils";
|
||||||
|
|
||||||
const MOCK_PROJECT = {
|
const MOCK_PROJECT = {
|
||||||
archivedAt: null,
|
|
||||||
createdAt: "2024-01-01T00:00:00.000Z",
|
createdAt: "2024-01-01T00:00:00.000Z",
|
||||||
description: "测试项目",
|
description: "测试项目",
|
||||||
id: "test-project-id",
|
id: "test-project-id",
|
||||||
@@ -15,7 +14,7 @@ const MOCK_PROJECT = {
|
|||||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
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 project = { ...MOCK_PROJECT, ...overrides };
|
||||||
const handler = (input: RequestInfo | URL) => {
|
const handler = (input: RequestInfo | URL) => {
|
||||||
const url = input instanceof Request ? input.url : typeof input === "string" ? input : input.toString();
|
const url = input instanceof Request ? input.url : typeof input === "string" ? input : input.toString();
|
||||||
@@ -92,7 +91,7 @@ describe("Workbench 路由", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("archived 项目显示不可访问", async () => {
|
test("archived 项目显示不可访问", async () => {
|
||||||
createMockHandler({ archivedAt: "2024-06-01T00:00:00.000Z", status: "archived" });
|
createMockHandler({ status: "archived" });
|
||||||
|
|
||||||
renderWithProviders(createElement(App), {
|
renderWithProviders(createElement(App), {
|
||||||
initialRoute: `/workbench/${MOCK_PROJECT.id}`,
|
initialRoute: `/workbench/${MOCK_PROJECT.id}`,
|
||||||
|
|||||||
Reference in New Issue
Block a user