Skip to content

场景 2: 敏感操作审批

模块:Interrupts(中断/人机协作)优先级:🟠 P1(中高)业务价值:提高系统安全性,防止误操作

一、业务背景

1.1 当前风险

当前项目允许用户执行以下敏感操作,但缺少安全确认机制:

具体风险:

  • 批量删除会话 → 误操作导致重要对话丢失
  • 注销账号 → 用户后悔无法恢复
  • 导出数据 → 隐私泄露风险
  • 修改关键设置 → 影响系统使用

1.2 期望效果


二、敏感操作识别

2.1 敏感操作分类

类别操作风险等级审批方式
数据删除删除单个会话🟡 中简单确认
数据删除批量删除会话🔴 高详细确认
数据删除清空所有数据🔴 高二次确认 + 输入验证
账号操作注销账号🔴 高二次确认 + 延迟执行
数据导出导出所有对话🟡 中简单确认
系统设置修改关键配置🟡 中简单确认

2.2 识别流程


三、代码实现

3.1 审批工具定义

创建文件: services/tools/sensitive_tools.py

python
"""敏感操作相关工具

这些工具在执行前会触发 interrupt,等待用户确认。
"""
from langchain.tools import tool
from typing import Optional, Dict, Any, List
import logging

logger = logging.getLogger(__name__)


@tool
def delete_conversation(
    conversation_id: str,
    user_id: str,
    confirm: bool = False
) -> Dict[str, Any]:
    """
    删除指定的对话。

    ⚠️ 这是一个敏感操作,需要用户确认后才会执行。

    Args:
        conversation_id: 要删除的对话 ID
        user_id: 用户 ID
        confirm: 是否确认删除(由审批流程设置)

    Returns:
        操作结果信息
    """
    if not confirm:
        # 返回需要审批的信息
        return {
            "action": "delete_conversation",
            "requires_approval": True,
            "risk_level": "medium",
            "details": {
                "conversation_id": conversation_id,
                "message": "删除后对话将无法恢复"
            }
        }

    # 用户已确认,执行删除
    from core.database import get_db
    from models import Conversation, Message

    db = next(get_db())
    try:
        # 验证所有权
        conv = db.query(Conversation).filter_by(
            id=conversation_id,
            user_id=user_id
        ).first()

        if not conv:
            return {"success": False, "error": "对话不存在"}

        # 删除消息
        db.query(Message).filter_by(conversation_id=conversation_id).delete()
        # 删除对话
        db.delete(conv)
        db.commit()

        return {
            "success": True,
            "message": f"已删除对话「{conv.title}」"
        }
    except Exception as e:
        db.rollback()
        logger.error(f"删除对话失败: {e}")
        return {"success": False, "error": str(e)}


@tool
def delete_all_conversations(
    user_id: str,
    confirm: bool = False,
    confirmation_text: Optional[str] = None
) -> Dict[str, Any]:
    """
    删除用户所有对话。

    ⚠️ 这是一个高风险操作,需要用户输入确认文字。

    Args:
        user_id: 用户 ID
        confirm: 是否确认删除
        confirmation_text: 用户需要输入"确认删除所有对话"

    Returns:
        操作结果信息
    """
    if not confirm:
        return {
            "action": "delete_all_conversations",
            "requires_approval": True,
            "risk_level": "high",
            "details": {
                "message": "⚠️ 这将删除您的所有对话记录",
                "warning": "此操作不可恢复!",
                "confirmation_required": "请输入「确认删除所有对话」以继续"
            }
        }

    # 验证确认文字
    if confirmation_text != "确认删除所有对话":
        return {
            "success": False,
            "error": "确认文字不正确,操作已取消"
        }

    # 执行删除
    from core.database import get_db
    from models import Conversation, Message
    from sqlalchemy import delete

    db = next(get_db())
    try:
        # 获取对话 ID 列表
        conv_ids = [c.id for c in db.query(Conversation.id).filter_by(user_id=user_id).all()]

        # 删除消息
        db.execute(delete(Message).where(Message.conversation_id.in_(conv_ids)))
        # 删除对话
        db.execute(delete(Conversation).where(Conversation.user_id == user_id))
        db.commit()

        return {
            "success": True,
            "message": f"已删除 {len(conv_ids)} 个对话"
        }
    except Exception as e:
        db.rollback()
        logger.error(f"批量删除失败: {e}")
        return {"success": False, "error": str(e)}


