- db: 新增 project_snapshots 表,每日一条,UNIQUE(project_id, snapshot_date) - mentor: 启动扫描写入快照(days_since_commit / blueprint_done_rate / blocked / errors) - mentor: ProjectHealth 加入 recent_commits(最近 10 条含日期) - mentor: MentorContext 加入 active_tasks(进行中/阻塞任务卡含 files/acceptance) - mentor: build_mentor_markdown 加入 AI 笔记段落(跨会话记忆) - mcp: 新增 get_project_snapshots 工具,Claude 可查询历史趋势数据
599 lines
24 KiB
Rust
599 lines
24 KiB
Rust
use axum::{
|
||
extract::Path,
|
||
http::StatusCode,
|
||
response::{IntoResponse, Response},
|
||
routing::{get, post},
|
||
Json, Router,
|
||
};
|
||
use serde::Deserialize;
|
||
use serde_json::{json, Value};
|
||
use std::net::SocketAddr;
|
||
|
||
use crate::{db, db::log_event};
|
||
|
||
/// 从 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 transport)
|
||
pub async fn start(port: u16) {
|
||
let app = Router::new()
|
||
.route("/health", get(health))
|
||
.route("/mcp/group/:group_id", post(handle_mcp));
|
||
|
||
let addr = SocketAddr::from(([127, 0, 0, 1], port));
|
||
match tokio::net::TcpListener::bind(addr).await {
|
||
Ok(listener) => {
|
||
log_event("mcp_server", "info", &format!("MCP Server 已启动,端口 {port}"), 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"
|
||
}
|
||
|
||
// ── JSON-RPC 类型 ─────────────────────────────────────────────────────────────
|
||
|
||
#[derive(Deserialize)]
|
||
struct RpcRequest {
|
||
#[allow(dead_code)]
|
||
jsonrpc: String,
|
||
method: String,
|
||
id: Option<Value>,
|
||
#[serde(default)]
|
||
params: Option<Value>,
|
||
}
|
||
|
||
async fn handle_mcp(Path(group_id): Path<String>, Json(req): Json<RpcRequest>) -> Response {
|
||
// MCP 通知(无 id)→ 202 Accepted,无需响应体
|
||
if req.id.is_none() {
|
||
return StatusCode::ACCEPTED.into_response();
|
||
}
|
||
|
||
let id = req.id.clone();
|
||
|
||
let result = match req.method.as_str() {
|
||
"initialize" => json!({
|
||
"protocolVersion": "2024-11-05",
|
||
"capabilities": { "tools": {} },
|
||
"serverInfo": {
|
||
"name": "炼境",
|
||
"version": env!("CARGO_PKG_VERSION")
|
||
}
|
||
}),
|
||
"tools/list" => tools_list_result(),
|
||
"tools/call" => tools_call(&group_id, req.params.as_ref()).await,
|
||
_ => {
|
||
return Json(json!({
|
||
"jsonrpc": "2.0",
|
||
"id": id,
|
||
"error": { "code": -32601, "message": "Method not found" }
|
||
}))
|
||
.into_response();
|
||
}
|
||
};
|
||
|
||
Json(json!({ "jsonrpc": "2.0", "id": id, "result": result })).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 的分析结论、建议或洞察写入项目笔记,供未来会话和用户在炼境中查阅。分析完项目后,将核心观点记录在此。",
|
||
"inputSchema": {
|
||
"type": "object",
|
||
"properties": {
|
||
"project_id": {
|
||
"type": "string",
|
||
"description": "项目 workspace ID(来自 list_group_projects 的返回结果)"
|
||
},
|
||
"content": {
|
||
"type": "string",
|
||
"description": "笔记内容,支持 Markdown。建议包含:核心观点、发现的问题、下一步建议。"
|
||
}
|
||
},
|
||
"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<String, String> {
|
||
let conn = db::pool().get().map_err(|e| e.to_string())?;
|
||
let port = read_port();
|
||
|
||
// 服务启动时间(首条 mcp_server info 日志)
|
||
let start_time: Option<String> = 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, 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::<serde_json::Value>(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<String, String> {
|
||
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<String> = stmt
|
||
.query_map([group_id], |row| {
|
||
let id: String = row.get(0)?;
|
||
let name: String = row.get(1)?;
|
||
let platform: Option<String> = row.get(2)?;
|
||
let win_path: Option<String> = row.get(3)?;
|
||
let wsl_path: Option<String> = row.get(4)?;
|
||
let tech_stack: Option<String> = row.get(5)?;
|
||
let role: Option<String> = 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<String, String> {
|
||
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<String, String> {
|
||
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\<distro>\...)
|
||
fn resolve_project_root(group_id: &str, project_id: &str) -> Result<std::path::PathBuf, String> {
|
||
let conn = db::pool().get().map_err(|e| e.to_string())?;
|
||
|
||
let row: rusqlite::Result<(Option<String>, Option<String>, Option<String>)> = 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<String, String> {
|
||
// 验证项目属于该产品组(与其他 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<String, String> {
|
||
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<String, String> {
|
||
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<i64>, Option<f64>, 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)
|
||
}
|