1use super::{lock_conn, Database, SCHEMA_VERSION};
6use crate::error::AppError;
7use rusqlite::{params, Connection};
8
9impl Database {
10 pub(crate) fn create_tables(&self) -> Result<(), AppError> {
12 let conn = lock_conn!(self.conn);
13 Self::create_tables_on_conn(&conn)
14 }
15
16 pub(crate) fn create_tables_on_conn(conn: &Connection) -> Result<(), AppError> {
18 conn.execute(
20 "CREATE TABLE IF NOT EXISTS providers (
21 id TEXT NOT NULL,
22 app_type TEXT NOT NULL,
23 name TEXT NOT NULL,
24 settings_config TEXT NOT NULL,
25 website_url TEXT,
26 category TEXT,
27 created_at INTEGER,
28 sort_index INTEGER,
29 notes TEXT,
30 icon TEXT,
31 icon_color TEXT,
32 meta TEXT NOT NULL DEFAULT '{}',
33 is_current BOOLEAN NOT NULL DEFAULT 0,
34 in_failover_queue BOOLEAN NOT NULL DEFAULT 0,
35 PRIMARY KEY (id, app_type)
36 )",
37 [],
38 )
39 .map_err(|e| AppError::Database(e.to_string()))?;
40
41 conn.execute(
43 "CREATE TABLE IF NOT EXISTS provider_endpoints (
44 id INTEGER PRIMARY KEY AUTOINCREMENT,
45 provider_id TEXT NOT NULL,
46 app_type TEXT NOT NULL,
47 url TEXT NOT NULL,
48 added_at INTEGER,
49 FOREIGN KEY (provider_id, app_type) REFERENCES providers(id, app_type) ON DELETE CASCADE
50 )",
51 [],
52 )
53 .map_err(|e| AppError::Database(e.to_string()))?;
54
55 conn.execute(
57 "CREATE TABLE IF NOT EXISTS mcp_servers (
58 id TEXT PRIMARY KEY, name TEXT NOT NULL, server_config TEXT NOT NULL,
59 description TEXT, homepage TEXT, docs TEXT, tags TEXT NOT NULL DEFAULT '[]',
60 enabled_claude BOOLEAN NOT NULL DEFAULT 0, enabled_codex BOOLEAN NOT NULL DEFAULT 0,
61 enabled_gemini BOOLEAN NOT NULL DEFAULT 0, enabled_opencode BOOLEAN NOT NULL DEFAULT 0,
62 enabled_openclaw BOOLEAN NOT NULL DEFAULT 0,
63 enabled_hermes BOOLEAN NOT NULL DEFAULT 0
64 )",
65 [],
66 )
67 .map_err(|e| AppError::Database(e.to_string()))?;
68
69 conn.execute("CREATE TABLE IF NOT EXISTS prompts (
71 id TEXT NOT NULL, app_type TEXT NOT NULL, name TEXT NOT NULL, content TEXT NOT NULL,
72 description TEXT, enabled BOOLEAN NOT NULL DEFAULT 1, created_at INTEGER, updated_at INTEGER,
73 PRIMARY KEY (id, app_type)
74 )", []).map_err(|e| AppError::Database(e.to_string()))?;
75
76 conn.execute(
78 "CREATE TABLE IF NOT EXISTS skills (
79 id TEXT PRIMARY KEY,
80 name TEXT NOT NULL,
81 description TEXT,
82 directory TEXT NOT NULL,
83 repo_owner TEXT,
84 repo_name TEXT,
85 repo_branch TEXT DEFAULT 'main',
86 readme_url TEXT,
87 enabled_claude BOOLEAN NOT NULL DEFAULT 0,
88 enabled_codex BOOLEAN NOT NULL DEFAULT 0,
89 enabled_gemini BOOLEAN NOT NULL DEFAULT 0,
90 enabled_opencode BOOLEAN NOT NULL DEFAULT 0,
91 enabled_openclaw BOOLEAN NOT NULL DEFAULT 0,
92 enabled_hermes BOOLEAN NOT NULL DEFAULT 0,
93 installed_at INTEGER NOT NULL DEFAULT 0,
94 content_hash TEXT,
95 updated_at INTEGER NOT NULL DEFAULT 0
96 )",
97 [],
98 )
99 .map_err(|e| AppError::Database(e.to_string()))?;
100
101 conn.execute(
103 "CREATE TABLE IF NOT EXISTS skill_repos (
104 owner TEXT NOT NULL, name TEXT NOT NULL, branch TEXT NOT NULL DEFAULT 'main',
105 enabled BOOLEAN NOT NULL DEFAULT 1, PRIMARY KEY (owner, name)
106 )",
107 [],
108 )
109 .map_err(|e| AppError::Database(e.to_string()))?;
110
111 conn.execute(
113 "CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT)",
114 [],
115 )
116 .map_err(|e| AppError::Database(e.to_string()))?;
117
118 conn.execute("CREATE TABLE IF NOT EXISTS proxy_config (
120 app_type TEXT PRIMARY KEY CHECK (app_type IN ('claude','codex','gemini')),
121 proxy_enabled INTEGER NOT NULL DEFAULT 0, listen_address TEXT NOT NULL DEFAULT '127.0.0.1',
122 listen_port INTEGER NOT NULL DEFAULT 15721, enable_logging INTEGER NOT NULL DEFAULT 1,
123 enabled INTEGER NOT NULL DEFAULT 0, auto_failover_enabled INTEGER NOT NULL DEFAULT 0,
124 max_retries INTEGER NOT NULL DEFAULT 3, streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 60,
125 streaming_idle_timeout INTEGER NOT NULL DEFAULT 120, non_streaming_timeout INTEGER NOT NULL DEFAULT 600,
126 circuit_failure_threshold INTEGER NOT NULL DEFAULT 4, circuit_success_threshold INTEGER NOT NULL DEFAULT 2,
127 circuit_timeout_seconds INTEGER NOT NULL DEFAULT 60, circuit_error_rate_threshold REAL NOT NULL DEFAULT 0.6,
128 circuit_min_requests INTEGER NOT NULL DEFAULT 10,
129 default_cost_multiplier TEXT NOT NULL DEFAULT '1',
130 pricing_model_source TEXT NOT NULL DEFAULT 'response',
131 created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now'))
132 )", []).map_err(|e| AppError::Database(e.to_string()))?;
133
134 if Self::has_column(conn, "proxy_config", "app_type")? {
140 conn.execute(
141 "INSERT OR IGNORE INTO proxy_config (app_type, max_retries,
142 streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
143 circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
144 circuit_error_rate_threshold, circuit_min_requests)
145 VALUES ('claude', 6, 90, 180, 600, 8, 3, 90, 0.7, 15)",
146 [],
147 )
148 .map_err(|e| AppError::Database(e.to_string()))?;
149 conn.execute(
150 "INSERT OR IGNORE INTO proxy_config (app_type, max_retries,
151 streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
152 circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
153 circuit_error_rate_threshold, circuit_min_requests)
154 VALUES ('codex', 3, 60, 120, 600, 4, 2, 60, 0.6, 10)",
155 [],
156 )
157 .map_err(|e| AppError::Database(e.to_string()))?;
158 conn.execute(
159 "INSERT OR IGNORE INTO proxy_config (app_type, max_retries,
160 streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
161 circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
162 circuit_error_rate_threshold, circuit_min_requests)
163 VALUES ('gemini', 5, 60, 120, 600, 4, 2, 60, 0.6, 10)",
164 [],
165 )
166 .map_err(|e| AppError::Database(e.to_string()))?;
167 }
168
169 conn.execute("CREATE TABLE IF NOT EXISTS provider_health (
171 provider_id TEXT NOT NULL, app_type TEXT NOT NULL, is_healthy INTEGER NOT NULL DEFAULT 1,
172 consecutive_failures INTEGER NOT NULL DEFAULT 0, last_success_at TEXT, last_failure_at TEXT,
173 last_error TEXT, updated_at TEXT NOT NULL,
174 PRIMARY KEY (provider_id, app_type),
175 FOREIGN KEY (provider_id, app_type) REFERENCES providers(id, app_type) ON DELETE CASCADE
176 )", []).map_err(|e| AppError::Database(e.to_string()))?;
177
178 conn.execute("CREATE TABLE IF NOT EXISTS proxy_request_logs (
180 request_id TEXT PRIMARY KEY, provider_id TEXT NOT NULL, app_type TEXT NOT NULL, model TEXT NOT NULL,
181 request_model TEXT,
182 input_tokens INTEGER NOT NULL DEFAULT 0, output_tokens INTEGER NOT NULL DEFAULT 0,
183 cache_read_tokens INTEGER NOT NULL DEFAULT 0, cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
184 input_cost_usd TEXT NOT NULL DEFAULT '0', output_cost_usd TEXT NOT NULL DEFAULT '0',
185 cache_read_cost_usd TEXT NOT NULL DEFAULT '0', cache_creation_cost_usd TEXT NOT NULL DEFAULT '0',
186 total_cost_usd TEXT NOT NULL DEFAULT '0', latency_ms INTEGER NOT NULL, first_token_ms INTEGER,
187 duration_ms INTEGER, status_code INTEGER NOT NULL, error_message TEXT, session_id TEXT,
188 provider_type TEXT, is_streaming INTEGER NOT NULL DEFAULT 0,
189 cost_multiplier TEXT NOT NULL DEFAULT '1.0', created_at INTEGER NOT NULL,
190 data_source TEXT NOT NULL DEFAULT 'proxy'
191 )", []).map_err(|e| AppError::Database(e.to_string()))?;
192
193 conn.execute("CREATE INDEX IF NOT EXISTS idx_request_logs_provider ON proxy_request_logs(provider_id, app_type)", [])
194 .map_err(|e| AppError::Database(e.to_string()))?;
195 conn.execute("CREATE INDEX IF NOT EXISTS idx_request_logs_created_at ON proxy_request_logs(created_at)", [])
196 .map_err(|e| AppError::Database(e.to_string()))?;
197 conn.execute(
198 "CREATE INDEX IF NOT EXISTS idx_request_logs_model ON proxy_request_logs(model)",
199 [],
200 )
201 .map_err(|e| AppError::Database(e.to_string()))?;
202 conn.execute(
203 "CREATE INDEX IF NOT EXISTS idx_request_logs_session ON proxy_request_logs(session_id)",
204 [],
205 )
206 .map_err(|e| AppError::Database(e.to_string()))?;
207 conn.execute(
208 "CREATE INDEX IF NOT EXISTS idx_request_logs_status ON proxy_request_logs(status_code)",
209 [],
210 )
211 .map_err(|e| AppError::Database(e.to_string()))?;
212
213 conn.execute(
215 "CREATE TABLE IF NOT EXISTS model_pricing (
216 model_id TEXT PRIMARY KEY, display_name TEXT NOT NULL,
217 input_cost_per_million TEXT NOT NULL, output_cost_per_million TEXT NOT NULL,
218 cache_read_cost_per_million TEXT NOT NULL DEFAULT '0',
219 cache_creation_cost_per_million TEXT NOT NULL DEFAULT '0'
220 )",
221 [],
222 )
223 .map_err(|e| AppError::Database(e.to_string()))?;
224
225 conn.execute("CREATE TABLE IF NOT EXISTS stream_check_logs (
227 id INTEGER PRIMARY KEY AUTOINCREMENT, provider_id TEXT NOT NULL, provider_name TEXT NOT NULL,
228 app_type TEXT NOT NULL, status TEXT NOT NULL, success INTEGER NOT NULL, message TEXT NOT NULL,
229 response_time_ms INTEGER, http_status INTEGER, model_used TEXT,
230 retry_count INTEGER DEFAULT 0, tested_at INTEGER NOT NULL
231 )", []).map_err(|e| AppError::Database(e.to_string()))?;
232
233 conn.execute(
234 "CREATE INDEX IF NOT EXISTS idx_stream_check_logs_provider
235 ON stream_check_logs(app_type, provider_id, tested_at DESC)",
236 [],
237 )
238 .map_err(|e| AppError::Database(e.to_string()))?;
239
240 conn.execute(
244 "CREATE TABLE IF NOT EXISTS proxy_live_backup (
245 app_type TEXT PRIMARY KEY, original_config TEXT NOT NULL, backed_up_at TEXT NOT NULL
246 )",
247 [],
248 )
249 .map_err(|e| AppError::Database(e.to_string()))?;
250
251 conn.execute(
252 "CREATE TABLE IF NOT EXISTS usage_daily_rollups (
253 date TEXT NOT NULL,
254 app_type TEXT NOT NULL,
255 provider_id TEXT NOT NULL,
256 model TEXT NOT NULL,
257 request_count INTEGER NOT NULL DEFAULT 0,
258 success_count INTEGER NOT NULL DEFAULT 0,
259 input_tokens INTEGER NOT NULL DEFAULT 0,
260 output_tokens INTEGER NOT NULL DEFAULT 0,
261 cache_read_tokens INTEGER NOT NULL DEFAULT 0,
262 cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
263 total_cost_usd TEXT NOT NULL DEFAULT '0',
264 avg_latency_ms INTEGER NOT NULL DEFAULT 0,
265 PRIMARY KEY (date, app_type, provider_id, model)
266 )",
267 [],
268 )
269 .map_err(|e| AppError::Database(e.to_string()))?;
270
271 conn.execute(
272 "CREATE TABLE IF NOT EXISTS session_log_sync (
273 file_path TEXT PRIMARY KEY,
274 last_modified INTEGER NOT NULL,
275 last_line_offset INTEGER NOT NULL DEFAULT 0,
276 last_synced_at INTEGER NOT NULL
277 )",
278 [],
279 )
280 .map_err(|e| AppError::Database(e.to_string()))?;
281
282 let _ = conn.execute(
284 "ALTER TABLE proxy_config ADD COLUMN live_takeover_active INTEGER NOT NULL DEFAULT 0",
285 [],
286 );
287
288 let _ = conn.execute(
290 "ALTER TABLE proxy_config ADD COLUMN proxy_enabled INTEGER NOT NULL DEFAULT 0",
291 [],
292 );
293 let _ = conn.execute(
294 "ALTER TABLE proxy_config ADD COLUMN listen_address TEXT NOT NULL DEFAULT '127.0.0.1'",
295 [],
296 );
297 let _ = conn.execute(
298 "ALTER TABLE proxy_config ADD COLUMN listen_port INTEGER NOT NULL DEFAULT 15721",
299 [],
300 );
301 let _ = conn.execute(
302 "ALTER TABLE proxy_config ADD COLUMN enable_logging INTEGER NOT NULL DEFAULT 1",
303 [],
304 );
305
306 let _ = conn.execute(
308 "ALTER TABLE proxy_config ADD COLUMN streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 60",
309 [],
310 );
311 let _ = conn.execute(
312 "ALTER TABLE proxy_config ADD COLUMN streaming_idle_timeout INTEGER NOT NULL DEFAULT 120",
313 [],
314 );
315 let _ = conn.execute(
316 "ALTER TABLE proxy_config ADD COLUMN non_streaming_timeout INTEGER NOT NULL DEFAULT 600",
317 [],
318 );
319
320 if Self::table_exists(conn, "proxy_config")?
323 && !Self::has_column(conn, "proxy_config", "app_type")?
324 {
325 Self::migrate_proxy_config_to_per_app(conn)?;
326 }
327
328 Self::add_column_if_missing(
330 conn,
331 "providers",
332 "in_failover_queue",
333 "BOOLEAN NOT NULL DEFAULT 0",
334 )?;
335
336 let _ = conn.execute("DROP INDEX IF EXISTS idx_failover_queue_order", []);
338 let _ = conn.execute("DROP TABLE IF EXISTS failover_queue", []);
339
340 let _ = conn.execute(
342 "CREATE INDEX IF NOT EXISTS idx_providers_failover
343 ON providers(app_type, in_failover_queue, sort_index)",
344 [],
345 );
346
347 Ok(())
348 }
349
350 pub(crate) fn apply_schema_migrations(&self) -> Result<(), AppError> {
352 let conn = lock_conn!(self.conn);
353 Self::apply_schema_migrations_on_conn(&conn)
354 }
355
356 pub(crate) fn apply_schema_migrations_on_conn(conn: &Connection) -> Result<(), AppError> {
358 conn.execute("SAVEPOINT schema_migration;", [])
359 .map_err(|e| AppError::Database(format!("开启迁移 savepoint 失败: {e}")))?;
360
361 let mut version = Self::get_user_version(conn)?;
362
363 if version > SCHEMA_VERSION {
364 conn.execute("ROLLBACK TO schema_migration;", []).ok();
365 conn.execute("RELEASE schema_migration;", []).ok();
366 return Err(Self::future_schema_error(version));
367 }
368
369 let result = (|| {
370 while version < SCHEMA_VERSION {
371 match version {
372 0 => {
373 log::info!("检测到 user_version=0,迁移到 1(补齐缺失列并设置版本)");
374 Self::migrate_v0_to_v1(conn)?;
375 Self::set_user_version(conn, 1)?;
376 }
377 1 => {
378 log::info!(
379 "迁移数据库从 v1 到 v2(添加使用统计表和完整字段,重构 skills 表)"
380 );
381 Self::migrate_v1_to_v2(conn)?;
382 Self::set_user_version(conn, 2)?;
383 }
384 2 => {
385 log::info!("迁移数据库从 v2 到 v3(Skills 统一管理架构)");
386 Self::migrate_v2_to_v3(conn)?;
387 Self::set_user_version(conn, 3)?;
388 }
389 3 => {
390 log::info!("迁移数据库从 v3 到 v4(OpenCode 支持)");
391 Self::migrate_v3_to_v4(conn)?;
392 Self::set_user_version(conn, 4)?;
393 }
394 4 => {
395 log::info!("迁移数据库从 v4 到 v5(计费模式支持)");
396 Self::migrate_v4_to_v5(conn)?;
397 Self::set_user_version(conn, 5)?;
398 }
399 5 => {
400 log::info!("迁移数据库从 v5 到 v6(使用量聚合表 + Copilot 模板类型统一)");
401 Self::migrate_v5_to_v6(conn)?;
402 Self::set_user_version(conn, 6)?;
403 }
404 6 => {
405 log::info!("迁移数据库从 v6 到 v7(Skills 更新检测支持)");
406 Self::migrate_v6_to_v7(conn)?;
407 Self::set_user_version(conn, 7)?;
408 }
409 7 => {
410 log::info!("迁移数据库从 v7 到 v8(会话日志使用追踪 + 修正模型定价)");
411 Self::migrate_v7_to_v8(conn)?;
412 Self::set_user_version(conn, 8)?;
413 }
414 8 => {
415 log::info!("迁移数据库从 v8 到 v9(全面补充模型定价)");
416 Self::migrate_v8_to_v9(conn)?;
417 Self::set_user_version(conn, 9)?;
418 }
419 9 => {
420 log::info!("迁移数据库从 v9 到 v10(添加 Hermes Agent 支持)");
421 Self::migrate_v9_to_v10(conn)?;
422 Self::set_user_version(conn, 10)?;
423 }
424 10 => {
425 log::info!("迁移数据库从 v10 到 v11(添加 OpenClaw Skills 支持)");
426 Self::migrate_v10_to_v11(conn)?;
427 Self::set_user_version(conn, 11)?;
428 }
429 11 => {
430 log::info!("迁移数据库从 v11 到 v12(添加 OpenClaw MCP 支持)");
431 Self::migrate_v11_to_v12(conn)?;
432 Self::set_user_version(conn, 12)?;
433 }
434 _ => {
435 return Err(AppError::Database(format!(
436 "未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
437 )));
438 }
439 }
440 version = Self::get_user_version(conn)?;
441 }
442 Ok(())
443 })();
444
445 match result {
446 Ok(_) => {
447 conn.execute("RELEASE schema_migration;", [])
448 .map_err(|e| AppError::Database(format!("提交迁移 savepoint 失败: {e}")))?;
449 Ok(())
450 }
451 Err(e) => {
452 conn.execute("ROLLBACK TO schema_migration;", []).ok();
453 conn.execute("RELEASE schema_migration;", []).ok();
454 Err(e)
455 }
456 }
457 }
458
459 fn migrate_v0_to_v1(conn: &Connection) -> Result<(), AppError> {
461 Self::add_column_if_missing(conn, "providers", "category", "TEXT")?;
463 Self::add_column_if_missing(conn, "providers", "created_at", "INTEGER")?;
464 Self::add_column_if_missing(conn, "providers", "sort_index", "INTEGER")?;
465 Self::add_column_if_missing(conn, "providers", "notes", "TEXT")?;
466 Self::add_column_if_missing(conn, "providers", "icon", "TEXT")?;
467 Self::add_column_if_missing(conn, "providers", "icon_color", "TEXT")?;
468 Self::add_column_if_missing(conn, "providers", "meta", "TEXT NOT NULL DEFAULT '{}'")?;
469 Self::add_column_if_missing(
470 conn,
471 "providers",
472 "is_current",
473 "BOOLEAN NOT NULL DEFAULT 0",
474 )?;
475
476 Self::add_column_if_missing(conn, "provider_endpoints", "added_at", "INTEGER")?;
478
479 Self::add_column_if_missing(conn, "mcp_servers", "description", "TEXT")?;
481 Self::add_column_if_missing(conn, "mcp_servers", "homepage", "TEXT")?;
482 Self::add_column_if_missing(conn, "mcp_servers", "docs", "TEXT")?;
483 Self::add_column_if_missing(conn, "mcp_servers", "tags", "TEXT NOT NULL DEFAULT '[]'")?;
484 Self::add_column_if_missing(
485 conn,
486 "mcp_servers",
487 "enabled_codex",
488 "BOOLEAN NOT NULL DEFAULT 0",
489 )?;
490 Self::add_column_if_missing(
491 conn,
492 "mcp_servers",
493 "enabled_gemini",
494 "BOOLEAN NOT NULL DEFAULT 0",
495 )?;
496
497 Self::add_column_if_missing(conn, "prompts", "description", "TEXT")?;
499 Self::add_column_if_missing(conn, "prompts", "enabled", "BOOLEAN NOT NULL DEFAULT 1")?;
500 Self::add_column_if_missing(conn, "prompts", "created_at", "INTEGER")?;
501 Self::add_column_if_missing(conn, "prompts", "updated_at", "INTEGER")?;
502
503 Self::add_column_if_missing(conn, "skills", "installed_at", "INTEGER NOT NULL DEFAULT 0")?;
505
506 Self::add_column_if_missing(
508 conn,
509 "skill_repos",
510 "branch",
511 "TEXT NOT NULL DEFAULT 'main'",
512 )?;
513 Self::add_column_if_missing(conn, "skill_repos", "enabled", "BOOLEAN NOT NULL DEFAULT 1")?;
514 Ok(())
517 }
518
519 fn migrate_v1_to_v2(conn: &Connection) -> Result<(), AppError> {
521 Self::add_column_if_missing(
523 conn,
524 "providers",
525 "cost_multiplier",
526 "TEXT NOT NULL DEFAULT '1.0'",
527 )?;
528 Self::add_column_if_missing(conn, "providers", "limit_daily_usd", "TEXT")?;
529 Self::add_column_if_missing(conn, "providers", "limit_monthly_usd", "TEXT")?;
530 Self::add_column_if_missing(conn, "providers", "provider_type", "TEXT")?;
531 Self::add_column_if_missing(
532 conn,
533 "providers",
534 "in_failover_queue",
535 "BOOLEAN NOT NULL DEFAULT 0",
536 )?;
537
538 if Self::table_exists(conn, "proxy_config")? {
540 Self::add_column_if_missing(
542 conn,
543 "proxy_config",
544 "proxy_enabled",
545 "INTEGER NOT NULL DEFAULT 0",
546 )?;
547 Self::add_column_if_missing(
548 conn,
549 "proxy_config",
550 "listen_address",
551 "TEXT NOT NULL DEFAULT '127.0.0.1'",
552 )?;
553 Self::add_column_if_missing(
554 conn,
555 "proxy_config",
556 "listen_port",
557 "INTEGER NOT NULL DEFAULT 15721",
558 )?;
559 Self::add_column_if_missing(
560 conn,
561 "proxy_config",
562 "enable_logging",
563 "INTEGER NOT NULL DEFAULT 1",
564 )?;
565
566 Self::add_column_if_missing(
567 conn,
568 "proxy_config",
569 "streaming_first_byte_timeout",
570 "INTEGER NOT NULL DEFAULT 60",
571 )?;
572 Self::add_column_if_missing(
573 conn,
574 "proxy_config",
575 "streaming_idle_timeout",
576 "INTEGER NOT NULL DEFAULT 120",
577 )?;
578 Self::add_column_if_missing(
579 conn,
580 "proxy_config",
581 "non_streaming_timeout",
582 "INTEGER NOT NULL DEFAULT 600",
583 )?;
584 }
585
586 conn.execute("DROP INDEX IF EXISTS idx_failover_queue_order", [])
588 .map_err(|e| AppError::Database(format!("删除 failover_queue 索引失败: {e}")))?;
589 conn.execute("DROP TABLE IF EXISTS failover_queue", [])
590 .map_err(|e| AppError::Database(format!("删除 failover_queue 表失败: {e}")))?;
591
592 conn.execute(
594 "CREATE INDEX IF NOT EXISTS idx_providers_failover
595 ON providers(app_type, in_failover_queue, sort_index)",
596 [],
597 )
598 .map_err(|e| AppError::Database(format!("创建 failover 索引失败: {e}")))?;
599
600 conn.execute("CREATE TABLE IF NOT EXISTS proxy_request_logs (
602 request_id TEXT PRIMARY KEY, provider_id TEXT NOT NULL, app_type TEXT NOT NULL, model TEXT NOT NULL,
603 request_model TEXT,
604 input_tokens INTEGER NOT NULL DEFAULT 0, output_tokens INTEGER NOT NULL DEFAULT 0,
605 cache_read_tokens INTEGER NOT NULL DEFAULT 0, cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
606 input_cost_usd TEXT NOT NULL DEFAULT '0', output_cost_usd TEXT NOT NULL DEFAULT '0',
607 cache_read_cost_usd TEXT NOT NULL DEFAULT '0', cache_creation_cost_usd TEXT NOT NULL DEFAULT '0',
608 total_cost_usd TEXT NOT NULL DEFAULT '0', latency_ms INTEGER NOT NULL, first_token_ms INTEGER,
609 duration_ms INTEGER, status_code INTEGER NOT NULL, error_message TEXT, session_id TEXT,
610 provider_type TEXT, is_streaming INTEGER NOT NULL DEFAULT 0,
611 cost_multiplier TEXT NOT NULL DEFAULT '1.0', created_at INTEGER NOT NULL
612 )", [])?;
613
614 Self::add_column_if_missing(conn, "proxy_request_logs", "provider_type", "TEXT")?;
616 Self::add_column_if_missing(
617 conn,
618 "proxy_request_logs",
619 "is_streaming",
620 "INTEGER NOT NULL DEFAULT 0",
621 )?;
622 Self::add_column_if_missing(
623 conn,
624 "proxy_request_logs",
625 "cost_multiplier",
626 "TEXT NOT NULL DEFAULT '1.0'",
627 )?;
628 Self::add_column_if_missing(conn, "proxy_request_logs", "first_token_ms", "INTEGER")?;
629 Self::add_column_if_missing(conn, "proxy_request_logs", "duration_ms", "INTEGER")?;
630
631 conn.execute(
633 "CREATE TABLE IF NOT EXISTS model_pricing (
634 model_id TEXT PRIMARY KEY, display_name TEXT NOT NULL,
635 input_cost_per_million TEXT NOT NULL, output_cost_per_million TEXT NOT NULL,
636 cache_read_cost_per_million TEXT NOT NULL DEFAULT '0',
637 cache_creation_cost_per_million TEXT NOT NULL DEFAULT '0'
638 )",
639 [],
640 )?;
641
642 conn.execute("DELETE FROM model_pricing", [])
644 .map_err(|e| AppError::Database(format!("清空模型定价失败: {e}")))?;
645 Self::seed_model_pricing(conn)?;
646
647 Self::migrate_skills_table(conn)?;
649
650 Self::migrate_proxy_config_to_per_app(conn)?;
652
653 Ok(())
654 }
655
656 fn migrate_proxy_config_to_per_app(conn: &Connection) -> Result<(), AppError> {
658 if !Self::table_exists(conn, "proxy_config")? {
660 return Ok(());
662 }
663
664 if Self::has_column(conn, "proxy_config", "app_type")? {
665 log::info!("proxy_config 已经是三行结构,跳过迁移");
667 return Ok(());
668 }
669
670 let old_config = conn
672 .query_row(
673 "SELECT listen_address, listen_port, max_retries, enable_logging,
674 streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout
675 FROM proxy_config WHERE id = 1",
676 [],
677 |row| {
678 Ok((
679 row.get::<_, String>(0)?,
680 row.get::<_, i32>(1)?,
681 row.get::<_, i32>(2)?,
682 row.get::<_, i32>(3)?,
683 row.get::<_, i32>(4).unwrap_or(30),
684 row.get::<_, i32>(5).unwrap_or(60),
685 row.get::<_, i32>(6).unwrap_or(300),
686 ))
687 },
688 )
689 .unwrap_or_else(|_| ("127.0.0.1".to_string(), 5000, 3, 1, 30, 60, 300));
690
691 let old_cb = conn.query_row(
692 "SELECT failure_threshold, success_threshold, timeout_seconds, error_rate_threshold, min_requests
693 FROM circuit_breaker_config WHERE id = 1", [],
694 |row| Ok((row.get::<_, i32>(0)?, row.get::<_, i32>(1)?, row.get::<_, i64>(2)?,
695 row.get::<_, f64>(3)?, row.get::<_, i32>(4)?))
696 ).unwrap_or((5, 2, 60, 0.5, 10));
697
698 let get_bool = |key: &str| -> bool {
699 conn.query_row("SELECT value FROM settings WHERE key = ?", [key], |r| {
700 r.get::<_, String>(0)
701 })
702 .map(|v| v == "true" || v == "1")
703 .unwrap_or(false)
704 };
705
706 let apps = [
707 (
708 "claude",
709 get_bool("proxy_takeover_claude"),
710 get_bool("auto_failover_enabled_claude"),
711 6,
712 45,
713 90,
714 8,
715 3,
716 90,
717 0.6,
718 15,
719 ),
720 (
721 "codex",
722 get_bool("proxy_takeover_codex"),
723 get_bool("auto_failover_enabled_codex"),
724 3,
725 old_config.4,
726 old_config.5,
727 old_cb.0,
728 old_cb.1,
729 old_cb.2,
730 old_cb.3,
731 old_cb.4,
732 ),
733 (
734 "gemini",
735 get_bool("proxy_takeover_gemini"),
736 get_bool("auto_failover_enabled_gemini"),
737 5,
738 old_config.4,
739 old_config.5,
740 old_cb.0,
741 old_cb.1,
742 old_cb.2,
743 old_cb.3,
744 old_cb.4,
745 ),
746 ];
747
748 conn.execute("DROP TABLE IF EXISTS proxy_config_new", [])?;
750 conn.execute("CREATE TABLE proxy_config_new (
751 app_type TEXT PRIMARY KEY CHECK (app_type IN ('claude','codex','gemini')),
752 proxy_enabled INTEGER NOT NULL DEFAULT 0, listen_address TEXT NOT NULL DEFAULT '127.0.0.1',
753 listen_port INTEGER NOT NULL DEFAULT 15721, enable_logging INTEGER NOT NULL DEFAULT 1,
754 enabled INTEGER NOT NULL DEFAULT 0, auto_failover_enabled INTEGER NOT NULL DEFAULT 0,
755 max_retries INTEGER NOT NULL DEFAULT 3, streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 60,
756 streaming_idle_timeout INTEGER NOT NULL DEFAULT 120, non_streaming_timeout INTEGER NOT NULL DEFAULT 600,
757 circuit_failure_threshold INTEGER NOT NULL DEFAULT 4, circuit_success_threshold INTEGER NOT NULL DEFAULT 2,
758 circuit_timeout_seconds INTEGER NOT NULL DEFAULT 60, circuit_error_rate_threshold REAL NOT NULL DEFAULT 0.6,
759 circuit_min_requests INTEGER NOT NULL DEFAULT 10,
760 default_cost_multiplier TEXT NOT NULL DEFAULT '1',
761 pricing_model_source TEXT NOT NULL DEFAULT 'response',
762 created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now'))
763 )", [])?;
764
765 for (app, takeover, failover, retries, fb, idle, cb_f, cb_s, cb_t, cb_r, cb_m) in apps {
767 conn.execute(
768 "INSERT INTO proxy_config_new (app_type, proxy_enabled, listen_address, listen_port, enable_logging,
769 enabled, auto_failover_enabled, max_retries, streaming_first_byte_timeout, streaming_idle_timeout,
770 non_streaming_timeout, circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
771 circuit_error_rate_threshold, circuit_min_requests)
772 VALUES (?1, 0, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
773 rusqlite::params![app, old_config.0, old_config.1, old_config.3,
774 if takeover { 1 } else { 0 }, if failover { 1 } else { 0 },
775 retries, fb, idle, old_config.6, cb_f, cb_s, cb_t, cb_r, cb_m]
776 ).map_err(|e| AppError::Database(format!("插入 {app} 配置失败: {e}")))?;
777 }
778
779 conn.execute("DROP TABLE IF EXISTS proxy_config", [])?;
781 conn.execute("ALTER TABLE proxy_config_new RENAME TO proxy_config", [])?;
782 conn.execute("DROP TABLE IF EXISTS circuit_breaker_config", [])?;
783 conn.execute("DELETE FROM settings WHERE key LIKE 'proxy_takeover_%'", [])?;
784 conn.execute(
785 "DELETE FROM settings WHERE key LIKE 'auto_failover_enabled_%'",
786 [],
787 )?;
788
789 log::info!("proxy_config 已迁移为三行结构");
790 Ok(())
791 }
792
793 fn migrate_skills_table(conn: &Connection) -> Result<(), AppError> {
795 if Self::has_column(conn, "skills", "enabled_claude")?
800 || Self::has_column(conn, "skills", "id")?
801 {
802 log::info!("skills 表已经是 v3 结构,跳过 v1 -> v2 迁移");
803 return Ok(());
804 }
805
806 if Self::has_column(conn, "skills", "app_type")? {
808 log::info!("skills 表已经包含 app_type 字段,跳过迁移");
809 return Ok(());
810 }
811
812 log::info!("开始迁移 skills 表...");
813
814 conn.execute("ALTER TABLE skills RENAME TO skills_old", [])
816 .map_err(|e| AppError::Database(format!("重命名旧 skills 表失败: {e}")))?;
817
818 conn.execute(
820 "CREATE TABLE skills (
821 directory TEXT NOT NULL,
822 app_type TEXT NOT NULL,
823 installed BOOLEAN NOT NULL DEFAULT 0,
824 installed_at INTEGER NOT NULL DEFAULT 0,
825 PRIMARY KEY (directory, app_type)
826 )",
827 [],
828 )
829 .map_err(|e| AppError::Database(format!("创建新 skills 表失败: {e}")))?;
830
831 let mut stmt = conn
834 .prepare("SELECT key, installed, installed_at FROM skills_old")
835 .map_err(|e| AppError::Database(format!("查询旧 skills 数据失败: {e}")))?;
836
837 let old_skills: Vec<(String, bool, i64)> = stmt
838 .query_map([], |row| {
839 Ok((
840 row.get::<_, String>(0)?,
841 row.get::<_, bool>(1)?,
842 row.get::<_, i64>(2)?,
843 ))
844 })
845 .map_err(|e| AppError::Database(format!("读取旧 skills 数据失败: {e}")))?
846 .collect::<Result<Vec<_>, _>>()
847 .map_err(|e| AppError::Database(format!("解析旧 skills 数据失败: {e}")))?;
848
849 let count = old_skills.len();
850
851 for (key, installed, installed_at) in old_skills {
852 let (app_type, directory) = if let Some(idx) = key.find(':') {
854 let (app, dir) = key.split_at(idx);
855 (app.to_string(), dir[1..].to_string()) } else {
857 ("claude".to_string(), key.clone())
858 };
859
860 conn.execute(
861 "INSERT INTO skills (directory, app_type, installed, installed_at) VALUES (?1, ?2, ?3, ?4)",
862 rusqlite::params![directory, app_type, installed, installed_at],
863 )
864 .map_err(|e| {
865 AppError::Database(format!("迁移 skill {key} 到新表失败: {e}"))
866 })?;
867 }
868
869 conn.execute("DROP TABLE skills_old", [])
871 .map_err(|e| AppError::Database(format!("删除旧 skills 表失败: {e}")))?;
872
873 log::info!("skills 表迁移完成,共迁移 {count} 条记录");
874 Ok(())
875 }
876
877 fn migrate_v2_to_v3(conn: &Connection) -> Result<(), AppError> {
886 if Self::has_column(conn, "skills", "enabled_claude")? {
888 log::info!("skills 表已经是 v3 结构,跳过迁移");
889 return Ok(());
890 }
891
892 log::info!("开始迁移 skills 表到 v3 结构(统一管理架构)...");
893
894 let old_count: i64 = conn
896 .query_row("SELECT COUNT(*) FROM skills", [], |row| row.get(0))
897 .unwrap_or(0);
898 log::info!("旧 skills 表有 {old_count} 条记录");
899
900 let _ = conn.execute(
904 "INSERT OR REPLACE INTO settings (key, value) VALUES ('skills_ssot_migration_pending', 'true')",
905 [],
906 );
907
908 conn.execute("DROP TABLE IF EXISTS skills", [])
910 .map_err(|e| AppError::Database(format!("删除旧 skills 表失败: {e}")))?;
911
912 conn.execute(
914 "CREATE TABLE skills (
915 id TEXT PRIMARY KEY,
916 name TEXT NOT NULL,
917 description TEXT,
918 directory TEXT NOT NULL,
919 repo_owner TEXT,
920 repo_name TEXT,
921 repo_branch TEXT DEFAULT 'main',
922 readme_url TEXT,
923 enabled_claude BOOLEAN NOT NULL DEFAULT 0,
924 enabled_codex BOOLEAN NOT NULL DEFAULT 0,
925 enabled_gemini BOOLEAN NOT NULL DEFAULT 0,
926 installed_at INTEGER NOT NULL DEFAULT 0
927 )",
928 [],
929 )
930 .map_err(|e| AppError::Database(format!("创建新 skills 表失败: {e}")))?;
931
932 log::info!(
933 "skills 表已迁移到 v3 结构。\n\
934 注意:旧的安装记录已清除,首次启动时将自动扫描文件系统重建数据。"
935 );
936
937 Ok(())
938 }
939
940 fn migrate_v3_to_v4(conn: &Connection) -> Result<(), AppError> {
944 Self::add_column_if_missing(
946 conn,
947 "mcp_servers",
948 "enabled_opencode",
949 "BOOLEAN NOT NULL DEFAULT 0",
950 )?;
951
952 Self::add_column_if_missing(
954 conn,
955 "skills",
956 "enabled_opencode",
957 "BOOLEAN NOT NULL DEFAULT 0",
958 )?;
959
960 log::info!("v3 -> v4 迁移完成:已添加 OpenCode 支持");
961 Ok(())
962 }
963
964 fn migrate_v4_to_v5(conn: &Connection) -> Result<(), AppError> {
966 if Self::table_exists(conn, "proxy_config")? {
967 Self::add_column_if_missing(
968 conn,
969 "proxy_config",
970 "default_cost_multiplier",
971 "TEXT NOT NULL DEFAULT '1'",
972 )?;
973 Self::add_column_if_missing(
974 conn,
975 "proxy_config",
976 "pricing_model_source",
977 "TEXT NOT NULL DEFAULT 'response'",
978 )?;
979 }
980 if Self::table_exists(conn, "proxy_request_logs")? {
981 Self::add_column_if_missing(conn, "proxy_request_logs", "request_model", "TEXT")?;
982 }
983
984 log::info!("v4 -> v5 迁移完成:已添加计费模式与请求模型字段");
985 Ok(())
986 }
987
988 fn migrate_v5_to_v6(conn: &Connection) -> Result<(), AppError> {
989 conn.execute(
990 "CREATE TABLE IF NOT EXISTS usage_daily_rollups (
991 date TEXT NOT NULL,
992 app_type TEXT NOT NULL,
993 provider_id TEXT NOT NULL,
994 model TEXT NOT NULL,
995 request_count INTEGER NOT NULL DEFAULT 0,
996 success_count INTEGER NOT NULL DEFAULT 0,
997 input_tokens INTEGER NOT NULL DEFAULT 0,
998 output_tokens INTEGER NOT NULL DEFAULT 0,
999 cache_read_tokens INTEGER NOT NULL DEFAULT 0,
1000 cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
1001 total_cost_usd TEXT NOT NULL DEFAULT '0',
1002 avg_latency_ms INTEGER NOT NULL DEFAULT 0,
1003 PRIMARY KEY (date, app_type, provider_id, model)
1004 )",
1005 [],
1006 )
1007 .map_err(|e| AppError::Database(format!("创建 usage_daily_rollups 表失败: {e}")))?;
1008
1009 let mut stmt = conn
1010 .prepare("SELECT id, app_type, meta FROM providers")
1011 .map_err(|e| AppError::Database(e.to_string()))?;
1012
1013 let rows = stmt
1014 .query_map([], |row| {
1015 Ok((
1016 row.get::<_, String>(0)?,
1017 row.get::<_, String>(1)?,
1018 row.get::<_, String>(2)?,
1019 ))
1020 })
1021 .map_err(|e| AppError::Database(e.to_string()))?;
1022
1023 let mut updates = Vec::new();
1024 for row in rows {
1025 let (id, app_type, meta_str) = row.map_err(|e| AppError::Database(e.to_string()))?;
1026
1027 if let Ok(mut meta) = serde_json::from_str::<serde_json::Value>(&meta_str) {
1028 let mut updated = false;
1029
1030 if let Some(usage_script) = meta.get_mut("usage_script") {
1031 if let Some(template_type) = usage_script.get_mut("template_type") {
1032 if template_type == "copilot" {
1033 *template_type =
1034 serde_json::Value::String("github_copilot".to_string());
1035 updated = true;
1036 }
1037 }
1038 }
1039
1040 if updated {
1041 let new_meta_str = serde_json::to_string(&meta)
1042 .map_err(|e| AppError::Database(e.to_string()))?;
1043 updates.push((id, app_type, new_meta_str));
1044 }
1045 }
1046 }
1047
1048 for (id, app_type, new_meta) in updates {
1049 conn.execute(
1050 "UPDATE providers SET meta = ?1 WHERE id = ?2 AND app_type = ?3",
1051 params![new_meta, id, app_type],
1052 )
1053 .map_err(|e| AppError::Database(e.to_string()))?;
1054 }
1055
1056 log::info!("v5 -> v6 迁移完成:已添加使用量日聚合表,统一 copilot 模板类型");
1057 Ok(())
1058 }
1059
1060 fn migrate_v6_to_v7(conn: &Connection) -> Result<(), AppError> {
1061 if Self::table_exists(conn, "skills")? {
1062 Self::add_column_if_missing(conn, "skills", "content_hash", "TEXT")?;
1063 Self::add_column_if_missing(
1064 conn,
1065 "skills",
1066 "updated_at",
1067 "INTEGER NOT NULL DEFAULT 0",
1068 )?;
1069 }
1070
1071 log::info!("v6 -> v7 迁移完成:已添加 content_hash 和 updated_at 列");
1072 Ok(())
1073 }
1074
1075 fn migrate_v7_to_v8(conn: &Connection) -> Result<(), AppError> {
1076 if Self::table_exists(conn, "proxy_request_logs")? {
1077 Self::add_column_if_missing(
1078 conn,
1079 "proxy_request_logs",
1080 "data_source",
1081 "TEXT NOT NULL DEFAULT 'proxy'",
1082 )?;
1083 }
1084
1085 conn.execute(
1086 "CREATE TABLE IF NOT EXISTS session_log_sync (
1087 file_path TEXT PRIMARY KEY,
1088 last_modified INTEGER NOT NULL,
1089 last_line_offset INTEGER NOT NULL DEFAULT 0,
1090 last_synced_at INTEGER NOT NULL
1091 )",
1092 [],
1093 )
1094 .map_err(|e| AppError::Database(format!("创建 session_log_sync 表失败: {e}")))?;
1095
1096 if Self::table_exists(conn, "model_pricing")? {
1097 let pricing_fixes: &[(&str, &str, &str, &str, &str)] = &[
1098 ("deepseek-v3.2", "0.28", "0.42", "0.028", "0"),
1099 ("deepseek-v3.1", "0.55", "1.67", "0.055", "0"),
1100 ("deepseek-v3", "0.28", "1.11", "0.028", "0"),
1101 ("doubao-seed-code", "0.17", "1.11", "0.02", "0"),
1102 ("kimi-k2-thinking", "0.55", "2.20", "0.10", "0"),
1103 ("kimi-k2-0905", "0.55", "2.20", "0.10", "0"),
1104 ("kimi-k2-turbo", "1.11", "8.06", "0.14", "0"),
1105 ("minimax-m2.1", "0.27", "0.95", "0.03", "0"),
1106 ("minimax-m2.1-lightning", "0.27", "2.33", "0.03", "0"),
1107 ("minimax-m2", "0.27", "0.95", "0.03", "0"),
1108 ("glm-4.7", "0.39", "1.75", "0.04", "0"),
1109 ("glm-4.6", "0.28", "1.11", "0.03", "0"),
1110 ("mimo-v2-flash", "0.09", "0.29", "0.009", "0"),
1111 ];
1112
1113 for (model_id, input, output, cache_read, cache_creation) in pricing_fixes {
1114 conn.execute(
1115 "UPDATE model_pricing SET
1116 input_cost_per_million = ?2,
1117 output_cost_per_million = ?3,
1118 cache_read_cost_per_million = ?4,
1119 cache_creation_cost_per_million = ?5
1120 WHERE model_id = ?1",
1121 params![model_id, input, output, cache_read, cache_creation],
1122 )
1123 .map_err(|e| AppError::Database(format!("更新模型 {model_id} 定价失败: {e}")))?;
1124 }
1125 }
1126
1127 log::info!("v7 -> v8 迁移完成:data_source 列、session_log_sync 表、修正 13 个模型定价");
1128 Ok(())
1129 }
1130
1131 fn migrate_v8_to_v9(conn: &Connection) -> Result<(), AppError> {
1133 conn.execute(
1134 "CREATE TABLE IF NOT EXISTS model_pricing (
1135 model_id TEXT PRIMARY KEY, display_name TEXT NOT NULL,
1136 input_cost_per_million TEXT NOT NULL, output_cost_per_million TEXT NOT NULL,
1137 cache_read_cost_per_million TEXT NOT NULL DEFAULT '0',
1138 cache_creation_cost_per_million TEXT NOT NULL DEFAULT '0'
1139 )",
1140 [],
1141 )
1142 .map_err(|e| AppError::Database(format!("创建 model_pricing 表失败: {e}")))?;
1143 conn.execute("DELETE FROM model_pricing", [])
1144 .map_err(|e| AppError::Database(format!("清空模型定价失败: {e}")))?;
1145 Self::seed_model_pricing(conn)?;
1146 log::info!("v8 -> v9 迁移完成:已刷新全部模型定价数据");
1147 Ok(())
1148 }
1149
1150 fn migrate_v9_to_v10(conn: &Connection) -> Result<(), AppError> {
1152 Self::add_column_if_missing(
1153 conn,
1154 "mcp_servers",
1155 "enabled_hermes",
1156 "BOOLEAN NOT NULL DEFAULT 0",
1157 )?;
1158
1159 if Self::table_exists(conn, "skills")? {
1160 Self::add_column_if_missing(
1161 conn,
1162 "skills",
1163 "enabled_hermes",
1164 "BOOLEAN NOT NULL DEFAULT 0",
1165 )?;
1166 }
1167
1168 log::info!("v9 -> v10 迁移完成:已添加 Hermes Agent 支持");
1169 Ok(())
1170 }
1171
1172 fn migrate_v10_to_v11(conn: &Connection) -> Result<(), AppError> {
1174 if Self::table_exists(conn, "skills")? {
1175 Self::add_column_if_missing(
1176 conn,
1177 "skills",
1178 "enabled_openclaw",
1179 "BOOLEAN NOT NULL DEFAULT 0",
1180 )?;
1181 }
1182
1183 log::info!("v10 -> v11 迁移完成:已添加 OpenClaw Skills 支持");
1184 Ok(())
1185 }
1186
1187 fn migrate_v11_to_v12(conn: &Connection) -> Result<(), AppError> {
1189 if Self::table_exists(conn, "mcp_servers")? {
1190 Self::add_column_if_missing(
1191 conn,
1192 "mcp_servers",
1193 "enabled_openclaw",
1194 "BOOLEAN NOT NULL DEFAULT 0",
1195 )?;
1196 }
1197
1198 log::info!("v11 -> v12 迁移完成:已添加 OpenClaw MCP 支持");
1199 Ok(())
1200 }
1201
1202 fn seed_model_pricing(conn: &Connection) -> Result<(), AppError> {
1206 let pricing_data = [
1207 (
1209 "claude-opus-4-7",
1210 "Claude Opus 4.7",
1211 "5",
1212 "25",
1213 "0.50",
1214 "6.25",
1215 ),
1216 (
1218 "claude-opus-4-6-20260206",
1219 "Claude Opus 4.6",
1220 "5",
1221 "25",
1222 "0.50",
1223 "6.25",
1224 ),
1225 (
1226 "claude-sonnet-4-6-20260217",
1227 "Claude Sonnet 4.6",
1228 "3",
1229 "15",
1230 "0.30",
1231 "3.75",
1232 ),
1233 (
1235 "claude-opus-4-5-20251101",
1236 "Claude Opus 4.5",
1237 "5",
1238 "25",
1239 "0.50",
1240 "6.25",
1241 ),
1242 (
1243 "claude-sonnet-4-5-20250929",
1244 "Claude Sonnet 4.5",
1245 "3",
1246 "15",
1247 "0.30",
1248 "3.75",
1249 ),
1250 (
1251 "claude-haiku-4-5-20251001",
1252 "Claude Haiku 4.5",
1253 "1",
1254 "5",
1255 "0.10",
1256 "1.25",
1257 ),
1258 (
1260 "claude-opus-4-20250514",
1261 "Claude Opus 4",
1262 "15",
1263 "75",
1264 "1.50",
1265 "18.75",
1266 ),
1267 (
1268 "claude-opus-4-1-20250805",
1269 "Claude Opus 4.1",
1270 "15",
1271 "75",
1272 "1.50",
1273 "18.75",
1274 ),
1275 (
1276 "claude-sonnet-4-20250514",
1277 "Claude Sonnet 4",
1278 "3",
1279 "15",
1280 "0.30",
1281 "3.75",
1282 ),
1283 (
1285 "claude-3-5-haiku-20241022",
1286 "Claude 3.5 Haiku",
1287 "0.80",
1288 "4",
1289 "0.08",
1290 "1",
1291 ),
1292 (
1293 "claude-3-5-sonnet-20241022",
1294 "Claude 3.5 Sonnet",
1295 "3",
1296 "15",
1297 "0.30",
1298 "3.75",
1299 ),
1300 ("gpt-5.4", "GPT-5.4", "2.50", "15", "0.25", "0"),
1302 ("gpt-5.4-mini", "GPT-5.4 Mini", "0.75", "4.50", "0.075", "0"),
1303 ("gpt-5.4-nano", "GPT-5.4 Nano", "0.20", "1.25", "0.02", "0"),
1304 ("gpt-5.2", "GPT-5.2", "1.75", "14", "0.175", "0"),
1306 ("gpt-5.2-low", "GPT-5.2", "1.75", "14", "0.175", "0"),
1307 ("gpt-5.2-medium", "GPT-5.2", "1.75", "14", "0.175", "0"),
1308 ("gpt-5.2-high", "GPT-5.2", "1.75", "14", "0.175", "0"),
1309 ("gpt-5.2-xhigh", "GPT-5.2", "1.75", "14", "0.175", "0"),
1310 ("gpt-5.2-codex", "GPT-5.2 Codex", "1.75", "14", "0.175", "0"),
1311 (
1312 "gpt-5.2-codex-low",
1313 "GPT-5.2 Codex",
1314 "1.75",
1315 "14",
1316 "0.175",
1317 "0",
1318 ),
1319 (
1320 "gpt-5.2-codex-medium",
1321 "GPT-5.2 Codex",
1322 "1.75",
1323 "14",
1324 "0.175",
1325 "0",
1326 ),
1327 (
1328 "gpt-5.2-codex-high",
1329 "GPT-5.2 Codex",
1330 "1.75",
1331 "14",
1332 "0.175",
1333 "0",
1334 ),
1335 (
1336 "gpt-5.2-codex-xhigh",
1337 "GPT-5.2 Codex",
1338 "1.75",
1339 "14",
1340 "0.175",
1341 "0",
1342 ),
1343 ("gpt-5.3-codex", "GPT-5.3 Codex", "1.75", "14", "0.175", "0"),
1345 (
1346 "gpt-5.3-codex-low",
1347 "GPT-5.3 Codex",
1348 "1.75",
1349 "14",
1350 "0.175",
1351 "0",
1352 ),
1353 (
1354 "gpt-5.3-codex-medium",
1355 "GPT-5.3 Codex",
1356 "1.75",
1357 "14",
1358 "0.175",
1359 "0",
1360 ),
1361 (
1362 "gpt-5.3-codex-high",
1363 "GPT-5.3 Codex",
1364 "1.75",
1365 "14",
1366 "0.175",
1367 "0",
1368 ),
1369 (
1370 "gpt-5.3-codex-xhigh",
1371 "GPT-5.3 Codex",
1372 "1.75",
1373 "14",
1374 "0.175",
1375 "0",
1376 ),
1377 ("gpt-5.1", "GPT-5.1", "1.25", "10", "0.125", "0"),
1379 ("gpt-5.1-low", "GPT-5.1", "1.25", "10", "0.125", "0"),
1380 ("gpt-5.1-medium", "GPT-5.1", "1.25", "10", "0.125", "0"),
1381 ("gpt-5.1-high", "GPT-5.1", "1.25", "10", "0.125", "0"),
1382 ("gpt-5.1-minimal", "GPT-5.1", "1.25", "10", "0.125", "0"),
1383 ("gpt-5.1-codex", "GPT-5.1 Codex", "1.25", "10", "0.125", "0"),
1384 (
1385 "gpt-5.1-codex-mini",
1386 "GPT-5.1 Codex",
1387 "1.25",
1388 "10",
1389 "0.125",
1390 "0",
1391 ),
1392 (
1393 "gpt-5.1-codex-max",
1394 "GPT-5.1 Codex",
1395 "1.25",
1396 "10",
1397 "0.125",
1398 "0",
1399 ),
1400 (
1401 "gpt-5.1-codex-max-high",
1402 "GPT-5.1 Codex",
1403 "1.25",
1404 "10",
1405 "0.125",
1406 "0",
1407 ),
1408 (
1409 "gpt-5.1-codex-max-xhigh",
1410 "GPT-5.1 Codex",
1411 "1.25",
1412 "10",
1413 "0.125",
1414 "0",
1415 ),
1416 ("gpt-5", "GPT-5", "1.25", "10", "0.125", "0"),
1418 ("gpt-5-low", "GPT-5", "1.25", "10", "0.125", "0"),
1419 ("gpt-5-medium", "GPT-5", "1.25", "10", "0.125", "0"),
1420 ("gpt-5-high", "GPT-5", "1.25", "10", "0.125", "0"),
1421 ("gpt-5-minimal", "GPT-5", "1.25", "10", "0.125", "0"),
1422 ("gpt-5-codex", "GPT-5 Codex", "1.25", "10", "0.125", "0"),
1423 ("gpt-5-codex-low", "GPT-5 Codex", "1.25", "10", "0.125", "0"),
1424 (
1425 "gpt-5-codex-medium",
1426 "GPT-5 Codex",
1427 "1.25",
1428 "10",
1429 "0.125",
1430 "0",
1431 ),
1432 (
1433 "gpt-5-codex-high",
1434 "GPT-5 Codex",
1435 "1.25",
1436 "10",
1437 "0.125",
1438 "0",
1439 ),
1440 (
1441 "gpt-5-codex-mini",
1442 "GPT-5 Codex",
1443 "1.25",
1444 "10",
1445 "0.125",
1446 "0",
1447 ),
1448 (
1449 "gpt-5-codex-mini-medium",
1450 "GPT-5 Codex",
1451 "1.25",
1452 "10",
1453 "0.125",
1454 "0",
1455 ),
1456 (
1457 "gpt-5-codex-mini-high",
1458 "GPT-5 Codex",
1459 "1.25",
1460 "10",
1461 "0.125",
1462 "0",
1463 ),
1464 ("o3", "OpenAI o3", "2", "8", "0.50", "0"),
1466 ("o4-mini", "OpenAI o4-mini", "1.10", "4.40", "0.275", "0"),
1467 ("gpt-4.1", "GPT-4.1", "2", "8", "0.50", "0"),
1469 ("gpt-4.1-mini", "GPT-4.1 Mini", "0.40", "1.60", "0.10", "0"),
1470 ("gpt-4.1-nano", "GPT-4.1 Nano", "0.10", "0.40", "0.025", "0"),
1471 (
1473 "gemini-3.1-pro-preview",
1474 "Gemini 3.1 Pro Preview",
1475 "2",
1476 "12",
1477 "0.20",
1478 "0",
1479 ),
1480 (
1481 "gemini-3.1-flash-lite-preview",
1482 "Gemini 3.1 Flash Lite Preview",
1483 "0.25",
1484 "1.50",
1485 "0.025",
1486 "0",
1487 ),
1488 (
1490 "gemini-3-pro-preview",
1491 "Gemini 3 Pro Preview",
1492 "2",
1493 "12",
1494 "0.2",
1495 "0",
1496 ),
1497 (
1498 "gemini-3-flash-preview",
1499 "Gemini 3 Flash Preview",
1500 "0.5",
1501 "3",
1502 "0.05",
1503 "0",
1504 ),
1505 (
1507 "gemini-2.5-pro",
1508 "Gemini 2.5 Pro",
1509 "1.25",
1510 "10",
1511 "0.125",
1512 "0",
1513 ),
1514 (
1515 "gemini-2.5-flash",
1516 "Gemini 2.5 Flash",
1517 "0.3",
1518 "2.5",
1519 "0.03",
1520 "0",
1521 ),
1522 (
1523 "gemini-2.5-flash-lite",
1524 "Gemini 2.5 Flash Lite",
1525 "0.10",
1526 "0.40",
1527 "0.01",
1528 "0",
1529 ),
1530 (
1532 "gemini-2.0-flash",
1533 "Gemini 2.0 Flash",
1534 "0.10",
1535 "0.40",
1536 "0.025",
1537 "0",
1538 ),
1539 (
1541 "step-3.5-flash",
1542 "Step 3.5 Flash",
1543 "0.10",
1544 "0.30",
1545 "0.02",
1546 "0",
1547 ),
1548 (
1551 "doubao-seed-code",
1552 "Doubao Seed Code",
1553 "0.17",
1554 "1.11",
1555 "0.02",
1556 "0",
1557 ),
1558 (
1559 "doubao-seed-2-0-pro",
1560 "Doubao Seed 2.0 Pro",
1561 "0.47",
1562 "2.37",
1563 "0",
1564 "0",
1565 ),
1566 (
1567 "doubao-seed-2-0-code",
1568 "Doubao Seed 2.0 Code",
1569 "0.47",
1570 "2.37",
1571 "0",
1572 "0",
1573 ),
1574 (
1575 "doubao-seed-2-0-lite",
1576 "Doubao Seed 2.0 Lite",
1577 "0.25",
1578 "2",
1579 "0",
1580 "0",
1581 ),
1582 (
1583 "doubao-seed-2-0-mini",
1584 "Doubao Seed 2.0 Mini",
1585 "0.03",
1586 "0.31",
1587 "0",
1588 "0",
1589 ),
1590 (
1592 "deepseek-v3.2",
1593 "DeepSeek V3.2",
1594 "0.28",
1595 "0.42",
1596 "0.028",
1597 "0",
1598 ),
1599 (
1600 "deepseek-v3.1",
1601 "DeepSeek V3.1",
1602 "0.55",
1603 "1.67",
1604 "0.055",
1605 "0",
1606 ),
1607 ("deepseek-v3", "DeepSeek V3", "0.28", "1.11", "0.028", "0"),
1608 (
1609 "deepseek-chat",
1610 "DeepSeek Chat",
1611 "0.27",
1612 "1.10",
1613 "0.07",
1614 "0",
1615 ),
1616 (
1617 "deepseek-reasoner",
1618 "DeepSeek Reasoner",
1619 "0.55",
1620 "2.19",
1621 "0.14",
1622 "0",
1623 ),
1624 (
1626 "kimi-k2-thinking",
1627 "Kimi K2 Thinking",
1628 "0.55",
1629 "2.20",
1630 "0.10",
1631 "0",
1632 ),
1633 ("kimi-k2-0905", "Kimi K2", "0.55", "2.20", "0.10", "0"),
1634 (
1635 "kimi-k2-turbo",
1636 "Kimi K2 Turbo",
1637 "1.11",
1638 "8.06",
1639 "0.14",
1640 "0",
1641 ),
1642 ("kimi-k2.5", "Kimi K2.5", "0.60", "2.50", "0.10", "0"),
1643 ("kimi-k2.6", "Kimi K2.6", "0.95", "4.00", "0.16", "0"),
1644 ("minimax-m2.1", "MiniMax M2.1", "0.27", "0.95", "0.03", "0"),
1646 (
1647 "minimax-m2.1-lightning",
1648 "MiniMax M2.1 Lightning",
1649 "0.27",
1650 "2.33",
1651 "0.03",
1652 "0",
1653 ),
1654 ("minimax-m2", "MiniMax M2", "0.27", "0.95", "0.03", "0"),
1655 ("minimax-m2.5", "MiniMax M2.5", "0.12", "0.95", "0.03", "0"),
1656 (
1657 "minimax-m2.5-lightning",
1658 "MiniMax M2.5 Lightning",
1659 "0.30",
1660 "2.40",
1661 "0.03",
1662 "0",
1663 ),
1664 (
1665 "minimax-m2.7",
1666 "MiniMax M2.7",
1667 "0.30",
1668 "1.20",
1669 "0.06",
1670 "0.375",
1671 ),
1672 (
1673 "minimax-m2.7-highspeed",
1674 "MiniMax M2.7 Highspeed",
1675 "0.60",
1676 "2.40",
1677 "0.06",
1678 "0.375",
1679 ),
1680 ("glm-4.7", "GLM-4.7", "0.39", "1.75", "0.04", "0"),
1682 ("glm-4.6", "GLM-4.6", "0.28", "1.11", "0.03", "0"),
1683 ("glm-5", "GLM-5", "0.72", "2.30", "0", "0"),
1684 ("glm-5.1", "GLM-5.1", "0.95", "3.15", "0", "0"),
1685 (
1687 "mimo-v2-flash",
1688 "Mimo V2 Flash",
1689 "0.09",
1690 "0.29",
1691 "0.009",
1692 "0",
1693 ),
1694 ("mimo-v2-pro", "MiMo V2 Pro", "1", "3", "0", "0"),
1695 ("qwen3.6-plus", "Qwen3.6 Plus", "0.325", "1.95", "0", "0"),
1697 ("qwen3.5-plus", "Qwen3.5 Plus", "0.26", "1.56", "0", "0"),
1698 ("qwen3-max", "Qwen3 Max", "0.78", "3.90", "0", "0"),
1699 (
1700 "qwen3-235b-a22b",
1701 "Qwen3 235B-A22B",
1702 "0.70",
1703 "8.40",
1704 "0",
1705 "0",
1706 ),
1707 (
1708 "qwen3-coder-plus",
1709 "Qwen3 Coder Plus",
1710 "0.65",
1711 "3.25",
1712 "0",
1713 "0",
1714 ),
1715 (
1716 "qwen3-coder-flash",
1717 "Qwen3 Coder Flash",
1718 "0.195",
1719 "0.975",
1720 "0",
1721 "0",
1722 ),
1723 (
1724 "qwen3-coder-next",
1725 "Qwen3 Coder Next",
1726 "0.12",
1727 "0.75",
1728 "0",
1729 "0",
1730 ),
1731 ("qwq-plus", "QwQ Plus", "0.80", "2.40", "0", "0"),
1732 ("qwq-32b", "QwQ 32B", "0.20", "0.60", "0", "0"),
1733 ("qwen3-32b", "Qwen3 32B", "0.16", "0.64", "0", "0"),
1734 (
1736 "grok-4.20-0309-reasoning",
1737 "Grok 4.20 Reasoning",
1738 "2",
1739 "6",
1740 "0.20",
1741 "0",
1742 ),
1743 (
1744 "grok-4.20-0309-non-reasoning",
1745 "Grok 4.20",
1746 "2",
1747 "6",
1748 "0.20",
1749 "0",
1750 ),
1751 (
1752 "grok-4-1-fast-reasoning",
1753 "Grok 4.1 Fast Reasoning",
1754 "0.20",
1755 "0.50",
1756 "0.05",
1757 "0",
1758 ),
1759 (
1760 "grok-4-1-fast-non-reasoning",
1761 "Grok 4.1 Fast",
1762 "0.20",
1763 "0.50",
1764 "0.05",
1765 "0",
1766 ),
1767 ("grok-4", "Grok 4", "3", "15", "0.75", "0"),
1768 (
1769 "grok-code-fast-1",
1770 "Grok Code Fast",
1771 "0.20",
1772 "1.50",
1773 "0.02",
1774 "0",
1775 ),
1776 ("grok-3", "Grok 3", "3", "15", "0.75", "0"),
1777 ("grok-3-mini", "Grok 3 Mini", "0.25", "0.50", "0.075", "0"),
1778 ("codestral-2508", "Codestral", "0.30", "0.90", "0.03", "0"),
1780 (
1781 "devstral-small-1.1",
1782 "Devstral Small 1.1",
1783 "0.07",
1784 "0.28",
1785 "0.01",
1786 "0",
1787 ),
1788 ("devstral-2-2512", "Devstral 2", "0.40", "0.90", "0.04", "0"),
1789 (
1790 "devstral-medium",
1791 "Devstral Medium",
1792 "0.40",
1793 "2",
1794 "0.04",
1795 "0",
1796 ),
1797 (
1798 "mistral-large-3-2512",
1799 "Mistral Large 3",
1800 "0.50",
1801 "1.50",
1802 "0.05",
1803 "0",
1804 ),
1805 (
1806 "mistral-medium-3.1",
1807 "Mistral Medium 3.1",
1808 "0.40",
1809 "2",
1810 "0.04",
1811 "0",
1812 ),
1813 (
1814 "mistral-small-3.2-24b",
1815 "Mistral Small 3.2",
1816 "0.075",
1817 "0.20",
1818 "0.01",
1819 "0",
1820 ),
1821 ("magistral-medium", "Magistral Medium", "2", "5", "0", "0"),
1822 ("command-a", "Cohere Command A", "2.50", "10", "0", "0"),
1824 (
1825 "command-r-plus",
1826 "Cohere Command R+",
1827 "2.50",
1828 "10",
1829 "0",
1830 "0",
1831 ),
1832 ("command-r", "Cohere Command R", "0.15", "0.60", "0", "0"),
1833 ("o3-pro", "OpenAI o3-pro", "20", "80", "0", "0"),
1835 ("o3-mini", "OpenAI o3-mini", "0.55", "2.20", "0.55", "0"),
1836 ("o1", "OpenAI o1", "15", "60", "7.50", "0"),
1837 ("o1-mini", "OpenAI o1-mini", "0.55", "2.20", "0.55", "0"),
1838 ("codex-mini", "Codex Mini", "0.75", "3", "0.025", "0"),
1839 ("gpt-5-mini", "GPT-5 Mini", "0.25", "2", "0.025", "0"),
1840 ("gpt-5-nano", "GPT-5 Nano", "0.05", "0.40", "0.005", "0"),
1841 ];
1842
1843 let mut stmt = conn
1844 .prepare(
1845 "INSERT OR IGNORE INTO model_pricing (
1846 model_id, display_name, input_cost_per_million, output_cost_per_million,
1847 cache_read_cost_per_million, cache_creation_cost_per_million
1848 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
1849 )
1850 .map_err(|e| AppError::Database(format!("准备模型定价语句失败: {e}")))?;
1851 for (model_id, display_name, input, output, cache_read, cache_creation) in pricing_data {
1852 stmt.execute(rusqlite::params![
1853 model_id,
1854 display_name,
1855 input,
1856 output,
1857 cache_read,
1858 cache_creation
1859 ])
1860 .map_err(|e| AppError::Database(format!("插入模型定价失败: {e}")))?;
1861 }
1862
1863 log::info!("已插入 {} 条默认模型定价数据", pricing_data.len());
1864 Ok(())
1865 }
1866
1867 pub fn ensure_model_pricing_seeded(&self) -> Result<(), AppError> {
1869 let conn = lock_conn!(self.conn);
1870 Self::ensure_model_pricing_seeded_on_conn(&conn)
1871 }
1872
1873 fn ensure_model_pricing_seeded_on_conn(conn: &Connection) -> Result<(), AppError> {
1874 Self::seed_model_pricing(conn)
1877 }
1878
1879 pub(crate) fn future_schema_error(version: i32) -> AppError {
1882 AppError::Database(format!(
1883 "当前数据库由较新版本的 CC Switch 创建,旧版本无法打开。\n\
1884 数据库版本: {version}\n\
1885 当前应用: v{},最高支持数据库版本: {SCHEMA_VERSION}\n\
1886 请运行 `cc-switch update` 升级到最新版;如果仍然失败,请从 GitHub Releases 安装最新版本。",
1887 env!("CARGO_PKG_VERSION")
1888 ))
1889 }
1890
1891 pub(crate) fn get_user_version(conn: &Connection) -> Result<i32, AppError> {
1892 conn.query_row("PRAGMA user_version;", [], |row| row.get(0))
1893 .map_err(|e| AppError::Database(format!("读取 user_version 失败: {e}")))
1894 }
1895
1896 pub(crate) fn set_user_version(conn: &Connection, version: i32) -> Result<(), AppError> {
1897 if version < 0 {
1898 return Err(AppError::Database("user_version 不能为负数".to_string()));
1899 }
1900 let sql = format!("PRAGMA user_version = {version};");
1901 conn.execute(&sql, [])
1902 .map_err(|e| AppError::Database(format!("写入 user_version 失败: {e}")))?;
1903 Ok(())
1904 }
1905
1906 fn validate_identifier(s: &str, kind: &str) -> Result<(), AppError> {
1907 if s.is_empty() {
1908 return Err(AppError::Database(format!("{kind} 不能为空")));
1909 }
1910 if !s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
1911 return Err(AppError::Database(format!(
1912 "非法{kind}: {s},仅允许字母、数字和下划线"
1913 )));
1914 }
1915 Ok(())
1916 }
1917
1918 pub(crate) fn table_exists(conn: &Connection, table: &str) -> Result<bool, AppError> {
1919 Self::validate_identifier(table, "表名")?;
1920
1921 let mut stmt = conn
1922 .prepare("SELECT name FROM sqlite_master WHERE type='table'")
1923 .map_err(|e| AppError::Database(format!("读取表名失败: {e}")))?;
1924 let mut rows = stmt
1925 .query([])
1926 .map_err(|e| AppError::Database(format!("查询表名失败: {e}")))?;
1927 while let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? {
1928 let name: String = row
1929 .get(0)
1930 .map_err(|e| AppError::Database(format!("解析表名失败: {e}")))?;
1931 if name.eq_ignore_ascii_case(table) {
1932 return Ok(true);
1933 }
1934 }
1935 Ok(false)
1936 }
1937
1938 pub(crate) fn has_column(
1939 conn: &Connection,
1940 table: &str,
1941 column: &str,
1942 ) -> Result<bool, AppError> {
1943 Self::validate_identifier(table, "表名")?;
1944 Self::validate_identifier(column, "列名")?;
1945
1946 let sql = format!("PRAGMA table_info(\"{table}\");");
1947 let mut stmt = conn
1948 .prepare(&sql)
1949 .map_err(|e| AppError::Database(format!("读取表结构失败: {e}")))?;
1950 let mut rows = stmt
1951 .query([])
1952 .map_err(|e| AppError::Database(format!("查询表结构失败: {e}")))?;
1953 while let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? {
1954 let name: String = row
1955 .get(1)
1956 .map_err(|e| AppError::Database(format!("读取列名失败: {e}")))?;
1957 if name.eq_ignore_ascii_case(column) {
1958 return Ok(true);
1959 }
1960 }
1961 Ok(false)
1962 }
1963
1964 fn add_column_if_missing(
1965 conn: &Connection,
1966 table: &str,
1967 column: &str,
1968 definition: &str,
1969 ) -> Result<bool, AppError> {
1970 Self::validate_identifier(table, "表名")?;
1971 Self::validate_identifier(column, "列名")?;
1972
1973 if !Self::table_exists(conn, table)? {
1974 return Err(AppError::Database(format!(
1975 "表 {table} 不存在,无法添加列 {column}"
1976 )));
1977 }
1978 if Self::has_column(conn, table, column)? {
1979 return Ok(false);
1980 }
1981
1982 let sql = format!("ALTER TABLE \"{table}\" ADD COLUMN \"{column}\" {definition};");
1983 conn.execute(&sql, [])
1984 .map_err(|e| AppError::Database(format!("为表 {table} 添加列 {column} 失败: {e}")))?;
1985 log::info!("已为表 {table} 添加缺失列 {column}");
1986 Ok(true)
1987 }
1988}