dev-manager-tauri/src-tauri/src/commands/settings.rs
lanrtop 0065866f6b fix: 消除 Windows 子进程黑框闪烁,修复 DB 外键迁移
- 新增 silent_cmd() 辅助函数,统一添加 CREATE_NO_WINDOW 标志,全量替换 commands/ 中的裸 std::process::Command::new 调用
- 补充 project_tools / activity_log / project_env_tools 外键从 projects 迁移到 project_workspaces 的 migrate 逻辑
- CONVENTIONS.md v1.4.2:补充 files 回填规则和批量执行完整性检查规则
2026-04-08 01:13:53 +09:00

648 lines
26 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 crate::db::{log_event, pool};
use rusqlite::params;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use std::collections::HashMap;
/// 全局 key不按空间隔离属于整个应用/机器级别配置)
const GLOBAL_KEYS: &[&str] = &[
"github_client_id", "github_client_secret",
"wsl_distro", // 机器级别,与空间无关
"wsl_open_mode", // WSL 编辑器启动方式
"editors", "active_editors", "active_editor",
"v2rayn_db_path", // v2rayN 数据库路径
"v2raya_username", // v2rayA 登录用户名
"v2raya_password", // v2rayA 登录密码
"mcp_port", // MCP Server 监听端口(全局,与空间无关)
];
/// 获取 settings
/// - 有 space_id返回该空间的 space_settings + 全局 keygithub_client_id/secret
/// - 无 space_id返回全局 settings 表
#[tauri::command]
pub fn get_settings(space_id: Option<String>) -> Result<HashMap<String, String>, String> {
let conn = pool().get().map_err(|e| e.to_string())?;
if let Some(ref sid) = space_id {
// 1. 只读当前空间的 space_settings
let mut map: HashMap<String, String> = {
let mut stmt = conn
.prepare("SELECT key, value FROM space_settings WHERE space_id = ?1")
.map_err(|e| e.to_string())?;
let x = stmt
.query_map(params![sid], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)))
.map_err(|e| e.to_string())?
.collect::<Result<HashMap<_, _>, _>>()
.map_err(|e| e.to_string())?; x
};
// 2. 补充全局 key应用级非空间相关
for key in GLOBAL_KEYS {
if let Ok(val) = conn.query_row(
"SELECT value FROM settings WHERE key = ?1",
params![key],
|row| row.get::<_, String>(0),
) {
map.insert(key.to_string(), val);
}
}
Ok(map)
} else {
// 无空间上下文:返回全局表(应用设置弹窗使用)
let mut stmt = conn.prepare("SELECT key, value FROM settings").map_err(|e| e.to_string())?;
let map = stmt
.query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)))
.map_err(|e| e.to_string())?
.collect::<Result<HashMap<_, _>, _>>()
.map_err(|e| e.to_string())?;
Ok(map)
}
}
/// 更新 settings全局 key 写入 settings 表;其余写入 space_settings 表
#[tauri::command]
pub fn update_settings(settings: HashMap<String, String>, space_id: Option<String>) -> Result<(), String> {
let conn = pool().get().map_err(|e| e.to_string())?;
let mut mcp_port_changed = false;
for (k, v) in &settings {
if k == "mcp_port" {
mcp_port_changed = true;
}
if GLOBAL_KEYS.contains(&k.as_str()) || space_id.is_none() {
conn.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
params![k, v],
)
.map_err(|e| e.to_string())?;
} else {
conn.execute(
"INSERT OR REPLACE INTO space_settings (space_id, key, value) VALUES (?1, ?2, ?3)",
params![space_id, k, v],
)
.map_err(|e| e.to_string())?;
}
}
// 端口变更后重新注入所有产品组成员的 MCP 配置
if mcp_port_changed {
let errors = crate::mcp_inject::reinject_all_mcp();
for e in &errors {
log_event("mcp_inject", "error", &format!("端口变更重注入失败: {e}"), None);
}
}
Ok(())
}
/// 检测系统中已安装的常见编辑器,返回 [{name, cmd, path, found}]
#[tauri::command]
pub fn detect_editors() -> Result<Vec<Value>, String> {
// (显示名, 命令名, 常见安装路径列表)
let candidates: &[(&str, &str, &[&str])] = &[
("Cursor", "cursor", &[
r"%LOCALAPPDATA%\Programs\cursor\Cursor.exe",
r"%USERPROFILE%\AppData\Local\Programs\cursor\Cursor.exe",
]),
("VS Code", "code", &[
r"%LOCALAPPDATA%\Programs\Microsoft VS Code\Code.exe",
r"C:\Program Files\Microsoft VS Code\Code.exe",
]),
("VS Code Insiders", "code-insiders", &[
r"%LOCALAPPDATA%\Programs\Microsoft VS Code Insiders\Code - Insiders.exe",
]),
("Windsurf", "windsurf", &[
r"%LOCALAPPDATA%\Programs\windsurf\Windsurf.exe",
]),
];
let mut results = Vec::new();
for (name, cmd, fallback_paths) in candidates {
// 先用 where 命令查 PATH
let where_out = crate::silent_cmd("cmd")
.args(["/c", &format!("where {cmd} 2>nul")])
.output()
.ok();
let path_from_where = where_out
.as_ref()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout.clone()).ok())
.and_then(|s| s.lines().next().map(|l| l.trim().to_string()))
.filter(|s| !s.is_empty());
// 如果 where 找不到,尝试常见安装路径(展开 %LOCALAPPDATA% 等)
let resolved_path = path_from_where.or_else(|| {
for &p in *fallback_paths {
let expanded = expand_env(p);
if std::path::Path::new(&expanded).exists() {
return Some(expanded);
}
}
None
});
let found = resolved_path.is_some();
let mut obj = Map::new();
obj.insert("name".into(), Value::String(name.to_string()));
obj.insert("cmd".into(), Value::String(cmd.to_string()));
obj.insert("path".into(), match &resolved_path {
Some(p) => Value::String(p.clone()),
None => Value::Null,
});
obj.insert("found".into(), Value::Bool(found));
results.push(Value::Object(obj));
}
Ok(results)
}
/// 搜索指定命令名对应的可执行路径
#[tauri::command]
pub fn search_editor_path(cmd: String) -> Result<Option<String>, String> {
// 先 where
let where_out = crate::silent_cmd("cmd")
.args(["/c", &format!("where {cmd} 2>nul")])
.output()
.map_err(|e| e.to_string())?;
if where_out.status.success() {
if let Ok(s) = String::from_utf8(where_out.stdout) {
if let Some(line) = s.lines().next() {
let p = line.trim().to_string();
if !p.is_empty() {
return Ok(Some(p));
}
}
}
}
// 再搜 Program Files / LocalAppData
let search_dirs = [
std::env::var("LOCALAPPDATA").unwrap_or_default(),
std::env::var("PROGRAMFILES").unwrap_or_default(),
std::env::var("PROGRAMFILES(X86)").unwrap_or_default(),
];
let extensions = ["exe", "cmd"];
for dir in &search_dirs {
if dir.is_empty() { continue; }
for ext in &extensions {
let candidate = format!("{dir}\\{cmd}.{ext}");
if std::path::Path::new(&candidate).exists() {
return Ok(Some(candidate));
}
// 一层子目录
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let sub = entry.path().join(format!("{cmd}.{ext}"));
if sub.exists() {
return Ok(Some(sub.to_string_lossy().into_owned()));
}
}
}
}
}
Ok(None)
}
/// 检测 MCP Server 是否真正在监听(实际 HTTP ping
#[tauri::command]
pub fn check_mcp_health() -> Result<serde_json::Value, String> {
let port = crate::mcp_server::read_port();
let url = format!("http://localhost:{}/health", port);
let start = std::time::Instant::now();
match ureq::builder()
.timeout(std::time::Duration::from_secs(2))
.build()
.get(&url)
.call()
{
Ok(_) => {
let latency_ms = start.elapsed().as_millis() as u64;
Ok(serde_json::json!({ "ok": true, "latency_ms": latency_ms, "port": port }))
}
Err(e) => Ok(serde_json::json!({ "ok": false, "error": e.to_string(), "port": port })),
}
}
// ── 健康检查 ──────────────────────────────────────────────────────────────────
#[derive(Debug, Serialize, Deserialize)]
pub struct HealthItem {
pub id: String,
pub name: String,
/// "ok" | "warn" | "error" | "skip"
pub status: String,
pub detail: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct HealthReport {
pub checked_at: String,
pub items: Vec<HealthItem>,
pub quality: QualityMetrics,
}
fn err_item(id: &str, name: &str, detail: &str) -> HealthItem {
HealthItem { id: id.into(), name: name.into(), status: "error".into(), detail: Some(detail.into()) }
}
fn skip_item(id: &str, name: &str, detail: &str) -> HealthItem {
HealthItem { id: id.into(), name: name.into(), status: "skip".into(), detail: Some(detail.into()) }
}
/// 检测 MCP Server 是否响应
fn check_mcp() -> HealthItem {
let port = crate::mcp_server::read_port();
let url = format!("http://localhost:{}/health", port);
let start = std::time::Instant::now();
match ureq::builder().timeout(std::time::Duration::from_secs(5)).build().get(&url).call() {
Ok(_) => HealthItem {
id: "mcp".into(), name: "MCP Server".into(), status: "ok".into(),
detail: Some(format!("端口 {} 响应正常,延迟 {}ms", port, start.elapsed().as_millis())),
},
Err(e) => HealthItem {
id: "mcp".into(), name: "MCP Server".into(), status: "error".into(),
detail: Some(format!("端口 {} 无响应: {}", port, e)),
},
}
}
/// 检测已注册仓库的本地 .git 目录是否存在(仅检测关联了 git_repos 的 workspace
fn check_git_paths() -> HealthItem {
let result: Result<HealthItem, String> = (|| {
let conn = pool().get().map_err(|e| e.to_string())?;
let mut stmt = conn.prepare(
"SELECT win_path FROM project_workspaces WHERE win_path IS NOT NULL AND win_path != '' AND repo_id IS NOT NULL"
).map_err(|e| e.to_string())?;
let paths: Vec<String> = stmt
.query_map([], |row| row.get::<_, String>(0))
.map_err(|e| e.to_string())?
.flatten().collect();
if paths.is_empty() {
return Ok(skip_item("git_paths", "Git 仓库路径", "暂无本地路径"));
}
let total = paths.len();
let invalid: Vec<&str> = paths.iter()
.filter(|p| !std::path::Path::new(p).join(".git").exists())
.map(|p| p.as_str()).collect();
if invalid.is_empty() {
Ok(HealthItem { id: "git_paths".into(), name: "Git 仓库路径".into(), status: "ok".into(),
detail: Some(format!("全部 {} 个仓库路径有效", total)) })
} else {
let status = if invalid.len() == total { "error" } else { "warn" };
Ok(HealthItem { id: "git_paths".into(), name: "Git 仓库路径".into(), status: status.into(),
detail: Some(format!("{}/{} 个 .git 目录不存在", invalid.len(), total)) })
}
})();
result.unwrap_or_else(|e| err_item("git_paths", "Git 仓库路径", &e))
}
/// 检测项目本地路径win_path是否存在于文件系统
fn check_project_paths() -> HealthItem {
let result: Result<HealthItem, String> = (|| {
let conn = pool().get().map_err(|e| e.to_string())?;
let mut stmt = conn.prepare(
"SELECT win_path FROM project_workspaces WHERE win_path IS NOT NULL AND win_path != ''"
).map_err(|e| e.to_string())?;
let paths: Vec<String> = stmt
.query_map([], |row| row.get::<_, String>(0))
.map_err(|e| e.to_string())?
.flatten().collect();
if paths.is_empty() {
return Ok(skip_item("project_paths", "项目本地路径", "暂无配置路径的项目"));
}
let total = paths.len();
let missing = paths.iter().filter(|p| !std::path::Path::new(p).exists()).count();
if missing == 0 {
Ok(HealthItem { id: "project_paths".into(), name: "项目本地路径".into(), status: "ok".into(),
detail: Some(format!("全部 {} 个路径存在", total)) })
} else {
Ok(HealthItem { id: "project_paths".into(), name: "项目本地路径".into(), status: "warn".into(),
detail: Some(format!("{}/{} 个路径不存在(可能已移动或删除)", missing, total)) })
}
})();
result.unwrap_or_else(|e| err_item("project_paths", "项目本地路径", &e))
}
/// 检测 GitHub Token 是否有效settings.github_token
fn check_github_token() -> HealthItem {
let result: Result<HealthItem, String> = (|| {
let conn = pool().get().map_err(|e| e.to_string())?;
let token: Option<String> = conn.query_row(
"SELECT value FROM settings WHERE key = 'github_token'",
[], |row| row.get(0),
).ok();
let token = match token {
Some(t) if !t.trim().is_empty() => t.trim().to_string(),
_ => return Ok(skip_item("github_token", "GitHub Token", "未登录 GitHub 账户")),
};
match ureq::builder().timeout(std::time::Duration::from_secs(8)).build()
.get("https://api.github.com/user")
.set("Authorization", &format!("Bearer {}", token))
.set("User-Agent", "dev-manager-tauri")
.call()
{
Ok(resp) => {
let username = resp.into_json::<Value>().ok()
.and_then(|v| v["login"].as_str().map(|s| s.to_string()))
.unwrap_or_else(|| "未知账户".into());
Ok(HealthItem { id: "github_token".into(), name: "GitHub Token".into(), status: "ok".into(),
detail: Some(format!("Token 有效,账户: {}", username)) })
}
Err(ureq::Error::Status(401, _)) | Err(ureq::Error::Status(403, _)) =>
Ok(HealthItem { id: "github_token".into(), name: "GitHub Token".into(), status: "error".into(),
detail: Some("Token 已失效或权限不足,请重新登录 GitHub 账户".into()) }),
Err(e) => Ok(HealthItem { id: "github_token".into(), name: "GitHub Token".into(), status: "warn".into(),
detail: Some(format!("Token 已存储但无法联通 GitHub API可能需要代理: {}", e)) }),
}
})();
result.unwrap_or_else(|e| err_item("github_token", "GitHub Token", &e))
}
/// 检测活跃编辑器路径是否可用
/// 存储结构editors = [{id,name,path}]active_editors = ["id1","id2"]
fn check_editor() -> HealthItem {
let result: Result<HealthItem, String> = (|| {
let conn = pool().get().map_err(|e| e.to_string())?;
let get_val = |key: &str| -> Option<String> {
conn.query_row(
"SELECT value FROM settings WHERE key = ?1",
rusqlite::params![key], |row| row.get(0),
).ok()
};
// 读取编辑器列表
let editors_raw = match get_val("editors") {
Some(s) if !s.trim().is_empty() => s,
_ => return Ok(skip_item("editor", "编辑器路径", "未配置任何编辑器")),
};
let editors: Vec<Value> = serde_json::from_str(&editors_raw)
.unwrap_or_default();
if editors.is_empty() {
return Ok(skip_item("editor", "编辑器路径", "未配置任何编辑器"));
}
// 读取活跃编辑器 ID 列表(兼容旧单值字段)
let active_ids: Vec<String> = if let Some(raw) = get_val("active_editors") {
serde_json::from_str(&raw).unwrap_or_default()
} else if let Some(old_id) = get_val("active_editor") {
if old_id.is_empty() { vec![] } else { vec![old_id] }
} else {
vec![]
};
if active_ids.is_empty() {
return Ok(HealthItem { id: "editor".into(), name: "编辑器路径".into(), status: "warn".into(),
detail: Some("已添加编辑器但未勾选活跃编辑器,请前往设置页勾选至少一个".into()) });
}
// 取第一个活跃编辑器检测路径
let first_id = &active_ids[0];
let entry = editors.iter().find(|e| e["id"].as_str() == Some(first_id));
let (name, path) = match entry {
Some(e) => (
e["name"].as_str().unwrap_or("编辑器").to_string(),
e["path"].as_str().unwrap_or("").to_string(),
),
None => return Ok(HealthItem { id: "editor".into(), name: "编辑器路径".into(), status: "warn".into(),
detail: Some(format!("活跃编辑器 ID `{}` 在编辑器列表中未找到,请重新配置", first_id)) }),
};
if path.is_empty() {
return Ok(HealthItem { id: "editor".into(), name: "编辑器路径".into(), status: "warn".into(),
detail: Some(format!("{} 路径为空,请在设置页编辑该编辑器条目补全路径", name)) });
}
if std::path::Path::new(&path).exists() {
let extra = if active_ids.len() > 1 {
format!("(共 {} 个活跃编辑器)", active_ids.len())
} else {
String::new()
};
Ok(HealthItem { id: "editor".into(), name: "编辑器路径".into(), status: "ok".into(),
detail: Some(format!("{} 路径有效{}", name, extra)) })
} else {
Ok(HealthItem { id: "editor".into(), name: "编辑器路径".into(), status: "error".into(),
detail: Some(format!("{} 可执行文件不存在: {},请在设置页更新路径", name, path)) })
}
})();
result.unwrap_or_else(|e| err_item("editor", "编辑器路径", &e))
}
/// 检测 SQLite 读写延迟
fn check_db_latency() -> HealthItem {
let result: Result<HealthItem, String> = (|| {
let conn = pool().get().map_err(|e| e.to_string())?;
let start = std::time::Instant::now();
conn.execute_batch("
BEGIN;
INSERT OR REPLACE INTO settings (key, value) VALUES ('__health_probe__', '1');
DELETE FROM settings WHERE key = '__health_probe__';
COMMIT;
").map_err(|e| e.to_string())?;
let ms = start.elapsed().as_millis();
let status = if ms < 100 { "ok" } else { "warn" };
Ok(HealthItem { id: "db_latency".into(), name: "数据库延迟".into(), status: status.into(),
detail: Some(format!("读写延迟 {}ms{}", ms, if ms >= 100 { "(偏高)" } else { "" })) })
})();
result.unwrap_or_else(|e| err_item("db_latency", "数据库延迟", &e))
}
// ── 质量指标 ──────────────────────────────────────────────────────────────────
#[derive(Debug, Serialize)]
pub struct DormantProject {
pub id: String,
pub name: String,
pub days_since_push: i64,
}
#[derive(Debug, Serialize)]
pub struct OrphanedProject {
pub id: String,
pub name: String,
}
#[derive(Debug, Serialize)]
pub struct QualityMetrics {
/// last_push_at 超过 30 天且有 repo_url 的项目
pub dormant_projects: Vec<DormantProject>,
/// 未加入任何产品组且无 repo_url 的项目
pub orphaned_projects: Vec<OrphanedProject>,
/// runtime_logs 近 7 天内有 error/warn 的产品组 id 列表
pub groups_with_inject_warn: Vec<String>,
/// win_path 和 wsl_path 均为空的项目数
pub projects_without_path: i64,
}
impl Default for QualityMetrics {
fn default() -> Self {
Self {
dormant_projects: vec![],
orphaned_projects: vec![],
groups_with_inject_warn: vec![],
projects_without_path: 0,
}
}
}
fn collect_quality_metrics() -> QualityMetrics {
let conn = match pool().get() {
Ok(c) => c,
Err(_) => return QualityMetrics::default(),
};
// 1. 休眠项目last_push_at 超 30 天 且 有 repo_url
let dormant_projects: Vec<DormantProject> = {
let stmt = conn.prepare(
"SELECT pw.id, pp.name,
CAST(julianday('now') - julianday(pw.last_push_at) AS INTEGER) as days
FROM project_workspaces pw
JOIN project_profiles pp ON pp.id = pw.profile_id
WHERE (pp.repo_url IS NOT NULL AND pp.repo_url != '')
AND pw.last_push_at IS NOT NULL AND pw.last_push_at != ''
AND julianday('now') - julianday(pw.last_push_at) > 30
ORDER BY days DESC"
);
match stmt {
Err(_) => vec![],
Ok(mut stmt) => stmt
.query_map([], |row| {
Ok(DormantProject {
id: row.get(0)?,
name: row.get(1)?,
days_since_push: row.get(2)?,
})
})
.map(|rows| rows.flatten().collect())
.unwrap_or_default(),
}
};
// 2. 孤立项目:无 repo_url 且不在任何产品组
let orphaned_projects: Vec<OrphanedProject> = {
let stmt = conn.prepare(
"SELECT pw.id, pp.name
FROM project_workspaces pw
JOIN project_profiles pp ON pp.id = pw.profile_id
WHERE (pp.repo_url IS NULL OR pp.repo_url = '')
AND pw.id NOT IN (SELECT project_id FROM project_group_map)"
);
match stmt {
Err(_) => vec![],
Ok(mut stmt) => stmt
.query_map([], |row| {
Ok(OrphanedProject { id: row.get(0)?, name: row.get(1)? })
})
.map(|rows| rows.flatten().collect())
.unwrap_or_default(),
}
};
// 3. 近 7 天有注入 error/warn 的产品组(解析 context JSON 取 group_id
let groups_with_inject_warn: Vec<String> = {
let stmt = conn.prepare(
"SELECT DISTINCT context FROM runtime_logs
WHERE category = 'mcp_inject'
AND level IN ('error', 'warn')
AND ts > datetime('now', '-7 days')
AND context IS NOT NULL"
);
let contexts: Vec<String> = match stmt {
Err(_) => vec![],
Ok(mut stmt) => stmt
.query_map([], |row| row.get::<_, String>(0))
.map(|rows| rows.flatten().collect())
.unwrap_or_default(),
};
let mut group_ids: Vec<String> = contexts.iter()
.filter_map(|ctx| {
serde_json::from_str::<Value>(ctx).ok()
.and_then(|v| v["group_id"].as_str().map(|s| s.to_string()))
})
.collect();
group_ids.sort();
group_ids.dedup();
group_ids
};
// 4. 无路径项目数
let projects_without_path: i64 = conn.query_row(
"SELECT COUNT(*) FROM project_workspaces
WHERE (win_path IS NULL OR win_path = '')
AND (wsl_path IS NULL OR wsl_path = '')",
[], |row| row.get(0),
).unwrap_or(0);
QualityMetrics { dormant_projects, orphaned_projects, groups_with_inject_warn, projects_without_path }
}
/// 并行执行全部健康检查返回结构化报告内部复用入口mcp_server 也可调用)
pub fn collect_health_report() -> HealthReport {
use std::thread;
let checked_at = chrono::Local::now().format("%Y-%m-%dT%H:%M:%S").to_string();
let h_mcp = thread::spawn(check_mcp);
let h_git = thread::spawn(check_git_paths);
let h_proj = thread::spawn(check_project_paths);
let h_github = thread::spawn(check_github_token);
let h_editor = thread::spawn(check_editor);
let h_db = thread::spawn(check_db_latency);
let h_quality = thread::spawn(collect_quality_metrics);
let items = vec![
h_mcp .join().unwrap_or_else(|_| err_item("mcp", "MCP Server", "检测线程异常")),
h_git .join().unwrap_or_else(|_| err_item("git_paths", "Git 仓库路径", "检测线程异常")),
h_proj .join().unwrap_or_else(|_| err_item("project_paths", "项目本地路径", "检测线程异常")),
h_github.join().unwrap_or_else(|_| err_item("github_token", "GitHub Token", "检测线程异常")),
h_editor.join().unwrap_or_else(|_| err_item("editor", "编辑器路径", "检测线程异常")),
h_db .join().unwrap_or_else(|_| err_item("db_latency", "数据库延迟", "检测线程异常")),
];
let quality = h_quality.join().unwrap_or_default();
HealthReport { checked_at, items, quality }
}
#[tauri::command]
pub fn run_health_check() -> Result<HealthReport, String> {
Ok(collect_health_report())
}
fn expand_env(s: &str) -> String {
let mut result = s.to_string();
for (key, val) in std::env::vars() {
result = result.replace(&format!("%{key}%"), &val);
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn global_keys_contains_auth_and_infra() {
assert!(GLOBAL_KEYS.contains(&"github_client_id"));
assert!(GLOBAL_KEYS.contains(&"github_client_secret"));
assert!(GLOBAL_KEYS.contains(&"mcp_port"));
assert!(GLOBAL_KEYS.contains(&"wsl_distro"));
}
#[test]
fn space_specific_keys_not_in_global() {
// editor / dev_mode 是空间维度的配置,不应进入全局表
assert!(!GLOBAL_KEYS.contains(&"editor"));
assert!(!GLOBAL_KEYS.contains(&"dev_mode"));
}
#[test]
fn settings_routing_via_db() {
crate::db::conn_with_schema(); // 仅验证 schema 可正常建立,不依赖全局 pool
}
}