dev-manager-tauri/src-tauri/src/commands/groups.rs
lanrtop a41161f7d1
All checks were successful
Push & PR Check / check (push) Successful in 1m4s
feat(mcp): 单一全局 lian-jing 服务器——组级挂载收敛(T1-T3+T5)
T1 服务器层:新增全局 /mcp + /mcp/messages 路由,process_rpc 组上下文
Option 化;旧 /mcp/group/:gid 路由保留过渡,T6 移除。驻军测试 2 条。
T2 工具层:resolve_project_root 按 project_id 直查 project_workspaces,
删除 3 处组归属校验(实测无隔离价值);list_group_projects 改显式传参。
T3 注入层:settings.json 收敛为单 "lian-jing" key(URL /mcp),注入时
清理旧 lian-jing:group-* key;liangjing.json 主动删除 group_id;
启动时一次性迁移(重注入全量 + 清理旧全局注册两种命名格式,标记位防重)。
T5 同步:git-workflow 文档/模板、retro-catchup 技能 Python 兜底、
设置页 URL 展示、mcp-config-inject 模块取代注记。

收益:MCP 客户端配置成本 O(N组)→O(1),授权一次永久生效;
group_id 缺失/过期/移组同步一整类问题从根上消失。
2026-07-06 19:30:01 +09:00