@tool
def export_conversations(
    user_id: str,
    conversation_ids: Optional[List[str]] = None,
    export_all: bool = False,
    confirm: bool = False
) -> Dict[str, Any]:
    """
    导出对话数据。

    Args:
        user_id: 用户 ID
        conversation_ids: 要导出的对话 ID 列表
        export_all: 是否导出全部
        confirm: 是否确认导出

    Returns:
        导出结果(包含下载链接)
    """
    if export_all and not confirm:
        return {
            "action": "export_all_conversations",
            "requires_approval": True,
            "risk_level": "medium",
            "details": {
                "message": "将导出您的所有对话数据",
                "warning": "导出文件包含敏感信息,请妥善保管"
            }
        }

    # 执行导出
    from core.database import get_db
    from models import Conversation, Message
    import json
    from datetime import datetime

    db = next(get_db())
    try:
        query = db.query(Conversation).filter_by(user_id=user_id)
        if conversation_ids:
            query = query.filter(Conversation.id.in_(conversation_ids))

        conversations = query.all()
        export_data = []

        for conv in conversations:
            messages = db.query(Message).filter_by(conversation_id=conv.id).order_by(Message.id).all()
            export_data.append({
                "id": conv.id,
                "title": conv.title,
                "created_at": conv.created_at.isoformat(),
                "messages": [
                    {
                        "role": msg.role,
                        "content": msg.content,
                        "created_at": msg.created_at.isoformat()
                    }
                    for msg in messages
                ]
            })

        # 生成导出文件
        filename = f"conversations_{user_id}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
        # TODO: 保存到 COS 并返回下载链接

        return {
            "success": True,
            "message": f"已导出 {len(export_data)} 个对话",
            "download_url": f"/api/download/{filename}",
            "conversation_count": len(export_data)
        }
    except Exception as e:
        logger.error(f"导出失败: {e}")
        return {"success": False, "error": str(e)}

3.2 审批 Agent

修改 services/langgraph_approval.py(在集成方案中定义的基础上,添加实际业务逻辑):

python
"""审批 Agent - 敏感操作人机协作

针对项目实际业务需求实现。
"""
from typing import Literal, Optional, Dict, Any
from langgraph.graph import StateGraph, MessagesState, START, END
from langgraph.types import interrupt, Command
from langgraph.prebuilt import ToolNode
from langchain_openai import ChatOpenAI
from langchain_core.messages import SystemMessage, HumanMessage
import os
import logging

from services.tools.sensitive_tools import (
    delete_conversation,
    delete_all_conversations,
    export_conversations,
)

logger = logging.getLogger(__name__)


class ApprovalState(MessagesState):
    """审批状态"""
    user_id: str
    pending_action: Optional[Dict[str, Any]] = None
    approved: Optional[bool] = None
    action_result: Optional[str] = None


class ApprovalAgent:
    """敏感操作审批 Agent"""

    SENSITIVE_TOOLS = [delete_conversation, delete_all_conversations, export_conversations]

    def __init__(self):
        self.tools_by_name = {t.name: t for t in self.SENSITIVE_TOOLS}

    def _should_interrupt(self, tool_name: str, tool_args: dict) -> bool:
        """判断是否需要中断"""
        # 所有敏感工具都需要中断
        return tool_name in self.tools_by_name

    def _approval_node(self, state: ApprovalState) -> Command[Literal["execute", "cancel"]]:
        """审批节点 - 中断等待用户确认"""
        last_message = state["messages"][-1]
        tool_calls = last_message.tool_calls

        if not tool_calls:
            return Command(goto=END)

        tool_call = tool_calls[0]
        tool_name = tool_call["name"]
        tool_args = tool_call["args"]

        # 获取工具的风险信息
        tool_result = self.tools_by_name[tool_name].invoke(tool_args)

        if not tool_result.get("requires_approval"):
            # 不需要审批,直接执行
            return Command(goto="execute")

        # 触发中断
        decision = interrupt({
            "action": tool_name,
            "risk_level": tool_result.get("risk_level", "medium"),
            "details": tool_result.get("details", {}),
            "confirmation_required": tool_result.get("details", {}).get("confirmation_required")
        })

        if decision:
            # 用户批准
            return Command(
                goto="execute",
                update={"approved": True, "pending_action": tool_call}
            )
        else:
            # 用户拒绝
            return Command(
                goto="cancel",
                update={"approved": False, "pending_action": tool_call}
            )

    def _execute_node(self, state: ApprovalState) -> ApprovalState:
        """执行节点 - 执行已批准的操作"""
        tool_call = state.get("pending_action")
        if not tool_call:
            return {"action_result": "没有待执行的操作"}

        # 添加确认标记
        args = tool_call["args"].copy()
        args["confirm"] = True

        # 执行工具
        result = self.tools_by_name[tool_call["name"]].invoke(args)

        return {
            "action_result": result.get("message", "操作完成"),
            "approved": None,
            "pending_action": None
        }

    def _cancel_node(self, state: ApprovalState) -> ApprovalState:
        """取消节点"""
        return {
            "action_result": "操作已取消",
            "approved": None,
            "pending_action": None
        }

    def build_graph(self, checkpointer):
        """构建审批工作流"""
        llm = ChatOpenAI(
            model=os.getenv("OPENROUTER_MODEL", "openai/gpt-4o-mini"),
            api_key=os.getenv("OPENROUTER_API_KEY"),
            base_url=os.getenv("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1")
        ).bind_tools(self.SENSITIVE_TOOLS)

        def agent_node(state: ApprovalState):
            system_prompt = """你是一个智能助手。当用户要求执行以下操作时,使用相应的工具:

- 删除对话 → 使用 delete_conversation
- 删除所有对话 → 使用 delete_all_conversations
- 导出对话 → 使用 export_conversations

对于敏感操作,系统会自动请求用户确认。"""
            messages = [SystemMessage(content=system_prompt)] + state["messages"]
            response = llm.invoke(messages)
            return {"messages": [response]}

        def should_approve(state: ApprovalState) -> Literal["approval", END]:
            last_message = state["messages"][-1]
            if last_message.tool_calls:
                tool_name = last_message.tool_calls[0]["name"]
                if tool_name in self.tools_by_name:
                    return "approval"
            return END

        workflow = StateGraph(ApprovalState)
        workflow.add_node("agent", agent_node)
        workflow.add_node("approval", self._approval_node)
        workflow.add_node("execute", self._execute_node)
        workflow.add_node("cancel", self._cancel_node)

        workflow.add_edge(START, "agent")
        workflow.add_conditional_edges("agent", should_approve, {"approval": "approval", END: END})
        workflow.add_edge("execute", END)
        workflow.add_edge("cancel", END)

        return workflow.compile(checkpointer=checkpointer)

