use axum::{ body::Bytes, extract::{Path, Query, State}, http::{HeaderMap, StatusCode}, response::{ sse::{Event, KeepAlive, Sse}, IntoResponse, Response, }, routing::{get, post}, Json, Router, }; use serde::Deserialize; use serde_json::{json, Value}; use std::{collections::HashMap, convert::Infallible, net::SocketAddr, sync::Arc}; use tokio::sync::{mpsc, Mutex}; use tokio_stream::{wrappers::ReceiverStream, StreamExt as _}; use crate::{db, db::log_event}; /// SSE 会话表:session_id → 发送端((event_name, data)) type Sessions = Arc>>>; /// 从 settings 表读取 MCP 端口,默认 27190 pub fn read_port() -> u16 { db::pool() .get() .ok() .and_then(|c| { c.query_row( "SELECT value FROM settings WHERE key = 'mcp_port'", [], |r| r.get::<_, String>(0), ) .ok() }) .and_then(|v| v.parse().ok()) .unwrap_or(27190) } /// 启动 MCP HTTP 服务(同时支持 Streamable HTTP 和 SSE 两种传输) pub async fn start(port: u16) { let sessions: Sessions = Arc::new(Mutex::new(HashMap::new())); let app = Router::new() .route("/health", get(health)) // Streamable HTTP(POST)— 保持向后兼容,Python 直调仍可用 .route("/mcp/group/:group_id", post(handle_mcp).get(handle_sse_connect)) // SSE 消息端点:客户端通过此 URL 发送 JSON-RPC,响应经 SSE 推回 .route("/mcp/group/:group_id/messages", post(handle_sse_message)) // Gitea webhook 观测面:接收 push 事件写入 commit_metrics(飞轮数据源) .route("/webhook/gitea/:project_id", post(handle_gitea_webhook)) .with_state(sessions); let addr = SocketAddr::from(([0, 0, 0, 0], port)); match tokio::net::TcpListener::bind(addr).await { Ok(listener) => { log_event("mcp_server", "info", &format!("MCP Server 已启动,端口 {port}(HTTP + SSE)"), None); if let Err(e) = axum::serve(listener, app).await { log_event("mcp_server", "error", &format!("Server 异常退出: {e}"), None); } } Err(e) => log_event("mcp_server", "error", &format!("绑定端口 {port} 失败: {e}"), None), } } async fn health() -> &'static str { "炼境 MCP Server running" } // ── Gitea Webhook 观测面 ────────────────────────────────────────────────────── /// push payload 中的一条 commit #[derive(Debug, PartialEq)] struct PushCommit { sha: String, message: String, timestamp: Option, } /// HMAC-SHA256 验签。Gitea 实际发送的签名头是 `X-Gitea-Signature`(payload 的 /// HMAC-SHA256 hex,R10:以实际抓包行为为准,非架构文档假设的 X-Gitea-Signature-256)。 fn verify_hmac_sha256(secret: &str, body: &[u8], sig_hex: &str) -> bool { use hmac::{Hmac, Mac}; use sha2::Sha256; let mut mac = match Hmac::::new_from_slice(secret.as_bytes()) { Ok(m) => m, Err(_) => return false, }; mac.update(body); let expected: String = mac .finalize() .into_bytes() .iter() .map(|b| format!("{b:02x}")) .collect(); // 大小写不敏感比较(Gitea 输出小写 hex) expected.eq_ignore_ascii_case(sig_hex.trim()) } /// 解析 Gitea push payload 的 commits 数组 fn parse_push_payload(body: &[u8]) -> Result, String> { let json: Value = serde_json::from_slice(body).map_err(|e| format!("payload 非法 JSON: {e}"))?; let commits = json["commits"].as_array().ok_or("payload 缺少 commits 数组")?; Ok(commits .iter() .filter_map(|c| { let sha = c["id"].as_str()?.to_string(); let message = c["message"].as_str().unwrap_or("").to_string(); let timestamp = c["timestamp"].as_str().map(|s| s.to_string()); Some(PushCommit { sha, message, timestamp }) }) .collect()) } /// 从请求头提取签名:优先 X-Gitea-Signature,兼容 X-Hub-Signature-256(sha256= 前缀) fn extract_signature(headers: &HeaderMap) -> Option { if let Some(v) = headers.get("x-gitea-signature").and_then(|v| v.to_str().ok()) { return Some(v.to_string()); } headers .get("x-hub-signature-256") .and_then(|v| v.to_str().ok()) .map(|v| v.trim_start_matches("sha256=").to_string()) } async fn handle_gitea_webhook( Path(project_id): Path, headers: HeaderMap, body: Bytes, ) -> Response { // 1. 查绑定与 secret let secret: Option = db::pool().get().ok().and_then(|c| { c.query_row( "SELECT webhook_secret FROM gitea_repos WHERE project_id = ?1", [&project_id], |r| r.get(0), ) .ok() }); let secret = match secret { Some(s) => s, None => { return (StatusCode::NOT_FOUND, Json(json!({"error": "项目未绑定 Gitea 仓库或未设置 webhook"}))) .into_response() } }; // 2. 验签 let sig = extract_signature(&headers); let verified = sig.as_deref().map(|s| verify_hmac_sha256(&secret, &body, s)).unwrap_or(false); if !verified { log_event( "gitea_webhook", "warn", &format!("项目 {project_id} webhook 验签失败(签名{})", if sig.is_some() { "不匹配" } else { "缺失" }), None, ); return (StatusCode::UNAUTHORIZED, Json(json!({"error": "签名验证失败"}))).into_response(); } // 3. 只处理 push 事件,其他事件确认收到但忽略 let event = headers .get("x-gitea-event") .and_then(|v| v.to_str().ok()) .unwrap_or(""); if event != "push" { return Json(json!({"ignored": event})).into_response(); } // 4. 解析 commits 并写入 commit_metrics(sha 主键幂等,与本地 watcher 双路径不重复计数) let commits = match parse_push_payload(&body) { Ok(c) => c, Err(e) => { return (StatusCode::BAD_REQUEST, Json(json!({"error": e}))).into_response(); } }; let conn = match db::pool().get() { Ok(c) => c, Err(e) => { return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": e.to_string()}))).into_response() } }; let mut received = 0; for c in &commits { if crate::commands::commit_metrics::record_commit( &conn, &project_id, &c.sha, &c.message, c.timestamp.as_deref(), ) .is_ok() { received += 1; } } log_event( "gitea_webhook", "info", &format!("项目 {project_id} push 事件已接收,写入 {received}/{} 条 commit", commits.len()), None, ); Json(json!({"received": received})).into_response() } // ── JSON-RPC 类型 ───────────────────────────────────────────────────────────── #[derive(Deserialize)] struct RpcRequest { #[allow(dead_code)] jsonrpc: String, method: String, id: Option, #[serde(default)] params: Option, } #[derive(Deserialize)] struct SessionQuery { session_id: String, } // ── 共享 RPC 处理逻辑 ───────────────────────────────────────────────────────── async fn process_rpc(group_id: &str, req: &RpcRequest) -> Result { match req.method.as_str() { "initialize" => Ok(json!({ "protocolVersion": "2024-11-05", "capabilities": { "tools": {} }, "serverInfo": { "name": "炼境", "version": env!("CARGO_PKG_VERSION") } })), "tools/list" => Ok(tools_list_result()), "tools/call" => Ok(tools_call(group_id, req.params.as_ref()).await), _ => Err(format!("Method not found: {}", req.method)), } } // ── Streamable HTTP(POST)— 向后兼容,Python 直调走此路径 ───────────────────── async fn handle_mcp(Path(group_id): Path, Json(req): Json) -> Response { // MCP 通知(无 id)→ 202 Accepted if req.id.is_none() { return StatusCode::ACCEPTED.into_response(); } let id = req.id.clone(); match process_rpc(&group_id, &req).await { Ok(result) => Json(json!({ "jsonrpc": "2.0", "id": id, "result": result })).into_response(), Err(msg) => Json(json!({ "jsonrpc": "2.0", "id": id, "error": { "code": -32601, "message": msg } })) .into_response(), } } // ── SSE 传输(GET + POST /messages)───────────────────────────────────────── /// GET /mcp/group/:group_id /// Claude Code 发起 SSE 握手,建立长连接,首条事件告知后续 POST 的 URL。 async fn handle_sse_connect( State(sessions): State, Path(group_id): Path, ) -> Sse>> { let session_id = uuid::Uuid::new_v4().to_string(); let (tx, rx) = mpsc::channel::<(String, String)>(32); // 第一条事件:告知客户端后续 POST 的 endpoint URL let port = read_port(); let endpoint_uri = format!( "http://127.0.0.1:{}/mcp/group/{}/messages?session_id={}", port, group_id, session_id ); let _ = tx.send(("endpoint".to_string(), endpoint_uri)).await; sessions.lock().await.insert(session_id, tx); let stream = ReceiverStream::new(rx) .map(|(evt_name, data)| Ok::(Event::default().event(evt_name).data(data))); Sse::new(stream).keep_alive(KeepAlive::default()) } /// POST /mcp/group/:group_id/messages?session_id=xxx /// 客户端通过此 URL 发送 JSON-RPC 请求,响应经 SSE channel 推回。 async fn handle_sse_message( State(sessions): State, Path(group_id): Path, Query(query): Query, Json(req): Json, ) -> Response { if req.id.is_none() { return StatusCode::ACCEPTED.into_response(); } let id = req.id.clone(); let response_json = match process_rpc(&group_id, &req).await { Ok(result) => json!({ "jsonrpc": "2.0", "id": id, "result": result }), Err(msg) => json!({ "jsonrpc": "2.0", "id": id, "error": { "code": -32601, "message": msg } }), }; let mut sessions_guard = sessions.lock().await; if let Some(tx) = sessions_guard.get(&query.session_id) { if tx .send(("message".to_string(), response_json.to_string())) .await .is_err() { // 连接已关闭,惰性清理 sessions_guard.remove(&query.session_id); } } StatusCode::ACCEPTED.into_response() } // ── 工具定义 ────────────────────────────────────────────────────────────────── fn tools_list_result() -> Value { json!({ "tools": [ { "name": "get_diagnostics", "description": "查询炼境运行时诊断信息:MCP Server 启动状态、各项目 MCP 配置注入结果、最近错误日志。排查功能是否正常时首先调用此工具。", "inputSchema": { "type": "object", "properties": {}, "required": [] } }, { "name": "list_group_projects", "description": "列出产品组内所有成员项目,包含名称、路径、平台、技术栈、角色等信息", "inputSchema": { "type": "object", "properties": {}, "required": [] } }, { "name": "get_project_blueprint", "description": "读取产品组内某个项目的蓝图文件(.blueprint/manifest.yaml),了解该项目的模块与任务状态", "inputSchema": { "type": "object", "properties": { "project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" } }, "required": ["project_id"] } }, { "name": "get_project_file", "description": "读取产品组内某个项目的任意文件内容,如 openapi.yaml、README.md、src/types.ts 等", "inputSchema": { "type": "object", "properties": { "project_id": { "type": "string", "description": "项目 workspace ID" }, "relative_path": { "type": "string", "description": "相对于项目根目录的文件路径,如 openapi.yaml 或 src/api/routes.ts" } }, "required": ["project_id", "relative_path"] } }, { "name": "get_project_mentor_context", "description": "获取产品组内某个项目的完整导师上下文——包含健康状态、蓝图进度摘要、近期错误日志,以及格式化好的 Markdown 报告(可直接用于质量分析和成长建议)", "inputSchema": { "type": "object", "properties": { "project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" } }, "required": ["project_id"] } }, { "name": "append_project_note", "description": "将 Claude 的分析结论或复盘记录写入项目笔记,供未来会话和用户在炼境中查阅。完成 M/L 复杂度任务后必须调用,写入结构化复盘笔记。", "inputSchema": { "type": "object", "properties": { "project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" }, "content": { "type": "string", "description": "笔记内容。完成 M/L 任务后使用以下结构化格式:\n【复盘】<任务标题>\n任务类型: 后端命令|前端组件|数据层|MCP工具|架构调整\n复杂度: S|M|L\n实际轮数: N轮(预估M轮)\n是否返工: 是/否\n返工原因: <若返工填写,否则省略>\n有效策略: <本次奏效的做法>\n遗留风险: <若有填写,否则省略>\n\n其他场景(分析结论、建议、洞察)可自由格式。" } }, "required": ["project_id", "content"] } }, { "name": "get_project_snapshots", "description": "获取项目的历史健康快照(每日一条),包含 git 提交间隔、蓝图完成率、阻塞任务数、错误情况。用于分析趋势、识别高价值改进变量。", "inputSchema": { "type": "object", "properties": { "project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" }, "days": { "type": "integer", "description": "查询最近 N 天的快照,默认 30" } }, "required": ["project_id"] } } ] }) } // ── 工具调用 ────────────────────────────────────────────────────────────────── async fn tools_call(group_id: &str, params: Option<&Value>) -> Value { let tool_name = params .and_then(|p| p.get("name")) .and_then(|n| n.as_str()) .unwrap_or("") .to_string(); let args = params .and_then(|p| p.get("arguments")) .cloned() .unwrap_or(json!({})); let gid = group_id.to_string(); let outcome = tokio::task::spawn_blocking(move || match tool_name.as_str() { "get_diagnostics" => get_diagnostics(), "list_group_projects" => list_group_projects(&gid), "get_project_blueprint" => { let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); get_project_blueprint(&gid, pid) } "get_project_file" => { let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); let rel = args .get("relative_path") .and_then(|v| v.as_str()) .unwrap_or(""); get_project_file(&gid, pid, rel) } "get_project_mentor_context" => { let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); get_project_mentor_context(&gid, pid) } "append_project_note" => { let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); let content = args.get("content").and_then(|v| v.as_str()).unwrap_or(""); append_project_note(&gid, pid, content) } "get_project_snapshots" => { let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); let days = args.get("days").and_then(|v| v.as_u64()).unwrap_or(30) as u32; get_project_snapshots(&gid, pid, days) } _ => Err(format!("未知工具: {}", tool_name)), }) .await; match outcome { Ok(Ok(text)) => json!({ "content": [{ "type": "text", "text": text }] }), Ok(Err(e)) => json!({ "content": [{ "type": "text", "text": e }], "isError": true }), Err(e) => json!({ "content": [{ "type": "text", "text": e.to_string() }], "isError": true }), } } // ── 工具实现(同步,由 spawn_blocking 包裹)────────────────────────────────── fn get_diagnostics() -> Result { let conn = db::pool().get().map_err(|e| e.to_string())?; let port = read_port(); // 服务启动时间(首条 mcp_server info 日志) let start_time: Option = conn.query_row( "SELECT ts FROM runtime_logs WHERE category = 'mcp_server' AND level = 'info' ORDER BY id ASC LIMIT 1", [], |r| r.get(0), ).ok(); let mut out = String::new(); out.push_str("## 炼境 MCP 诊断报告\n\n"); out.push_str(&format!("- **服务端口**: {}\n", port)); out.push_str(&format!("- **启动时间**: {}\n\n", start_time.as_deref().unwrap_or("未知(可能在本次日志之前启动)"))); // MCP 注入状态:每个 project_id 取最后一条 out.push_str("### MCP 注入状态(每个项目最后一次结果)\n\n"); let mut stmt = conn.prepare( "SELECT context, level, message, ts FROM runtime_logs WHERE category = 'mcp_inject' AND id IN ( SELECT MAX(id) FROM runtime_logs WHERE category = 'mcp_inject' GROUP BY json_extract(context, '$.project_id') ) ORDER BY ts DESC LIMIT 50", ).map_err(|e| e.to_string())?; let inject_rows: Vec<(Option, String, String, String)> = stmt .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))) .map_err(|e| e.to_string())? .filter_map(|r| r.ok()) .collect(); if inject_rows.is_empty() { out.push_str("_暂无注入记录(成员加入产品组时会产生)_\n\n"); } else { for (ctx, level, msg, ts) in &inject_rows { let project_id = ctx.as_deref() .and_then(|s| serde_json::from_str::(s).ok()) .and_then(|v| v.get("project_id").and_then(|x| x.as_str()).map(|s| s.to_string())) .unwrap_or_else(|| "unknown".to_string()); let icon = if level == "info" { "✅" } else { "⚠️" }; out.push_str(&format!("- {} `{}` — {} _({})\n", icon, project_id, msg, ts)); } out.push('\n'); } // 最近 20 条 error/warn out.push_str("### 最近错误日志(最多 20 条)\n\n"); let mut stmt2 = conn.prepare( "SELECT ts, category, level, message FROM runtime_logs WHERE level IN ('error', 'warn') ORDER BY id DESC LIMIT 20", ).map_err(|e| e.to_string())?; let error_rows: Vec<(String, String, String, String)> = stmt2 .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))) .map_err(|e| e.to_string())? .filter_map(|r| r.ok()) .collect(); if error_rows.is_empty() { out.push_str("_无错误记录_ ✅\n"); } else { for (ts, cat, level, msg) in &error_rows { out.push_str(&format!("- `[{}]` **{}** `{}` — {}\n", ts, level, cat, msg)); } } // ── 系统健康(复用 health check 逻辑)──────────────────────────────────── out.push_str("\n### 系统健康\n\n"); let report = crate::commands::settings::collect_health_report(); out.push_str(&format!("检查时间:{}\n\n", report.checked_at)); out.push_str("| 检查项 | 状态 | 详情 |\n|--------|------|------|\n"); for item in &report.items { let icon = match item.status.as_str() { "ok" => "✅", "warn" => "⚠️", "error" => "❌", _ => "—", }; out.push_str(&format!( "| {} | {} {} | {} |\n", item.name, icon, item.status, item.detail.as_deref().unwrap_or("-") )); } // ── 质量指标 ────────────────────────────────────────────────────────────── out.push_str("\n### 质量指标\n\n"); let q = &report.quality; out.push_str(&format!("- **休眠项目**(>30天未 push):{} 个\n", q.dormant_projects.len())); for p in &q.dormant_projects { out.push_str(&format!(" - {} ({} 天)\n", p.name, p.days_since_push)); } out.push_str(&format!("- **孤立项目**(无分组 & 无 repo):{} 个\n", q.orphaned_projects.len())); for p in &q.orphaned_projects { out.push_str(&format!(" - {}\n", p.name)); } out.push_str(&format!( "- **MCP 注入异常产品组**(近 7 天):{} 个\n", q.groups_with_inject_warn.len() )); for gid in &q.groups_with_inject_warn { out.push_str(&format!(" - `{}`\n", gid)); } out.push_str(&format!("- **无本地路径项目**:{} 个\n", q.projects_without_path)); Ok(out) } fn list_group_projects(group_id: &str) -> Result { let conn = db::pool().get().map_err(|e| e.to_string())?; let mut stmt = conn .prepare( "SELECT pw.id, pp.name, pw.platform, pw.win_path, pw.wsl_path, pp.tech_stack, pgm.role FROM project_group_map pgm JOIN project_workspaces pw ON pw.id = pgm.project_id JOIN project_profiles pp ON pp.id = pw.profile_id WHERE pgm.group_id = ?1", ) .map_err(|e| e.to_string())?; let rows: Vec = stmt .query_map([group_id], |row| { let id: String = row.get(0)?; let name: String = row.get(1)?; let platform: Option = row.get(2)?; let win_path: Option = row.get(3)?; let wsl_path: Option = row.get(4)?; let tech_stack: Option = row.get(5)?; let role: Option = row.get(6)?; Ok(format!( "- **{}** (id: `{}`)\n 平台: {} | 技术栈: {}{}\n Windows 路径: {}\n WSL 路径: {}", name, id, platform.as_deref().unwrap_or("unknown"), tech_stack.as_deref().unwrap_or("未配置"), role.map(|r| format!(" | 角色: {}", r)).unwrap_or_default(), win_path.as_deref().unwrap_or("未配置"), wsl_path.as_deref().unwrap_or("未配置"), )) }) .map_err(|e| e.to_string())? .filter_map(|r| r.ok()) .collect(); if rows.is_empty() { return Ok("该产品组暂无成员项目。".to_string()); } Ok(format!( "产品组成员(共 {} 个项目):\n\n{}", rows.len(), rows.join("\n\n") )) } fn get_project_blueprint(group_id: &str, project_id: &str) -> Result { let root = resolve_project_root(group_id, project_id)?; let path = root.join(".blueprint").join("manifest.yaml"); std::fs::read_to_string(&path) .map_err(|e| format!("读取蓝图失败({}): {}", path.display(), e)) } fn get_project_file(group_id: &str, project_id: &str, relative_path: &str) -> Result { if relative_path.contains("..") { return Err("路径不允许包含 \"..\"".to_string()); } let root = resolve_project_root(group_id, project_id)?; let path = root.join(relative_path); std::fs::read_to_string(&path) .map_err(|e| format!("读取文件失败({}): {}", path.display(), e)) } /// 根据 group_id + project_id 解析项目根目录路径 /// WSL 项目自动转换为 Windows UNC 路径(\\wsl.localhost\\...) fn resolve_project_root(group_id: &str, project_id: &str) -> Result { let conn = db::pool().get().map_err(|e| e.to_string())?; let row: rusqlite::Result<(Option, Option, Option)> = conn.query_row( "SELECT pw.win_path, pw.wsl_path, pw.platform FROM project_group_map pgm JOIN project_workspaces pw ON pw.id = pgm.project_id WHERE pgm.group_id = ?1 AND pgm.project_id = ?2", [group_id, project_id], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), ); let (win_path, wsl_path, platform) = row.map_err(|_| { format!("项目 {} 不属于产品组 {},或项目不存在", project_id, group_id) })?; if platform.as_deref() == Some("wsl") { let wsl = wsl_path.ok_or_else(|| "WSL 项目未配置 wsl_path".to_string())?; let distro: String = conn .query_row( "SELECT value FROM settings WHERE key = 'wsl_distro'", [], |r| r.get(0), ) .unwrap_or_else(|_| "Ubuntu".to_string()); // /home/user/project → \\wsl.localhost\Ubuntu\home\user\project let inner = wsl.trim_start_matches('/').replace('/', "\\"); Ok(std::path::PathBuf::from(format!( "\\\\wsl.localhost\\{}\\{}", distro, inner ))) } else { let win = win_path.ok_or_else(|| "Windows 项目未配置 win_path".to_string())?; Ok(std::path::PathBuf::from(win)) } } fn get_project_mentor_context(group_id: &str, project_id: &str) -> Result { // 验证项目属于该产品组(与其他 MCP 工具一致) let conn = db::pool().get().map_err(|e| e.to_string())?; let exists: bool = conn .query_row( "SELECT COUNT(*) FROM project_group_map WHERE group_id = ?1 AND project_id = ?2", [group_id, project_id], |r| r.get::<_, i64>(0), ) .map(|n| n > 0) .unwrap_or(false); if !exists { return Err(format!("项目 {} 不属于产品组 {},或项目不存在", project_id, group_id)); } let ctx = crate::commands::mentor::get_mentor_context(project_id.to_string()) .map_err(|e| format!("获取导师上下文失败: {e}"))?; Ok(ctx.mentor_markdown) } fn append_project_note(group_id: &str, project_id: &str, content: &str) -> Result { if content.trim().is_empty() { return Err("笔记内容不能为空".to_string()); } let conn = db::pool().get().map_err(|e| e.to_string())?; // 校验 project_id 属于该 group let exists: bool = conn .query_row( "SELECT COUNT(*) FROM project_group_map WHERE group_id = ?1 AND project_id = ?2", [group_id, project_id], |r| r.get::<_, i64>(0), ) .map(|n| n > 0) .unwrap_or(false); if !exists { return Err(format!("项目 {} 不属于产品组 {},或项目不存在", project_id, group_id)); } conn.execute( "INSERT INTO project_notes (project_id, source, content) VALUES (?1, 'mcp', ?2)", rusqlite::params![project_id, content], ) .map_err(|e| e.to_string())?; Ok("笔记已保存 ✅".to_string()) } fn get_project_snapshots(group_id: &str, project_id: &str, days: u32) -> Result { let conn = db::pool().get().map_err(|e| e.to_string())?; // 校验项目属于该产品组 let exists: bool = conn .query_row( "SELECT COUNT(*) FROM project_group_map WHERE group_id = ?1 AND project_id = ?2", [group_id, project_id], |r| r.get::<_, i64>(0), ) .map(|n| n > 0) .unwrap_or(false); if !exists { return Err(format!("项目 {} 不属于产品组 {},或项目不存在", project_id, group_id)); } let mut stmt = conn .prepare( "SELECT snapshot_date, days_since_commit, blueprint_done_rate, blueprint_in_progress, blueprint_blocked, has_errors FROM project_snapshots WHERE project_id = ?1 AND snapshot_date >= date('now', 'localtime', ?2) ORDER BY snapshot_date ASC", ) .map_err(|e| e.to_string())?; let offset = format!("-{} days", days); let rows: Vec<(String, Option, Option, i64, i64, i64)> = stmt .query_map(rusqlite::params![project_id, offset], |r| { Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?)) }) .map_err(|e| e.to_string())? .filter_map(|r| r.ok()) .collect(); if rows.is_empty() { return Ok(format!( "项目 `{}` 暂无历史快照(快照在每次应用启动时写入,最多保留 {} 天)。", project_id, days )); } let mut md = format!( "# 项目历史快照:`{}`\n\n最近 {} 天,共 {} 条记录\n\n", project_id, days, rows.len() ); md.push_str("| 日期 | 距上次提交(天) | 蓝图完成率 | 进行中模块 | 阻塞任务 | 有错误 |\n"); md.push_str("|------|--------------|-----------|-----------|---------|------|\n"); for (date, days_commit, done_rate, in_prog, blocked, has_err) in &rows { let commit_col = days_commit .map(|d| d.to_string()) .unwrap_or_else(|| "—".to_string()); let rate_col = done_rate .map(|r| format!("{:.0}%", r * 100.0)) .unwrap_or_else(|| "—".to_string()); let err_col = if *has_err == 1 { "⚠️" } else { "✅" }; md.push_str(&format!( "| {} | {} | {} | {} | {} | {} |\n", date, commit_col, rate_col, in_prog, blocked, err_col )); } md.push_str("\n---\n\n请根据以上趋势数据,分析哪些变量与项目停滞或健康度下降最相关,并给出改进建议。\n"); Ok(md) } // ── Gitea Webhook 单元测试 ──────────────────────────────────────────────────── #[cfg(test)] mod webhook_tests { use super::*; /// 用相同算法生成签名,验证正反两个方向 fn sign(secret: &str, body: &[u8]) -> String { use hmac::{Hmac, Mac}; use sha2::Sha256; let mut mac = Hmac::::new_from_slice(secret.as_bytes()).unwrap(); mac.update(body); mac.finalize().into_bytes().iter().map(|b| format!("{b:02x}")).collect() } #[test] fn hmac_verify_accepts_valid_signature() { let body = br#"{"commits":[]}"#; let sig = sign("my-secret", body); assert!(verify_hmac_sha256("my-secret", body, &sig)); // 大写 hex 也应通过 assert!(verify_hmac_sha256("my-secret", body, &sig.to_uppercase())); } #[test] fn hmac_verify_rejects_wrong_secret_or_tampered_body() { let body = br#"{"commits":[]}"#; let sig = sign("my-secret", body); assert!(!verify_hmac_sha256("other-secret", body, &sig)); assert!(!verify_hmac_sha256("my-secret", br#"{"commits":[1]}"#, &sig)); assert!(!verify_hmac_sha256("my-secret", body, "deadbeef")); } #[test] fn parse_push_payload_extracts_commits() { let body = br#"{ "ref": "refs/heads/master", "commits": [ {"id": "abc123", "message": "feat(core): add thing", "timestamp": "2026-07-02T10:00:00+08:00"}, {"id": "def456", "message": "fix(ui): repair", "timestamp": null} ] }"#; let commits = parse_push_payload(body).unwrap(); assert_eq!(commits.len(), 2); assert_eq!(commits[0].sha, "abc123"); assert_eq!(commits[0].message, "feat(core): add thing"); assert_eq!(commits[0].timestamp.as_deref(), Some("2026-07-02T10:00:00+08:00")); assert_eq!(commits[1].timestamp, None); } #[test] fn parse_push_payload_rejects_invalid() { assert!(parse_push_payload(b"not json").is_err()); assert!(parse_push_payload(br#"{"no_commits": true}"#).is_err()); // commits 为空数组是合法的(如仅 tag push) assert_eq!(parse_push_payload(br#"{"commits":[]}"#).unwrap().len(), 0); } }