- AuditLog +298(筛选/详情/i18n)+ audit 后端 record/mod - AI 命令层:generate_image +81 / fetch_url / fetch_search / skills / tool_registry / tools/file / provider / conversation - 前端组件:AiChat/TopBar/ConversationSidebar/GitChanges/ApprovalPopup/Dashboard/ProjectDetail 等 30+ + composables + i18n - 诊断文档: aichat历史会话实证诊断-2026-08-04 + project_soft_delete 测试
226 lines
8.1 KiB
Rust
226 lines
8.1 KiB
Rust
//! 审计记录写/查操作 + 审批历史 DTO + 审计面板查询 IPC。
|
||
//!
|
||
//! 第六批从 audit/mod.rs 抽离,行为零变更。包含:
|
||
//! - `audit_tool_call`:写一条工具执行审计记录(insert)
|
||
//! - `record_audit`:`audit_tool_call` 的别名,语义更清晰("记一条审计")
|
||
//! - `query_audit_history`:按条件查询审计历史记录
|
||
//! - `ToolExecutionDto`:传给前端的精简审计视图
|
||
//! - `list_tool_executions`:审批历史面板查询 IPC
|
||
//!
|
||
//! 依赖 audit/utils.rs 的 `truncate_chars` 做参数/结果截断,通过 `super::truncate_chars` 引用。
|
||
|
||
use serde::{Deserialize, Serialize};
|
||
use tauri::State;
|
||
|
||
use df_ai::ai_tools::RiskLevel;
|
||
use df_storage::crud::{AiToolExecutionRepo, AuditQuery};
|
||
use df_storage::models::AiToolExecutionRecord;
|
||
use df_types::types::new_id;
|
||
|
||
use crate::commands::err_str;
|
||
use crate::commands::now_millis;
|
||
use crate::state::AppState;
|
||
|
||
use super::risk_str;
|
||
|
||
/// 写一条工具执行审计记录(insert 失败不阻断主流程,故 `let _ =`)
|
||
///
|
||
/// `decided_by` 有值(auto/human)= 已决策执行 → 记 executed_at;
|
||
/// `None`(pending 待审批)→ executed_at 留空,待 audit_finalize 回填。
|
||
pub(crate) async fn audit_tool_call(
|
||
repo: &AiToolExecutionRepo,
|
||
conv_id: &str,
|
||
tool_call_id: &str,
|
||
tool_name: &str,
|
||
arguments: &str,
|
||
status: &str,
|
||
risk_level: RiskLevel,
|
||
result: Option<String>,
|
||
decided_by: Option<&str>,
|
||
message_id: Option<&str>,
|
||
) {
|
||
let executed_at = if decided_by.is_some() { Some(now_millis()) } else { None };
|
||
if let Err(e) = repo
|
||
.insert(AiToolExecutionRecord {
|
||
id: new_id(),
|
||
conversation_id: Some(conv_id.to_string()),
|
||
// P1 消息级溯源:message_id 由调用方(process_tool_calls)从
|
||
// ContextManager 取当前 assistant 消息 id 传入(LLM 返回带 tool_calls 的
|
||
// assistant 消息已 push 到 per_conv.messages,入口取末条 assistant id)。
|
||
// None 表示无 assistant 消息(异常路径/老数据无 id),展示侧兼容。
|
||
message_id: message_id.map(|s| s.to_string()),
|
||
tool_call_id: tool_call_id.to_string(),
|
||
tool_name: tool_name.to_string(),
|
||
arguments: arguments.to_string(),
|
||
result,
|
||
status: status.to_string(),
|
||
risk_level: risk_str(risk_level).to_string(),
|
||
requested_at: now_millis(),
|
||
executed_at,
|
||
decided_by: decided_by.map(|s| s.to_string()),
|
||
})
|
||
.await
|
||
{
|
||
tracing::error!(
|
||
"audit_tool_call: 写审计记录失败(conv={}, tool_call_id={}, tool={}): {}",
|
||
conv_id,
|
||
tool_call_id,
|
||
tool_name,
|
||
e
|
||
);
|
||
}
|
||
}
|
||
|
||
/// `audit_tool_call` 的语义别名,功能完全相同。
|
||
/// 命名更符合"记录一条审计"的调用意图,供新代码使用。
|
||
#[allow(dead_code)]
|
||
pub(crate) async fn record_audit(
|
||
repo: &AiToolExecutionRepo,
|
||
conv_id: &str,
|
||
tool_call_id: &str,
|
||
tool_name: &str,
|
||
arguments: &str,
|
||
status: &str,
|
||
risk_level: RiskLevel,
|
||
result: Option<String>,
|
||
decided_by: Option<&str>,
|
||
message_id: Option<&str>,
|
||
) {
|
||
audit_tool_call(repo, conv_id, tool_call_id, tool_name, arguments, status, risk_level, result, decided_by, message_id).await;
|
||
}
|
||
|
||
/// 查询审计历史记录(分页,按 requested_at 倒序)。
|
||
///
|
||
/// 封装 `list_recent` 添加一层可读语义,方便未来扩展筛选条件。
|
||
#[allow(dead_code)]
|
||
pub(crate) async fn query_audit_history(
|
||
repo: &AiToolExecutionRepo,
|
||
limit: u32,
|
||
offset: u32,
|
||
) -> Result<Vec<AiToolExecutionRecord>, String> {
|
||
let limit = limit.min(200);
|
||
repo.list_recent(limit, offset)
|
||
.await
|
||
.map_err(|e| format!("query_audit_history 查询失败: {}", e))
|
||
}
|
||
|
||
/// 审批历史 DTO(传给前端的精简视图,敏感字段截断防泄露)
|
||
///
|
||
/// arguments/result 在落库时是完整 JSON(可能含项目名/路径/长结果),审计面板只展示摘要,
|
||
/// 故截断到固定长度(参数 120 / 结果 160),既保留可读性又不泄露全量数据到前端 DOM。
|
||
#[derive(Debug, Clone, Serialize)]
|
||
pub struct ToolExecutionDto {
|
||
pub id: String,
|
||
pub conversation_id: Option<String>,
|
||
pub tool_call_id: String,
|
||
pub tool_name: String,
|
||
/// 参数摘要(截断 120 字符,完整原值仍留库)
|
||
pub arguments_brief: String,
|
||
/// 结果摘要(截断 160 字符,None → 空串便于前端展示)
|
||
pub result_brief: Option<String>,
|
||
/// pending/approved/rejected/executing/completed/failed
|
||
pub status: String,
|
||
/// low/medium/high
|
||
pub risk_level: String,
|
||
pub requested_at: String,
|
||
pub executed_at: Option<String>,
|
||
/// human/auto,None 表示尚未决策
|
||
pub decided_by: Option<String>,
|
||
}
|
||
|
||
/// 审批历史查询入参(前端透传,空值=不过滤)。
|
||
///
|
||
/// 复用 [`AuditQuery`](`df_storage::crud::AuditQuery`) 的字段语义:status/risk_level 精确匹配,
|
||
/// tool_keyword 走 tool_name LIKE。limit/offset 默认 50/0,storage 层钳制 limit ≤ 200。
|
||
///
|
||
/// `Deserialize`:Tauri IPC 从前端 JSON 反序列化。
|
||
#[derive(Debug, Clone, Default, Deserialize)]
|
||
pub struct ToolExecQuery {
|
||
/// 状态精确匹配(pending/approved/rejected/executing/completed/failed/interrupted)
|
||
pub status: Option<String>,
|
||
/// 风险等级精确匹配(low/medium/high)
|
||
pub risk_level: Option<String>,
|
||
/// 工具名关键词(tool_name LIKE %kw%)
|
||
pub tool_keyword: Option<String>,
|
||
pub limit: Option<u32>,
|
||
pub offset: Option<u32>,
|
||
}
|
||
|
||
impl From<ToolExecQuery> for AuditQuery {
|
||
fn from(q: ToolExecQuery) -> Self {
|
||
AuditQuery {
|
||
status: q.status,
|
||
risk_level: q.risk_level,
|
||
tool_keyword: q.tool_keyword,
|
||
limit: q.limit,
|
||
offset: q.offset,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 审批历史分页结果(对标项目通用 `{items,total,has_more}` 结构)。
|
||
///
|
||
/// - `items`:当前页审计 DTO 列表
|
||
/// - `total`:满足筛选条件的总行数(忽略分页裁剪,前端用于"第 N 页 / 共 M 条"展示)
|
||
/// - `has_more`:基于 `loaded < total` 推断,而非"本页是否满 limit"启发式
|
||
#[derive(Debug, Clone, Serialize)]
|
||
pub struct ToolExecutionPage {
|
||
pub items: Vec<ToolExecutionDto>,
|
||
pub total: i64,
|
||
pub has_more: bool,
|
||
}
|
||
|
||
/// 审批历史面板查询:按 requested_at 倒序(最新在前)分页返回工具调用审计记录。
|
||
///
|
||
/// 支持 status / risk_level / 工具名关键词筛选(WHERE 在后端收口,非前端 filter 当前页)。
|
||
/// 默认 limit=50 / offset=0(第一页)。limit 在 storage 层钳制 ≤200 防滥用。
|
||
/// 敏感字段(arguments/result)截断成摘要返回,完整原值仍留库。
|
||
///
|
||
/// 返回 `{items,total,has_more}`:total 为满足筛选条件的真实总数(独立 COUNT 查询),
|
||
/// has_more 基于 `offset + items.len() < total` 推断。
|
||
#[tauri::command]
|
||
pub async fn list_tool_executions(
|
||
state: State<'_, AppState>,
|
||
query: Option<ToolExecQuery>,
|
||
) -> Result<ToolExecutionPage, String> {
|
||
let q = query.unwrap_or_default();
|
||
let _limit = q.limit.unwrap_or(50);
|
||
let offset = q.offset.unwrap_or(0);
|
||
let audit_q = AuditQuery::from(q);
|
||
|
||
let records = state
|
||
.ai_tool_executions
|
||
.list_by_query(&audit_q)
|
||
.await
|
||
.map_err(err_str)?;
|
||
let total = state
|
||
.ai_tool_executions
|
||
.count_by_query(&audit_q)
|
||
.await
|
||
.map_err(err_str)?;
|
||
|
||
let items: Vec<ToolExecutionDto> = records
|
||
.into_iter()
|
||
.map(|r| ToolExecutionDto {
|
||
id: r.id,
|
||
conversation_id: r.conversation_id,
|
||
tool_call_id: r.tool_call_id,
|
||
tool_name: r.tool_name,
|
||
arguments_brief: super::truncate_chars(&r.arguments, 120),
|
||
result_brief: r.result.map(|s| super::truncate_chars(&s, 160)),
|
||
status: r.status,
|
||
risk_level: r.risk_level,
|
||
requested_at: r.requested_at,
|
||
executed_at: r.executed_at,
|
||
decided_by: r.decided_by,
|
||
})
|
||
.collect();
|
||
let has_more = (offset as i64 + items.len() as i64) < total;
|
||
|
||
Ok(ToolExecutionPage {
|
||
items,
|
||
total,
|
||
has_more,
|
||
})
|
||
}
|