四、前端审批组件

4.1 审批对话框

javascript
// static/js/approval-dialog.js

class ApprovalDialog {
    constructor() {
        this.dialog = null;
    }

    show(interruptData, onDecision) {
        this.close();

        const { action, risk_level, details, confirmation_required } = interruptData;

        // 根据风险等级选择样式
        const riskConfig = {
            high: {
                icon: '⚠️',
                color: '#f44336',
                title: '高风险操作'
            },
            medium: {
                icon: '⚡',
                color: '#ff9800',
                title: '敏感操作'
            },
            low: {
                icon: 'ℹ️',
                color: '#2196f3',
                title: '确认操作'
            }
        };

        const config = riskConfig[risk_level] || riskConfig.medium;

        this.dialog = document.createElement('div');
        this.dialog.className = 'approval-dialog-overlay';
        this.dialog.innerHTML = `
            <div class="approval-dialog ${risk_level}-risk">
                <div class="approval-header" style="border-color: ${config.color}">
                    <span class="approval-icon">${config.icon}</span>
                    <h3>${config.title}</h3>
                </div>
                <div class="approval-body">
                    <div class="approval-action">
                        操作: ${this.getActionDisplayName(action)}
                    </div>
                    <div class="approval-details">
                        ${details.message ? `<p>📝 ${details.message}</p>` : ''}
                        ${details.warning ? `<p class="warning">⚠️ ${details.warning}</p>` : ''}
                    </div>
                    ${confirmation_required ? `
                        <div class="confirmation-input">
                            <label>${confirmation_required}</label>
                            <input type="text" id="confirmation-text" placeholder="请输入确认文字">
                        </div>
                    ` : ''}
                </div>
                <div class="approval-footer">
                    <button class="btn-cancel" onclick="approvalDialog.cancel()">取消</button>
                    <button class="btn-confirm" onclick="approvalDialog.confirm()" style="background: ${config.color}">
                        确认执行
                    </button>
                </div>
            </div>
        `;

        document.body.appendChild(this.dialog);
        this.onDecision = onDecision;
    }

    confirm() {
        const confirmationInput = this.dialog.querySelector('#confirmation-text');
        if (confirmationInput && confirmationInput.value) {
            this.onDecision(true, { confirmation_text: confirmationInput.value });
        } else {
            this.onDecision(true);
        }
        this.close();
    }

    cancel() {
        this.onDecision(false);
        this.close();
    }

    close() {
        if (this.dialog) {
            this.dialog.remove();
            this.dialog = null;
        }
    }

    getActionDisplayName(action) {
        const names = {
            'delete_conversation': '删除对话',
            'delete_all_conversations': '删除所有对话',
            'export_all_conversations': '导出所有对话'
        };
        return names[action] || action;
    }
}