219 lines
8.2 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 crate::mcp_inject;
use crate::models::{ProductGroup, ProjectGroupMap};
use rusqlite::params;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub struct GroupInput {
pub id: Option<String>,
pub name: String,
pub description: Option<String>,
pub space_id: Option<String>,
}
#[tauri::command]
pub fn get_groups(space_id: Option<String>) -> Result<Vec<ProductGroup>, String> {
let conn = pool().get().map_err(|e| e.to_string())?;
let result = match &space_id {
Some(sid) => {
let mut stmt = conn.prepare(
"SELECT * FROM product_groups WHERE space_id = ?1 ORDER BY sort_order ASC, id"
).map_err(|e| e.to_string())?;
let x = stmt.query_map(params![sid], ProductGroup::from_row)
.map_err(|e| e.to_string())?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| e.to_string())?; x
}
None => {
let mut stmt = conn.prepare("SELECT * FROM product_groups ORDER BY sort_order ASC, id").map_err(|e| e.to_string())?;
let x = stmt.query_map([], ProductGroup::from_row)
.map_err(|e| e.to_string())?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| e.to_string())?; x
}
};
Ok(result)
}
#[tauri::command]
pub fn create_group(input: GroupInput) -> Result<(), String> {
let conn = pool().get().map_err(|e| e.to_string())?;
let id = input.id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
conn.execute(
"INSERT INTO product_groups (id, name, description, space_id) VALUES (?1, ?2, ?3, ?4)",
params![id, input.name, input.description, input.space_id],
)
.map_err(|e| {
if e.to_string().contains("UNIQUE") { "Group id already exists".into() }
else { e.to_string() }
})?;
Ok(())
}
#[tauri::command]
pub fn update_group(id: String, input: GroupInput) -> Result<(), String> {
let conn = pool().get().map_err(|e| e.to_string())?;
conn.execute(
"UPDATE product_groups SET name = COALESCE(?1, name), description = COALESCE(?2, description) WHERE id = ?3",
params![input.name, input.description, id],
)
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub fn delete_group(id: String) -> Result<(), String> {
let conn = pool().get().map_err(|e| e.to_string())?;
// 先清除所有成员的 MCP 配置DB cascade 删除之前)
let member_ids: Vec<String> = conn
.prepare("SELECT project_id FROM project_group_map WHERE group_id = ?1")
.map_err(|e| e.to_string())?
.query_map(params![id], |row| row.get::<_, String>(0))
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
for project_id in &member_ids {
// 单一 lian-jing key 为全项目共用:成员还属于其他组时保留配置
let in_other_group: i64 = conn
.query_row(
"SELECT COUNT(*) FROM project_group_map WHERE project_id = ?1 AND group_id != ?2",
params![project_id, id],
|row| row.get(0),
)
.unwrap_or(0);
if in_other_group > 0 {
continue;
}
if let Err(e) = mcp_inject::remove_project_mcp(project_id) {
let ctx = serde_json::json!({"project_id": project_id, "group_id": id}).to_string();
log_event("mcp_inject", "warn", &format!("删除组时清除 MCP 配置失败: {e}"), Some(&ctx));
}
}
conn.execute("DELETE FROM product_groups WHERE id = ?1", params![id])
.map_err(|e| e.to_string())?;
Ok(())
}
/// 按传入的 id 顺序批量更新 sort_orderindex 即为新顺序)
#[tauri::command]
pub fn reorder_groups(ids: Vec<String>) -> Result<(), String> {
let conn = pool().get().map_err(|e| e.to_string())?;
for (i, id) in ids.iter().enumerate() {
conn.execute(
"UPDATE product_groups SET sort_order = ?1 WHERE id = ?2",
params![i as i32, id],
)
.map_err(|e| e.to_string())?;
}
Ok(())
}
#[tauri::command]
pub fn get_group_maps() -> Result<Vec<ProjectGroupMap>, String> {
let conn = pool().get().map_err(|e| e.to_string())?;
let mut stmt = conn.prepare("SELECT * FROM project_group_map").map_err(|e| e.to_string())?;
let result = stmt.query_map([], ProjectGroupMap::from_row)
.map_err(|e| e.to_string())?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| e.to_string());
result
}
/// 确保独立项目有默认产品组。
/// 若项目已属于某产品组,直接返回;否则创建 "{project_name} (独立项目)" 组,加入成员并注入 MCP。
pub fn ensure_standalone_group(project_id: &str) -> Result<(), String> {
let conn = pool().get().map_err(|e| e.to_string())?;
// 已在某个组内,跳过
let already_in_group: bool = conn
.query_row(
"SELECT COUNT(*) FROM project_group_map WHERE project_id = ?1",
params![project_id],
|row| row.get::<_, i64>(0),
)
.unwrap_or(0)
> 0;
if already_in_group {
return Ok(());
}
// 查项目显示名和所属空间
let (project_name, space_id): (String, Option<String>) = conn
.query_row(
"SELECT pp.name, pw.space_id FROM project_workspaces pw
JOIN project_profiles pp ON pp.id = pw.profile_id
WHERE pw.id = ?1",
params![project_id],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.map_err(|_| format!("项目 {project_id} 不存在或未关联 profile"))?;
let group_id = uuid::Uuid::new_v4().to_string();
let group_name = format!("{project_name} (独立项目)");
conn.execute(
"INSERT INTO product_groups (id, name, space_id) VALUES (?1, ?2, ?3)",
params![group_id, group_name, space_id],
)
.map_err(|e| e.to_string())?;
conn.execute(
"INSERT OR REPLACE INTO project_group_map (project_id, group_id) VALUES (?1, ?2)",
params![project_id, group_id],
)
.map_err(|e| e.to_string())?;
if let Err(e) = mcp_inject::inject_project_mcp(project_id) {
let ctx = serde_json::json!({"project_id": project_id, "group_id": &group_id}).to_string();
log_event("mcp_inject", "warn", &format!("独立项目 MCP 注入失败: {e}"), Some(&ctx));
}
Ok(())
}
#[tauri::command]
pub fn add_group_member(project_id: String, group_id: String, role: Option<String>) -> Result<Option<String>, String> {
let conn = pool().get().map_err(|e| e.to_string())?;
conn.execute(
"INSERT OR REPLACE INTO project_group_map (project_id, group_id, role) VALUES (?1, ?2, ?3)",
params![project_id, group_id, role],
)
.map_err(|e| e.to_string())?;
// DB 写入成功后注入 MCP 配置(非致命,注入失败不影响成员关系)
let inject_warning = match mcp_inject::inject_project_mcp(&project_id) {
Ok(()) => None,
Err(e) => {
let ctx = serde_json::json!({"project_id": &project_id, "group_id": &group_id}).to_string();
log_event("mcp_inject", "error", &format!("注入失败: {e}"), Some(&ctx));
Some(e)
}
};
Ok(inject_warning)
}
#[tauri::command]
pub fn remove_group_member(project_id: String, group_id: String) -> Result<(), String> {
let conn = pool().get().map_err(|e| e.to_string())?;
// 仅当项目退出后不再属于任何组时才清除 MCP 配置(单一 lian-jing key 为全项目共用)
let in_other_group: i64 = conn
.query_row(
"SELECT COUNT(*) FROM project_group_map WHERE project_id = ?1 AND group_id != ?2",
params![project_id, group_id],
|row| row.get(0),
)
.unwrap_or(0);
if in_other_group == 0 {
if let Err(e) = mcp_inject::remove_project_mcp(&project_id) {
let ctx = serde_json::json!({"project_id": &project_id, "group_id": &group_id}).to_string();
log_event("mcp_inject", "warn", &format!("清除 MCP 配置失败: {e}"), Some(&ctx));
}
}
conn.execute(
"DELETE FROM project_group_map WHERE project_id = ?1 AND group_id = ?2",
params![project_id, group_id],
)
.map_err(|e| e.to_string())?;
Ok(())
}