dev-manager-tauri/src-tauri/src/db.rs
lanrtop 0ac809970f feat(flywheel): commit_files 采集——文件级风险画像数据地基
- 新表 commit_files(sha,path 联合主键 + path 索引),migration 入 migrate()
- read_git_log 加 --name-only:单次 git 调用同时取 conventional 指标、
  Rules-Applied trailer 与触碰文件,零额外进程开销
- parse_git_log_output 纯函数解析 header/文件行混排输出(含 \x1f 的行
  是 header,其后非空行归属该 commit)
- record_commit_entry 落库文件清单(INSERT OR IGNORE 幂等),buff 增量
  与 ingest 全量两条链路自动覆盖,无需各自改动

Rules-Applied: R05,R06,R07
2026-07-04 14:07:57 +09:00

999 lines
41 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use once_cell::sync::OnceCell;
use r2d2::Pool;
use r2d2_sqlite::SqliteConnectionManager;
use std::path::PathBuf;
pub type DbPool = Pool<SqliteConnectionManager>;
static POOL: OnceCell<DbPool> = OnceCell::new();
pub fn init(data_dir: PathBuf) {
std::fs::create_dir_all(&data_dir).expect("create data dir");
let db_path = data_dir.join("dev-manager.db");
let manager = SqliteConnectionManager::file(db_path);
let pool = Pool::new(manager).expect("create sqlite pool");
{
let conn = pool.get().expect("get conn for migration");
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")
.expect("pragma");
migrate(&conn);
}
POOL.set(pool).expect("pool already initialized");
}
pub fn pool() -> &'static DbPool {
POOL.get().expect("db not initialized")
}
/// pool 未初始化时返回 None测试环境中安全调用。
pub(crate) fn try_pool() -> Option<&'static DbPool> {
POOL.get()
}
fn migrate(conn: &rusqlite::Connection) {
conn.execute_batch(
"
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
type TEXT,
status TEXT DEFAULT 'active',
priority TEXT DEFAULT 'mid',
wsl_path TEXT,
win_path TEXT,
tech_stack TEXT,
recent_focus TEXT,
startup_commands TEXT,
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS product_groups (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT
);
CREATE TABLE IF NOT EXISTS project_group_map (
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
group_id TEXT NOT NULL REFERENCES product_groups(id) ON DELETE CASCADE,
role TEXT,
PRIMARY KEY (project_id, group_id)
);
CREATE TABLE IF NOT EXISTS relations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
from_project TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
to_project TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
relation_type TEXT,
note TEXT
);
CREATE TABLE IF NOT EXISTS activity_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
summary TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS project_tools (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
name TEXT NOT NULL,
icon TEXT NOT NULL DEFAULT '🔧',
exe_path TEXT NOT NULL,
args TEXT,
sort_order INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS dev_tools (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
category TEXT NOT NULL,
cmd TEXT,
path TEXT,
version TEXT,
note TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS project_env_tools (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
layer INTEGER NOT NULL,
name TEXT NOT NULL,
source TEXT,
version TEXT,
note TEXT,
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS network_monitored_tools (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
exe_path TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS network_mode_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mode TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
INSERT OR IGNORE INTO settings (key, value) VALUES ('editor', 'cursor');
INSERT OR IGNORE INTO settings (key, value) VALUES ('wsl_distro', 'Ubuntu');
INSERT OR IGNORE INTO settings (key, value) VALUES ('dev_mode', '0');
INSERT OR IGNORE INTO settings (key, value) VALUES ('github_client_id', '');
INSERT OR IGNORE INTO settings (key, value) VALUES ('github_client_secret', '');
INSERT OR IGNORE INTO settings (key, value) VALUES ('mcp_port', '27190');
",
)
.expect("migrate");
// 增量迁移:为 dev_tools 添加 env 字段(已存在则忽略)
let _ = conn.execute(
"ALTER TABLE dev_tools ADD COLUMN env TEXT NOT NULL DEFAULT 'windows'",
[],
);
// 增量迁移Git 多空间功能所需新表
conn.execute_batch(
"
CREATE TABLE IF NOT EXISTS git_accounts (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
avatar_url TEXT,
provider TEXT NOT NULL DEFAULT 'github',
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS spaces (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
local_root TEXT NOT NULL,
is_current INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS deploy_commands (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
name TEXT NOT NULL,
command TEXT NOT NULL,
run_in TEXT NOT NULL DEFAULT 'windows'
);
",
)
.expect("migrate git spaces");
// 增量迁移git_accounts 补 UNIQUE 索引(已存在则忽略)
let _ = conn.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_git_accounts_username ON git_accounts(username)",
[],
);
// 增量迁移projects 表新增 Git 相关字段(已存在则忽略)
let _ = conn.execute("ALTER TABLE projects ADD COLUMN space_id TEXT", []);
let _ = conn.execute("ALTER TABLE projects ADD COLUMN repo_url TEXT", []);
let _ = conn.execute("ALTER TABLE projects ADD COLUMN upstream_url TEXT", []);
let _ = conn.execute("ALTER TABLE projects ADD COLUMN default_branch TEXT", []);
let _ = conn.execute("ALTER TABLE projects ADD COLUMN last_push_at TEXT", []);
// 增量迁移platform 字段("windows" | "wsl",已存在则忽略)
let _ = conn.execute("ALTER TABLE projects ADD COLUMN platform TEXT", []);
// 增量迁移product_groups 表新增 space_id已存在则忽略
let _ = conn.execute("ALTER TABLE product_groups ADD COLUMN space_id TEXT", []);
// 增量迁移dev_tools 表新增 space_id已存在则忽略
let _ = conn.execute("ALTER TABLE dev_tools ADD COLUMN space_id TEXT", []);
// 增量迁移network_monitored_tools 新增 space_id已存在则忽略
let _ = conn.execute("ALTER TABLE network_monitored_tools ADD COLUMN space_id TEXT", []);
// 增量迁移:空间级别的 settings 覆盖表
let _ = conn.execute_batch(
"CREATE TABLE IF NOT EXISTS space_settings (
space_id TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
PRIMARY KEY (space_id, key)
);"
);
// 增量迁移:项目生命周期字段
let _ = conn.execute("ALTER TABLE projects ADD COLUMN staging_url TEXT", []);
let _ = conn.execute("ALTER TABLE projects ADD COLUMN prod_url TEXT", []);
let _ = conn.execute("ALTER TABLE projects ADD COLUMN server_note TEXT", []);
// ── 方案A重构project_profiles + project_workspaces ──────────────────────
conn.execute_batch("
CREATE TABLE IF NOT EXISTS project_profiles (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
type TEXT,
status TEXT DEFAULT 'active',
priority TEXT DEFAULT 'mid',
tech_stack TEXT,
repo_url TEXT,
upstream_url TEXT,
default_branch TEXT,
staging_url TEXT,
prod_url TEXT,
server_note TEXT,
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS project_workspaces (
id TEXT PRIMARY KEY,
profile_id TEXT NOT NULL REFERENCES project_profiles(id) ON DELETE CASCADE,
space_id TEXT,
wsl_path TEXT,
win_path TEXT,
platform TEXT DEFAULT 'windows',
recent_focus TEXT,
startup_commands TEXT,
last_push_at TEXT,
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_pw_profile_space
ON project_workspaces(profile_id, ifnull(space_id, ''));
").expect("migrate project profiles/workspaces");
// ── git_reposGitHub 仓库注册表(全局锚点)────────────────────────────────
conn.execute_batch("
CREATE TABLE IF NOT EXISTS git_repos (
id TEXT PRIMARY KEY,
repo_url TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT,
default_branch TEXT DEFAULT 'main',
upstream_url TEXT,
staging_url TEXT,
prod_url TEXT,
server_note TEXT,
status TEXT DEFAULT 'active',
priority TEXT DEFAULT 'mid',
tech_stack TEXT,
last_synced_at TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_git_repos_url ON git_repos(repo_url);
").expect("migrate git_repos");
// project_workspaces 添加 repo_id 外键(已存在则忽略)
let _ = conn.execute(
"ALTER TABLE project_workspaces ADD COLUMN repo_id TEXT REFERENCES git_repos(id) ON DELETE SET NULL",
[],
);
// 增量迁移:项目模板系统
conn.execute_batch("
CREATE TABLE IF NOT EXISTS project_templates (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
platform TEXT NOT NULL DEFAULT 'both',
tech_stack TEXT,
init_commands TEXT,
is_builtin INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS project_template_files (
id TEXT PRIMARY KEY,
template_id TEXT NOT NULL REFERENCES project_templates(id) ON DELETE CASCADE,
path TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
UNIQUE(template_id, path)
);
").expect("migrate project templates");
// 迁移 project_group_map将外键从 projects(id) 改为 project_workspaces(id)
let pgm_sql: Option<String> = conn.query_row(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='project_group_map'",
[],
|row| row.get(0),
).ok();
if pgm_sql.map(|s| s.contains("REFERENCES projects")).unwrap_or(false) {
conn.execute_batch("
PRAGMA foreign_keys=OFF;
CREATE TABLE project_group_map_new (
project_id TEXT NOT NULL REFERENCES project_workspaces(id) ON DELETE CASCADE,
group_id TEXT NOT NULL REFERENCES product_groups(id) ON DELETE CASCADE,
role TEXT,
PRIMARY KEY (project_id, group_id)
);
INSERT OR IGNORE INTO project_group_map_new (project_id, group_id, role)
SELECT m.project_id, m.group_id, m.role
FROM project_group_map m
INNER JOIN project_workspaces pw ON pw.id = m.project_id;
DROP TABLE project_group_map;
ALTER TABLE project_group_map_new RENAME TO project_group_map;
PRAGMA foreign_keys=ON;
").expect("migrate project_group_map FK to project_workspaces");
}
// 迁移 project_tools / activity_log / project_env_tools
// 将外键从 projects(id) 改为 project_workspaces(id)
let pt_sql: Option<String> = conn.query_row(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='project_tools'",
[], |row| row.get(0),
).ok();
if pt_sql.as_deref().map(|s| s.contains("REFERENCES projects")).unwrap_or(false) {
conn.execute_batch("
PRAGMA foreign_keys=OFF;
CREATE TABLE project_tools_new (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES project_workspaces(id) ON DELETE CASCADE,
name TEXT NOT NULL,
icon TEXT NOT NULL DEFAULT '🔧',
exe_path TEXT NOT NULL,
args TEXT,
sort_order INTEGER DEFAULT 0
);
INSERT OR IGNORE INTO project_tools_new
SELECT t.id, t.project_id, t.name, t.icon, t.exe_path, t.args, t.sort_order
FROM project_tools t
INNER JOIN project_workspaces pw ON pw.id = t.project_id;
DROP TABLE project_tools;
ALTER TABLE project_tools_new RENAME TO project_tools;
PRAGMA foreign_keys=ON;
").expect("migrate project_tools FK to project_workspaces");
}
let al_sql: Option<String> = conn.query_row(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='activity_log'",
[], |row| row.get(0),
).ok();
if al_sql.as_deref().map(|s| s.contains("REFERENCES projects")).unwrap_or(false) {
conn.execute_batch("
PRAGMA foreign_keys=OFF;
CREATE TABLE activity_log_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id TEXT NOT NULL REFERENCES project_workspaces(id) ON DELETE CASCADE,
summary TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
INSERT OR IGNORE INTO activity_log_new (id, project_id, summary, created_at)
SELECT a.id, a.project_id, a.summary, a.created_at
FROM activity_log a
INNER JOIN project_workspaces pw ON pw.id = a.project_id;
DROP TABLE activity_log;
ALTER TABLE activity_log_new RENAME TO activity_log;
PRAGMA foreign_keys=ON;
").expect("migrate activity_log FK to project_workspaces");
}
let pet_sql: Option<String> = conn.query_row(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='project_env_tools'",
[], |row| row.get(0),
).ok();
if pet_sql.as_deref().map(|s| s.contains("REFERENCES projects")).unwrap_or(false) {
conn.execute_batch("
PRAGMA foreign_keys=OFF;
CREATE TABLE project_env_tools_new (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES project_workspaces(id) ON DELETE CASCADE,
layer INTEGER NOT NULL,
name TEXT NOT NULL,
source TEXT,
version TEXT,
note TEXT,
updated_at TEXT DEFAULT (datetime('now'))
);
INSERT OR IGNORE INTO project_env_tools_new
SELECT t.id, t.project_id, t.layer, t.name, t.source, t.version, t.note, t.updated_at
FROM project_env_tools t
INNER JOIN project_workspaces pw ON pw.id = t.project_id;
DROP TABLE project_env_tools;
ALTER TABLE project_env_tools_new RENAME TO project_env_tools;
PRAGMA foreign_keys=ON;
").expect("migrate project_env_tools FK to project_workspaces");
}
// 从旧 projects 表迁移数据INSERT OR IGNORE 保证幂等)
let _ = conn.execute_batch("
INSERT OR IGNORE INTO project_profiles
(id, name, description, type, status, priority, tech_stack,
repo_url, upstream_url, default_branch,
staging_url, prod_url, server_note, updated_at)
SELECT
'prof-' || id,
name, description, type, status, priority, tech_stack,
repo_url, upstream_url, default_branch,
staging_url, prod_url, server_note, updated_at
FROM projects;
INSERT OR IGNORE INTO project_workspaces
(id, profile_id, space_id, wsl_path, win_path, platform,
recent_focus, startup_commands, last_push_at, updated_at)
SELECT
id,
'prof-' || id,
space_id, wsl_path, win_path, COALESCE(platform, 'windows'),
recent_focus, startup_commands, last_push_at, updated_at
FROM projects;
");
// 增量迁移project_profiles 加 tags 字段
let _ = conn.execute("ALTER TABLE project_profiles ADD COLUMN tags TEXT", []);
// 标签管理表
conn.execute_batch("
CREATE TABLE IF NOT EXISTS project_tags (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
color TEXT NOT NULL DEFAULT '#8b5cf6'
);
CREATE TABLE IF NOT EXISTS project_tag_map (
profile_id TEXT NOT NULL,
tag_id TEXT NOT NULL,
PRIMARY KEY (profile_id, tag_id)
);
").expect("create tag tables");
// 增量迁移product_groups 加 sort_order 字段
let _ = conn.execute(
"ALTER TABLE product_groups ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0",
[],
);
// CI/CD 配置表(替代 deploy_commands
conn.execute_batch("
CREATE TABLE IF NOT EXISTS cicd_configs (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL UNIQUE,
config_type TEXT NOT NULL DEFAULT 'web_ssh',
trigger_branch TEXT NOT NULL DEFAULT 'main',
build_command TEXT DEFAULT 'pnpm build',
node_version TEXT DEFAULT '20',
server_host TEXT,
server_user TEXT DEFAULT 'ubuntu',
deploy_path TEXT,
start_command TEXT DEFAULT 'pm2 restart app',
oss_endpoint TEXT DEFAULT 'oss-cn-hangzhou.aliyuncs.com',
created_at TEXT DEFAULT (datetime('now'))
);
").expect("create cicd_configs table");
// 资源视角画布状态(每个产品组独立保存)
conn.execute_batch("
CREATE TABLE IF NOT EXISTS canvas_states (
group_id TEXT PRIMARY KEY,
data TEXT NOT NULL,
updated_at TEXT DEFAULT (datetime('now'))
);
").expect("create canvas_states table");
// 运行时可观测性日志(最多保留 500 条)
conn.execute_batch("
CREATE TABLE IF NOT EXISTS runtime_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
category TEXT NOT NULL,
level TEXT NOT NULL,
message TEXT NOT NULL,
context TEXT
);
").expect("create runtime_logs table");
// AI 笔记Claude 通过 MCP 或用户手动写入的分析结论)
conn.execute_batch("
CREATE TABLE IF NOT EXISTS project_notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'mcp',
content TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now', 'localtime'))
);
").expect("create project_notes table");
// 用户项目个人备忘自由文本待办Claude 不感知)
conn.execute_batch("
CREATE TABLE IF NOT EXISTS user_project_todos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id TEXT NOT NULL,
content TEXT NOT NULL,
done INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now', 'localtime'))
);
").expect("create user_project_todos table");
// 蓝图 Buff记录哪些项目施加了 buff
conn.execute_batch("
CREATE TABLE IF NOT EXISTS blueprint_buffs (
project_path TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
applied_at TEXT NOT NULL DEFAULT (datetime('now')),
last_sync_at TEXT,
last_commit TEXT,
status TEXT NOT NULL DEFAULT 'active'
);
").expect("create blueprint_buffs table");
// 飞轮 L0从 git 解析的 conventional commit 执行质量指标
conn.execute_batch("
CREATE TABLE IF NOT EXISTS commit_metrics (
sha TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
commit_type TEXT,
scope TEXT,
is_rework INTEGER NOT NULL DEFAULT 0,
is_ci_auto INTEGER NOT NULL DEFAULT 0,
committed_at TEXT,
parsed_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_commit_metrics_project ON commit_metrics(project_id);
").expect("create commit_metrics table");
// 增量迁移(飞轮 R1Rules-Applied trailer 规则命中列(已存在则忽略)
let _ = conn.execute("ALTER TABLE commit_metrics ADD COLUMN rules_applied TEXT", []);
// 飞轮阶段三P3-Bcommit 触碰的文件清单,供文件级返工风险画像
conn.execute_batch("
CREATE TABLE IF NOT EXISTS commit_files (
sha TEXT NOT NULL,
path TEXT NOT NULL,
PRIMARY KEY (sha, path)
);
CREATE INDEX IF NOT EXISTS idx_commit_files_path ON commit_files(path);
").expect("create commit_files table");
// 飞轮 L1PR 生命周期事件opened / closed / merged
conn.execute_batch("
CREATE TABLE IF NOT EXISTS pr_events (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
pr_number INTEGER NOT NULL,
title TEXT,
action TEXT NOT NULL,
head_branch TEXT,
base_branch TEXT,
author TEXT,
event_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_pr_events_project ON pr_events(project_id);
").expect("create pr_events table");
// 仪表盘全局快启应用(无项目 FK 约束)
conn.execute_batch("
CREATE TABLE IF NOT EXISTS global_tools (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
icon TEXT NOT NULL DEFAULT '🔧',
exe_path TEXT NOT NULL,
args TEXT,
sort_order INTEGER DEFAULT 0
);
").expect("create global_tools table");
// 增量迁移:模板 variants 列(多初始化方式下拉选项)
let _ = conn.execute_batch(
"ALTER TABLE project_templates ADD COLUMN variants TEXT;"
);
// 增量迁移:服务器注册表 + 项目-服务器关联
conn.execute_batch("
CREATE TABLE IF NOT EXISTS servers (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
server_host TEXT NOT NULL,
server_port TEXT DEFAULT '22',
username TEXT,
password TEXT,
ssh_key_path TEXT,
quick_links TEXT,
notes TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS project_server_map (
project_id TEXT NOT NULL,
server_id TEXT NOT NULL,
role TEXT DEFAULT 'production',
deploy_type TEXT DEFAULT 'bare',
deploy_path TEXT,
container_name TEXT,
start_command TEXT,
notes TEXT,
PRIMARY KEY (project_id, server_id)
);
").expect("create servers tables");
// 增量迁移project_server_map 新增部署类型字段(已存在则忽略)
let _ = conn.execute("ALTER TABLE project_server_map ADD COLUMN deploy_type TEXT DEFAULT 'bare'", []);
let _ = conn.execute("ALTER TABLE project_server_map ADD COLUMN container_name TEXT", []);
let _ = conn.execute("ALTER TABLE project_server_map ADD COLUMN start_command TEXT", []);
// 增量迁移GitHub 镜像源 + 服务器网络配置 + 软件仓库
conn.execute_batch("
CREATE TABLE IF NOT EXISTS github_mirrors (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
url_prefix TEXT NOT NULL,
is_builtin INTEGER DEFAULT 0,
sort_order INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS server_network_config (
server_id TEXT PRIMARY KEY,
github_mirror_id TEXT,
last_tested TEXT,
test_results TEXT
);
CREATE TABLE IF NOT EXISTS server_software (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
category TEXT,
install_type TEXT NOT NULL DEFAULT 'package',
install_cmd TEXT,
github_url TEXT,
remote_path TEXT,
post_cmd TEXT,
is_builtin INTEGER DEFAULT 0,
sort_order INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
);
").expect("create software mgmt tables");
// 软件分类标签
conn.execute_batch("
CREATE TABLE IF NOT EXISTS software_categories (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
color TEXT NOT NULL DEFAULT '#6b7280',
sort_order INTEGER DEFAULT 0
);
").expect("create software_categories table");
// 内置分类标签(幂等)
conn.execute_batch("
INSERT OR IGNORE INTO software_categories (id, name, color, sort_order)
VALUES ('cat-web', 'Web 服务', '#3b82f6', 0);
INSERT OR IGNORE INTO software_categories (id, name, color, sort_order)
VALUES ('cat-runtime', '运行时', '#10b981', 1);
INSERT OR IGNORE INTO software_categories (id, name, color, sort_order)
VALUES ('cat-tool', '工具', '#f59e0b', 2);
INSERT OR IGNORE INTO software_categories (id, name, color, sort_order)
VALUES ('cat-custom', '自定义', '#8b5cf6', 3);
").expect("seed software categories");
// 迁移旧 category 字符串 → category id
let _ = conn.execute_batch("
UPDATE server_software SET category = 'cat-web' WHERE category = 'web';
UPDATE server_software SET category = 'cat-runtime' WHERE category = 'runtime';
UPDATE server_software SET category = 'cat-tool' WHERE category = 'tool';
UPDATE server_software SET category = 'cat-custom' WHERE category = 'custom';
");
// 内置镜像源(幂等)
conn.execute_batch("
INSERT OR IGNORE INTO github_mirrors (id, name, url_prefix, is_builtin, sort_order)
VALUES ('mirror-direct', '直连(无镜像)', 'https://github.com', 1, 0);
INSERT OR IGNORE INTO github_mirrors (id, name, url_prefix, is_builtin, sort_order)
VALUES ('mirror-ghfast', 'ghfast.top', 'https://ghfast.top', 1, 1);
INSERT OR IGNORE INTO github_mirrors (id, name, url_prefix, is_builtin, sort_order)
VALUES ('mirror-ghproxy', 'gh-proxy.com', 'https://gh-proxy.com', 1, 2);
").expect("seed github mirrors");
// 内置软件模板(幂等)
conn.execute_batch("
INSERT OR IGNORE INTO server_software (id, name, description, category, install_type, install_cmd, is_builtin, sort_order)
VALUES ('sw-nginx', 'Nginx', 'Web 服务器 / 反向代理', 'cat-web', 'package', 'apt install -y nginx && systemctl enable nginx', 1, 0);
INSERT OR IGNORE INTO server_software (id, name, description, category, install_type, install_cmd, is_builtin, sort_order)
VALUES ('sw-docker', 'Docker', '容器运行时', 'cat-runtime', 'package', 'apt install -y docker.io && systemctl enable docker', 1, 1);
INSERT OR IGNORE INTO server_software (id, name, description, category, install_type, install_cmd, is_builtin, sort_order)
VALUES ('sw-node20', 'Node.js 20','JavaScript 运行时', 'cat-runtime', 'package', 'curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && apt install -y nodejs', 1, 2);
INSERT OR IGNORE INTO server_software (id, name, description, category, install_type, install_cmd, is_builtin, sort_order)
VALUES ('sw-pm2', 'PM2', 'Node.js 进程管理', 'cat-runtime', 'package', 'npm install -g pm2', 1, 3);
INSERT OR IGNORE INTO server_software (id, name, description, category, install_type, install_cmd, is_builtin, sort_order)
VALUES ('sw-git', 'Git', '版本控制', 'cat-tool', 'package', 'apt install -y git', 1, 4);
INSERT OR IGNORE INTO server_software (id, name, description, category, install_type, install_cmd, is_builtin, sort_order)
VALUES ('sw-htop', 'htop', '系统监控工具', 'cat-tool', 'package', 'apt install -y htop', 1, 5);
").expect("seed server software");
// 增量迁移:服务连接配置(每台服务器上部署的各种服务的连接信息)
conn.execute_batch("
CREATE TABLE IF NOT EXISTS server_service_configs (
id TEXT PRIMARY KEY,
server_id TEXT NOT NULL,
name TEXT NOT NULL,
category TEXT DEFAULT 'custom',
scope TEXT DEFAULT 'system',
config_data TEXT NOT NULL DEFAULT '[]',
sort_order INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
").expect("create server_service_configs table");
let _ = conn.execute("ALTER TABLE server_service_configs ADD COLUMN scope TEXT DEFAULT 'system'", []);
// 增量迁移API 密钥管理
conn.execute_batch("
CREATE TABLE IF NOT EXISTS api_keys (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
provider TEXT NOT NULL,
api_key TEXT NOT NULL,
base_url TEXT,
model TEXT,
notes TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
").expect("create api_keys table");
// 增量迁移:宿主机部署应用
conn.execute_batch("
CREATE TABLE IF NOT EXISTS server_host_apps (
id TEXT PRIMARY KEY,
server_id TEXT NOT NULL,
name TEXT NOT NULL,
deploy_type TEXT NOT NULL DEFAULT 'systemd',
service_name TEXT,
binary_path TEXT,
config_path TEXT,
log_path TEXT,
port TEXT,
start_cmd TEXT,
stop_cmd TEXT,
restart_cmd TEXT,
status_cmd TEXT,
notes TEXT,
sort_order INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
").expect("create server_host_apps table");
// ── 源码库 ──────────────────────────────────────────────────────────
conn.execute_batch("
CREATE TABLE IF NOT EXISTS source_categories (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
icon TEXT DEFAULT '📁',
color TEXT DEFAULT '#6B7280',
sort_order INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS source_projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
category_id TEXT REFERENCES source_categories(id) ON DELETE SET NULL,
language TEXT,
tags TEXT,
repo_url TEXT,
introduction TEXT,
readme TEXT,
notes TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS source_versions (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES source_projects(id) ON DELETE CASCADE,
version TEXT NOT NULL,
file_path TEXT NOT NULL,
archive_path TEXT,
file_size INTEGER DEFAULT 0,
is_primary INTEGER DEFAULT 0,
dir_tree TEXT,
notes TEXT,
imported_at TEXT DEFAULT (datetime('now'))
);
").expect("create source_library tables");
// 增量迁移:项目历史快照(每日一条,供 Claude 做趋势分析)
conn.execute_batch("
CREATE TABLE IF NOT EXISTS project_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id TEXT NOT NULL,
snapshot_date TEXT NOT NULL,
days_since_commit INTEGER,
blueprint_done_rate REAL,
blueprint_in_progress INTEGER DEFAULT 0,
blueprint_blocked INTEGER DEFAULT 0,
has_errors INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
UNIQUE(project_id, snapshot_date)
);
").expect("create project_snapshots table");
// 增量迁移:云产品管理
conn.execute_batch("
CREATE TABLE IF NOT EXISTS cloud_products (
id TEXT PRIMARY KEY,
vendor TEXT NOT NULL DEFAULT 'aliyun',
product_type TEXT NOT NULL DEFAULT 'ecs',
name TEXT NOT NULL,
region TEXT,
instance_id TEXT,
endpoint TEXT,
expire_at TEXT,
status TEXT NOT NULL DEFAULT 'active',
config_data TEXT NOT NULL DEFAULT '{}',
notes TEXT,
sort_order INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS cloud_db_databases (
id TEXT PRIMARY KEY,
product_id TEXT NOT NULL REFERENCES cloud_products(id) ON DELETE CASCADE,
name TEXT NOT NULL,
username TEXT,
password TEXT,
charset TEXT DEFAULT 'utf8mb4',
notes TEXT,
sort_order INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
").expect("create cloud_products table");
// 增量迁移cloud_db_databases 加 db_driver 字段
let _ = conn.execute(
"ALTER TABLE cloud_db_databases ADD COLUMN db_driver TEXT NOT NULL DEFAULT 'mysql'",
[],
);
// 增量迁移Docker 容器应用管理
conn.execute_batch("
CREATE TABLE IF NOT EXISTS docker_apps (
id TEXT PRIMARY KEY,
product_id TEXT NOT NULL REFERENCES cloud_products(id) ON DELETE CASCADE,
name TEXT NOT NULL,
access_url TEXT,
username TEXT,
password TEXT,
notes TEXT,
sort_order INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
").expect("create docker_apps table");
// 增量迁移Agent 基础设施文档腐化评分缓存
conn.execute_batch("
CREATE TABLE IF NOT EXISTS doc_freshness_cache (
project_id TEXT PRIMARY KEY,
score TEXT NOT NULL,
details TEXT NOT NULL,
checked_at TEXT NOT NULL DEFAULT (datetime('now'))
);
").expect("create doc_freshness_cache table");
// 增量迁移Gitea 实例 + 项目-仓库绑定
conn.execute_batch("
CREATE TABLE IF NOT EXISTS gitea_instances (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
url TEXT NOT NULL,
access_token TEXT NOT NULL,
is_default INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS gitea_repos (
id TEXT PRIMARY KEY,
instance_id TEXT NOT NULL REFERENCES gitea_instances(id) ON DELETE CASCADE,
project_id TEXT NOT NULL,
gitea_owner TEXT NOT NULL,
gitea_repo_name TEXT NOT NULL,
clone_url TEXT,
webhook_secret TEXT,
webhook_id INTEGER,
linked_at TEXT DEFAULT (datetime('now'))
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_gitea_repos_project ON gitea_repos(project_id);
").expect("create gitea tables");
}
#[cfg(test)]
pub fn conn_with_schema() -> rusqlite::Connection {
let conn = rusqlite::Connection::open_in_memory().unwrap();
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")
.unwrap();
migrate(&conn);
conn
}
/// 写入运行时日志。失败时静默(不能让日志写入反过来崩溃业务逻辑)
pub fn log_event(category: &str, level: &str, message: &str, context: Option<&str>) {
let pool = match POOL.get() {
Some(p) => p,
None => return,
};
let conn = match pool.get() {
Ok(c) => c,
Err(_) => return,
};
let _ = conn.execute(
"INSERT INTO runtime_logs (category, level, message, context) VALUES (?1, ?2, ?3, ?4)",
rusqlite::params![category, level, message, context],
);
// 超出 550 条时才触发清理,保留最新 500 条(避免每次写入都全表扫描)
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM runtime_logs", [], |r| r.get(0))
.unwrap_or(0);
if count > 550 {
let _ = conn.execute(
"DELETE FROM runtime_logs WHERE id NOT IN (SELECT id FROM runtime_logs ORDER BY id DESC LIMIT 500)",
[],
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn migrate_idempotent() {
// 连续两次 migrate 不应 panic
let conn = conn_with_schema();
migrate(&conn);
}
#[test]
fn core_tables_exist() {
let conn = conn_with_schema();
let count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' \
AND name IN ('projects','settings','spaces','git_repos','runtime_logs',\
'project_profiles','project_workspaces')",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(count, 7, "缺少核心表");
}
#[test]
fn default_settings_seeded() {
let conn = conn_with_schema();
let editor: String = conn
.query_row("SELECT value FROM settings WHERE key='editor'", [], |r| r.get(0))
.unwrap();
assert_eq!(editor, "cursor");
let port: String = conn
.query_row("SELECT value FROM settings WHERE key='mcp_port'", [], |r| r.get(0))
.unwrap();
assert_eq!(port, "27190");
}
#[test]
fn runtime_logs_insert_and_count() {
let conn = conn_with_schema();
conn.execute(
"INSERT INTO runtime_logs (category, level, message) VALUES ('test','info','hello')",
[],
)
.unwrap();
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM runtime_logs", [], |r| r.get(0))
.unwrap();
assert_eq!(count, 1);
}
#[test]
fn log_event_silent_when_no_pool() {
// pool 未初始化时应静默返回,不 panic
log_event("test", "info", "no-pool test", None);
}
}