const approvalDialog = new ApprovalDialog();

4.2 CSS 样式

css
/* 审批对话框样式 */
.approval-dialog-overlay {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    background: rgba(0, 0, 0, 0.6);
    display: flex;
    align-items: center;
    justify-content: center;
    z-index: 2000;
    backdrop-filter: blur(4px);
}

.approval-dialog {
    background: white;
    border-radius: 16px;
    max-width: 480px;
    width: 90%;
    box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
    overflow: hidden;
    animation: dialog-appear 0.2s ease-out;
}

@keyframes dialog-appear {
    from {
        opacity: 0;
        transform: scale(0.9);
    }
    to {
        opacity: 1;
        transform: scale(1);
    }
}

.approval-header {
    padding: 20px 24px;
    border-bottom: 3px solid;
    display: flex;
    align-items: center;
    gap: 12px;
}

.approval-icon {
    font-size: 24px;
}

.approval-header h3 {
    margin: 0;
    font-size: 18px;
    color: #333;
}

.approval-body {
    padding: 24px;
}

.approval-action {
    font-size: 16px;
    font-weight: 600;
    margin-bottom: 16px;
    color: #333;
}

.approval-details p {
    margin: 8px 0;
    font-size: 14px;
    color: #666;
}

.approval-details .warning {
    color: #f44336;
    font-weight: 500;
}

.confirmation-input {
    margin-top: 16px;
    padding-top: 16px;
    border-top: 1px solid #eee;
}

.confirmation-input label {
    display: block;
    font-size: 13px;
    color: #666;
    margin-bottom: 8px;
}

.confirmation-input input {
    width: 100%;
    padding: 12px;
    border: 2px solid #ddd;
    border-radius: 8px;
    font-size: 14px;
    transition: border-color 0.2s;
}

.confirmation-input input:focus {
    border-color: #4a90d9;
    outline: none;
}

.approval-footer {
    padding: 16px 24px;
    background: #f8f9fa;
    display: flex;
    gap: 12px;
    justify-content: flex-end;
}

.btn-cancel, .btn-confirm {
    padding: 12px 24px;
    border: none;
    border-radius: 8px;
    font-size: 14px;
    font-weight: 500;
    cursor: pointer;
    transition: all 0.2s;
}

.btn-cancel {
    background: #e0e0e0;
    color: #666;
}

.btn-cancel:hover {
    background: #d0d0d0;
}

.btn-confirm {
    color: white;
}

.btn-confirm:hover {
    filter: brightness(1.1);
}

/* 高风险特殊样式 */
.approval-dialog.high-risk .approval-header {
    background: #ffebee;
}

.approval-dialog.high-risk .btn-confirm {
    animation: pulse-warning 2s infinite;
}

@keyframes pulse-warning {
    0%, 100% { box-shadow: 0 0 0 0 rgba(244, 67, 54, 0.4); }
    50% { box-shadow: 0 0 0 8px rgba(244, 67, 54, 0); }
}

五、API 集成

python
# api/approval.py

from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel
from typing import Optional, Dict, Any

router = APIRouter(prefix="/api", tags=["approval"])


class ApprovalRequest(BaseModel):
    thread_id: str
    approved: bool
    confirmation_text: Optional[str] = None


@router.post("/approval/resume")
async def resume_approval(request: ApprovalRequest, req: Request):
    """
    恢复中断的审批流程

    用户在前端确认或取消后,调用此接口继续执行。
    """
    from services.langgraph_approval import get_approval_agent
    from services.checkpointer import get_checkpointer

    agent = get_approval_agent()

    with get_checkpointer() as checkpointer:
        graph = agent.build_graph(checkpointer)
        config = {"configurable": {"thread_id": request.thread_id}}

        # 构建恢复命令
        if request.approved:
            resume_data = True
            if request.confirmation_text:
                resume_data = {
                    "approved": True,
                    "confirmation_text": request.confirmation_text
                }
        else:
            resume_data = False

        result = graph.invoke(
            Command(resume=resume_data),
            config
        )

        return {
            "success": True,
            "action_result": result.get("action_result", ""),
            "approved": request.approved
        }

六、预期收益

6.1 安全提升

场景改进前改进后
误删除无法恢复二次确认防误操作
批量删除一键删除无提示明确警告 + 输入验证
账号注销即时生效延迟确认期

6.2 用户体验


七、实施计划

步骤任务预估时间
1创建 services/tools/sensitive_tools.py2h
2实现 services/langgraph_approval.py2h
3添加 API 端点 /api/approval/resume1h
4前端审批对话框组件2h
5集成测试1h
总计8h (1天)