zhou zhou
7 小时以前 d5884d0974d17d96225a5d80e432de33a5ee6552
#AI.日志与审计
12个文件已添加
8个文件已修改
1195 ■■■■■ 已修改文件
rsf-admin/src/api/ai/observe.js 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-admin/src/config/authProvider.js 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-admin/src/i18n/en.js 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-admin/src/i18n/zh.js 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-admin/src/page/ResourceContent.js 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-admin/src/page/system/aiCallLog/AiCallLogList.jsx 378 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-admin/src/page/system/aiCallLog/index.jsx 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/controller/AiCallLogController.java 76 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatToolEventDto.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiObserveStatsDto.java 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/entity/AiCallLog.java 118 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/entity/AiMcpCallLog.java 76 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/mapper/AiCallLogMapper.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/mapper/AiMcpCallLogMapper.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiCallLogService.java 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/MountedToolCallback.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiCallLogServiceImpl.java 184 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiChatServiceImpl.java 116 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/McpMountRuntimeFactoryImpl.java 62 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
version/db/ai_feature.sql 61 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-admin/src/api/ai/observe.js
New file
@@ -0,0 +1,19 @@
import request from "@/utils/request";
export const getAiObserveStats = async () => {
    const res = await request.get("aiCallLog/stats");
    const { code, msg, data } = res.data;
    if (code === 200) {
        return data;
    }
    throw new Error(msg || "获取 AI 观测统计失败");
};
export const getAiCallLogMcpLogs = async (callLogId) => {
    const res = await request.get(`aiCallLog/${callLogId}/mcpLogs`);
    const { code, msg, data } = res.data;
    if (code === 200) {
        return data || [];
    }
    throw new Error(msg || "获取 MCP 调用日志失败");
};
rsf-admin/src/config/authProvider.js
@@ -7,7 +7,6 @@
const AI_COMPONENTS = new Set([
  'aiDiagnosis',
  'aiDiagnosisPlan',
  'aiCallLog',
  'aiRoute',
  'aiToolConfig',
]);
rsf-admin/src/i18n/en.js
@@ -153,6 +153,7 @@
        aiParam: 'AI Params',
        aiPrompt: 'Prompts',
        aiMcpMount: 'MCP Mounts',
        aiCallLog: 'AI Observe',
        tenant: 'Tenant',
        userLogin: 'Token',
        customer: 'Customer',
rsf-admin/src/i18n/zh.js
@@ -154,6 +154,7 @@
        aiParam: 'AI 参数',
        aiPrompt: 'Prompt 管理',
        aiMcpMount: 'MCP 挂载',
        aiCallLog: 'AI 观测',
        tenant: '租户管理',
        userLogin: '登录日志',
        customer: '客户表',
rsf-admin/src/page/ResourceContent.js
@@ -73,6 +73,7 @@
import aiParam from "./system/aiParam";
import aiPrompt from "./system/aiPrompt";
import aiMcpMount from "./system/aiMcpMount";
import aiCallLog from "./system/aiCallLog";
const ResourceContent = (node) => {
  switch (node.component) {
@@ -214,6 +215,8 @@
      return aiPrompt;
    case "aiMcpMount":
      return aiMcpMount;
    case "aiCallLog":
      return aiCallLog;
    // case "locItem":
    //   return locItem;
    default:
rsf-admin/src/page/system/aiCallLog/AiCallLogList.jsx
New file
@@ -0,0 +1,378 @@
import React, { useEffect, useMemo, useState } from "react";
import {
    FilterButton,
    List,
    SearchInput,
    SelectInput,
    TextInput,
    TopToolbar,
    useListContext,
    useNotify,
} from "react-admin";
import {
    Alert,
    Box,
    Button,
    Card,
    CardActions,
    CardContent,
    Chip,
    CircularProgress,
    Dialog,
    DialogActions,
    DialogContent,
    DialogTitle,
    Divider,
    Grid,
    Stack,
    Typography,
} from "@mui/material";
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
import { getAiCallLogMcpLogs, getAiObserveStats } from "@/api/ai/observe";
const filters = [
    <SearchInput source="condition" alwaysOn />,
    <TextInput source="requestId" label="请求ID" />,
    <TextInput source="promptCode" label="Prompt 编码" />,
    <TextInput source="userId" label="用户ID" />,
    <SelectInput
        source="status"
        label="状态"
        choices={[
            { id: "RUNNING", name: "RUNNING" },
            { id: "COMPLETED", name: "COMPLETED" },
            { id: "FAILED", name: "FAILED" },
            { id: "ABORTED", name: "ABORTED" },
        ]}
    />,
];
const ObserveSummary = () => {
    const [stats, setStats] = useState(null);
    const [loading, setLoading] = useState(true);
    const [error, setError] = useState("");
    useEffect(() => {
        let active = true;
        setLoading(true);
        getAiObserveStats()
            .then((data) => {
                if (active) {
                    setStats(data);
                    setError("");
                }
            })
            .catch((err) => {
                if (active) {
                    setError(err?.message || "获取 AI 观测统计失败");
                }
            })
            .finally(() => {
                if (active) {
                    setLoading(false);
                }
            });
        return () => {
            active = false;
        };
    }, []);
    return (
        <Box px={2} pt={2}>
            <Card variant="outlined" sx={{ borderRadius: 3, boxShadow: "0 8px 24px rgba(15, 23, 42, 0.06)" }}>
                <CardContent>
                    <Stack direction="row" justifyContent="space-between" alignItems="center" mb={2}>
                        <Box>
                            <Typography variant="h6">观测总览</Typography>
                            <Typography variant="body2" color="text.secondary">
                                当前租户下的 AI 对话调用与 MCP 工具调用统计。
                            </Typography>
                        </Box>
                        {loading && <CircularProgress size={24} />}
                    </Stack>
                    {error && <Alert severity="error">{error}</Alert>}
                    {!loading && !error && stats && (
                        <Grid container spacing={2}>
                            <Grid item xs={12} md={3}>
                                <Typography variant="caption" color="text.secondary">AI 调用量</Typography>
                                <Typography variant="h5">{stats.callCount ?? 0}</Typography>
                                <Typography variant="body2" color="text.secondary">
                                    成功 {stats.successCount ?? 0} / 失败 {stats.failureCount ?? 0}
                                </Typography>
                            </Grid>
                            <Grid item xs={12} md={3}>
                                <Typography variant="caption" color="text.secondary">平均耗时</Typography>
                                <Typography variant="h5">{stats.avgElapsedMs ?? 0} ms</Typography>
                                <Typography variant="body2" color="text.secondary">
                                    首包 {stats.avgFirstTokenLatencyMs ?? 0} ms
                                </Typography>
                            </Grid>
                            <Grid item xs={12} md={3}>
                                <Typography variant="caption" color="text.secondary">Token 使用</Typography>
                                <Typography variant="h5">{stats.totalTokens ?? 0}</Typography>
                                <Typography variant="body2" color="text.secondary">
                                    平均 {stats.avgTotalTokens ?? 0}
                                </Typography>
                            </Grid>
                            <Grid item xs={12} md={3}>
                                <Typography variant="caption" color="text.secondary">工具成功率</Typography>
                                <Typography variant="h5">{Number(stats.toolSuccessRate || 0).toFixed(2)}%</Typography>
                                <Typography variant="body2" color="text.secondary">
                                    调用 {stats.toolCallCount ?? 0} / 失败 {stats.toolFailureCount ?? 0}
                                </Typography>
                            </Grid>
                        </Grid>
                    )}
                </CardContent>
            </Card>
        </Box>
    );
};
const resolveStatusChip = (status) => {
    if (status === "COMPLETED") {
        return { color: "success", label: "成功" };
    }
    if (status === "FAILED") {
        return { color: "error", label: "失败" };
    }
    if (status === "ABORTED") {
        return { color: "warning", label: "中断" };
    }
    return { color: "default", label: status || "--" };
};
const AiCallLogDetailDialog = ({ record, open, onClose }) => {
    const notify = useNotify();
    const [logs, setLogs] = useState([]);
    const [loading, setLoading] = useState(false);
    useEffect(() => {
        if (!open || !record?.id) {
            return;
        }
        let active = true;
        setLoading(true);
        getAiCallLogMcpLogs(record.id)
            .then((data) => {
                if (active) {
                    setLogs(data);
                }
            })
            .catch((error) => {
                if (active) {
                    notify(error?.message || "获取 MCP 调用日志失败", { type: "error" });
                }
            })
            .finally(() => {
                if (active) {
                    setLoading(false);
                }
            });
        return () => {
            active = false;
        };
    }, [open, record, notify]);
    if (!record) {
        return null;
    }
    return (
        <Dialog open={open} onClose={onClose} fullWidth maxWidth="lg">
            <DialogTitle>AI 调用详情</DialogTitle>
            <DialogContent dividers>
                <Grid container spacing={2}>
                    <Grid item xs={12} md={6}>
                        <Typography variant="caption" color="text.secondary">请求ID</Typography>
                        <Typography variant="body2">{record.requestId || "--"}</Typography>
                    </Grid>
                    <Grid item xs={12} md={3}>
                        <Typography variant="caption" color="text.secondary">用户ID</Typography>
                        <Typography variant="body2">{record.userId || "--"}</Typography>
                    </Grid>
                    <Grid item xs={12} md={3}>
                        <Typography variant="caption" color="text.secondary">会话ID</Typography>
                        <Typography variant="body2">{record.sessionId || "--"}</Typography>
                    </Grid>
                    <Grid item xs={12} md={4}>
                        <Typography variant="caption" color="text.secondary">Prompt</Typography>
                        <Typography variant="body2">{record.promptName || "--"} / {record.promptCode || "--"}</Typography>
                    </Grid>
                    <Grid item xs={12} md={4}>
                        <Typography variant="caption" color="text.secondary">模型</Typography>
                        <Typography variant="body2">{record.model || "--"}</Typography>
                    </Grid>
                    <Grid item xs={12} md={4}>
                        <Typography variant="caption" color="text.secondary">状态</Typography>
                        <Typography variant="body2">{record.status || "--"}</Typography>
                    </Grid>
                    <Grid item xs={12}>
                        <Typography variant="caption" color="text.secondary">MCP 挂载</Typography>
                        <Typography variant="body2">{record.mountedMcpNames || "--"}</Typography>
                    </Grid>
                    {record.errorMessage && (
                        <Grid item xs={12}>
                            <Alert severity="error">{record.errorMessage}</Alert>
                        </Grid>
                    )}
                    <Grid item xs={12}>
                        <Divider sx={{ my: 1 }} />
                        <Stack direction="row" justifyContent="space-between" alignItems="center" mb={1}>
                            <Typography variant="h6">MCP 工具调用日志</Typography>
                            {loading && <CircularProgress size={20} />}
                        </Stack>
                        {!loading && !logs.length && (
                            <Typography variant="body2" color="text.secondary">
                                当前调用没有产生 MCP 工具日志。
                            </Typography>
                        )}
                        <Stack spacing={1.5}>
                            {logs.map((item) => (
                                <Card key={item.id} variant="outlined">
                                    <CardContent>
                                        <Stack direction="row" justifyContent="space-between" alignItems="center" mb={1}>
                                            <Box>
                                                <Typography variant="subtitle2">{item.toolName || "--"}</Typography>
                                                <Typography variant="body2" color="text.secondary">
                                                    {item.mountName || "--"} · {item.createTime$ || "--"}
                                                </Typography>
                                            </Box>
                                            <Chip
                                                size="small"
                                                color={item.status === "COMPLETED" ? "success" : "error"}
                                                label={item.status || "--"}
                                            />
                                        </Stack>
                                        <Typography variant="caption" color="text.secondary">输入摘要</Typography>
                                        <Typography variant="body2" sx={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
                                            {item.inputSummary || "--"}
                                        </Typography>
                                        <Typography variant="caption" color="text.secondary" display="block" mt={1}>
                                            输出摘要 / 错误
                                        </Typography>
                                        <Typography variant="body2" sx={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
                                            {item.outputSummary || item.errorMessage || "--"}
                                        </Typography>
                                    </CardContent>
                                </Card>
                            ))}
                        </Stack>
                    </Grid>
                </Grid>
            </DialogContent>
            <DialogActions>
                <Button onClick={onClose}>关闭</Button>
            </DialogActions>
        </Dialog>
    );
};
const AiCallLogCards = ({ onView }) => {
    const { data, isLoading } = useListContext();
    const records = useMemo(() => (Array.isArray(data) ? data : []), [data]);
    if (isLoading) {
        return (
            <Box display="flex" justifyContent="center" py={8}>
                <CircularProgress size={28} />
            </Box>
        );
    }
    if (!records.length) {
        return (
            <Box px={2} py={6}>
                <Card variant="outlined" sx={{ p: 3, textAlign: "center", borderStyle: "dashed" }}>
                    <Typography variant="subtitle1">暂无 AI 调用日志</Typography>
                    <Typography variant="body2" color="text.secondary" mt={1}>
                        发起 AI 对话后,这里会展示调用统计和审计记录。
                    </Typography>
                </Card>
            </Box>
        );
    }
    return (
        <Box px={2} py={2}>
            <Grid container spacing={2}>
                {records.map((record) => {
                    const statusMeta = resolveStatusChip(record.status);
                    return (
                        <Grid item xs={12} md={6} xl={4} key={record.id}>
                            <Card variant="outlined" sx={{ height: "100%", borderRadius: 3, boxShadow: "0 8px 24px rgba(15, 23, 42, 0.06)" }}>
                                <CardContent sx={{ pb: 1.5 }}>
                                    <Stack direction="row" justifyContent="space-between" alignItems="flex-start" spacing={1}>
                                        <Box>
                                            <Typography variant="h6">{record.promptName || "AI 对话"}</Typography>
                                            <Typography variant="body2" color="text.secondary">
                                                {record.promptCode || "--"} / {record.model || "--"}
                                            </Typography>
                                        </Box>
                                        <Chip size="small" color={statusMeta.color} label={statusMeta.label} />
                                    </Stack>
                                    <Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap mt={1.5}>
                                        <Chip size="small" variant="outlined" label={`用户 ${record.userId || "--"}`} />
                                        <Chip size="small" variant="outlined" label={`耗时 ${record.elapsedMs ?? 0} ms`} />
                                        <Chip size="small" variant="outlined" label={`Token ${record.totalTokens ?? 0}`} />
                                    </Stack>
                                    <Divider sx={{ my: 1.5 }} />
                                    <Typography variant="caption" color="text.secondary">请求ID</Typography>
                                    <Typography variant="body2" sx={{ wordBreak: "break-all" }}>{record.requestId || "--"}</Typography>
                                    <Typography variant="caption" color="text.secondary" display="block" mt={1.5}>
                                        MCP / 工具调用
                                    </Typography>
                                    <Typography variant="body2">
                                        挂载 {record.mountedMcpCount ?? 0} 个,工具成功 {record.toolSuccessCount ?? 0},失败 {record.toolFailureCount ?? 0}
                                    </Typography>
                                    {record.errorMessage && (
                                        <>
                                            <Typography variant="caption" color="text.secondary" display="block" mt={1.5}>
                                                错误
                                            </Typography>
                                            <Typography variant="body2">{record.errorMessage}</Typography>
                                        </>
                                    )}
                                </CardContent>
                                <CardActions sx={{ px: 2, pb: 2, pt: 0 }}>
                                    <Button size="small" startIcon={<VisibilityOutlinedIcon />} onClick={() => onView(record)}>
                                        详情
                                    </Button>
                                </CardActions>
                            </Card>
                        </Grid>
                    );
                })}
            </Grid>
        </Box>
    );
};
const AiCallLogList = () => {
    const [selectedRecord, setSelectedRecord] = useState(null);
    return (
        <>
            <List
                title="menu.aiCallLog"
                filters={filters}
                sort={{ field: "create_time", order: "desc" }}
                actions={(
                    <TopToolbar>
                        <FilterButton />
                    </TopToolbar>
                )}
            >
                <ObserveSummary />
                <AiCallLogCards onView={setSelectedRecord} />
            </List>
            <AiCallLogDetailDialog
                record={selectedRecord}
                open={Boolean(selectedRecord)}
                onClose={() => setSelectedRecord(null)}
            />
        </>
    );
};
export default AiCallLogList;
rsf-admin/src/page/system/aiCallLog/index.jsx
New file
@@ -0,0 +1,6 @@
import AiCallLogList from "./AiCallLogList";
export default {
    list: AiCallLogList,
    recordRepresentation: (record) => `${record?.requestId || ""}`,
};
rsf-server/src/main/java/com/vincent/rsf/server/ai/controller/AiCallLogController.java
New file
@@ -0,0 +1,76 @@
package com.vincent.rsf.server.ai.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.vincent.rsf.framework.common.R;
import com.vincent.rsf.server.ai.entity.AiCallLog;
import com.vincent.rsf.server.ai.service.AiCallLogService;
import com.vincent.rsf.server.common.domain.BaseParam;
import com.vincent.rsf.server.common.domain.PageParam;
import com.vincent.rsf.server.system.controller.BaseController;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import java.util.Arrays;
import java.util.Map;
@RestController
@RequiredArgsConstructor
public class AiCallLogController extends BaseController {
    private final AiCallLogService aiCallLogService;
    @PreAuthorize("hasAuthority('system:aiCallLog:list')")
    @PostMapping("/aiCallLog/page")
    public R page(@RequestBody Map<String, Object> map) {
        BaseParam baseParam = buildParam(map, BaseParam.class);
        PageParam<AiCallLog, BaseParam> pageParam = new PageParam<>(baseParam, AiCallLog.class);
        return R.ok().add(aiCallLogService.page(pageParam, pageParam.buildWrapper(true)
                .eq("tenant_id", getTenantId())
                .eq("deleted", 0)));
    }
    @PreAuthorize("hasAuthority('system:aiCallLog:list')")
    @PostMapping("/aiCallLog/list")
    public R list(@RequestBody Map<String, Object> map) {
        return R.ok().add(aiCallLogService.list(new LambdaQueryWrapper<AiCallLog>()
                .eq(AiCallLog::getTenantId, getTenantId())
                .eq(AiCallLog::getDeleted, 0)
                .orderByDesc(AiCallLog::getId)));
    }
    @PreAuthorize("hasAuthority('system:aiCallLog:list')")
    @PostMapping({"/aiCallLog/many/{ids}", "/aiCallLogs/many/{ids}"})
    public R many(@PathVariable Long[] ids) {
        return R.ok().add(aiCallLogService.list(new LambdaQueryWrapper<AiCallLog>()
                .eq(AiCallLog::getTenantId, getTenantId())
                .eq(AiCallLog::getDeleted, 0)
                .in(AiCallLog::getId, Arrays.asList(ids))));
    }
    @PreAuthorize("hasAuthority('system:aiCallLog:list')")
    @GetMapping("/aiCallLog/{id}")
    public R get(@PathVariable("id") Long id) {
        return R.ok().add(aiCallLogService.getOne(new LambdaQueryWrapper<AiCallLog>()
                .eq(AiCallLog::getId, id)
                .eq(AiCallLog::getTenantId, getTenantId())
                .eq(AiCallLog::getDeleted, 0)
                .last("limit 1")));
    }
    @PreAuthorize("hasAuthority('system:aiCallLog:list')")
    @GetMapping("/aiCallLog/stats")
    public R stats() {
        return R.ok().add(aiCallLogService.getObserveStats(getTenantId()));
    }
    @PreAuthorize("hasAuthority('system:aiCallLog:list')")
    @GetMapping("/aiCallLog/{id}/mcpLogs")
    public R listMcpLogs(@PathVariable("id") Long id) {
        return R.ok().add(aiCallLogService.listMcpLogs(id, getTenantId()));
    }
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatToolEventDto.java
@@ -15,6 +15,8 @@
    private String toolName;
    private String mountName;
    private String status;
    private String inputSummary;
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiObserveStatsDto.java
New file
@@ -0,0 +1,31 @@
package com.vincent.rsf.server.ai.dto;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class AiObserveStatsDto {
    private Long callCount;
    private Long successCount;
    private Long failureCount;
    private Long avgElapsedMs;
    private Long avgFirstTokenLatencyMs;
    private Long totalTokens;
    private Long avgTotalTokens;
    private Long toolCallCount;
    private Long toolSuccessCount;
    private Long toolFailureCount;
    private Double toolSuccessRate;
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/entity/AiCallLog.java
New file
@@ -0,0 +1,118 @@
package com.vincent.rsf.server.ai.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import lombok.experimental.Accessors;
import org.springframework.format.annotation.DateTimeFormat;
import java.io.Serializable;
import java.text.SimpleDateFormat;
import java.util.Date;
@Data
@Accessors(chain = true)
@TableName("sys_ai_call_log")
public class AiCallLog implements Serializable {
    private static final long serialVersionUID = 1L;
    @TableId(value = "id", type = IdType.AUTO)
    private Long id;
    @ApiModelProperty("请求ID")
    private String requestId;
    @ApiModelProperty("会话ID")
    private Long sessionId;
    @ApiModelProperty("Prompt编码")
    private String promptCode;
    @ApiModelProperty("Prompt名称")
    private String promptName;
    @ApiModelProperty("模型")
    private String model;
    @ApiModelProperty("用户ID")
    private Long userId;
    @ApiModelProperty("租户ID")
    private Long tenantId;
    @ApiModelProperty("状态")
    private String status;
    @ApiModelProperty("错误分类")
    private String errorCategory;
    @ApiModelProperty("错误阶段")
    private String errorStage;
    @ApiModelProperty("错误信息")
    private String errorMessage;
    @ApiModelProperty("配置MCP数量")
    private Integer configuredMcpCount;
    @ApiModelProperty("挂载MCP数量")
    private Integer mountedMcpCount;
    @ApiModelProperty("挂载MCP名称")
    private String mountedMcpNames;
    @ApiModelProperty("工具调用总数")
    private Integer toolCallCount;
    @ApiModelProperty("工具成功数")
    private Integer toolSuccessCount;
    @ApiModelProperty("工具失败数")
    private Integer toolFailureCount;
    @ApiModelProperty("总耗时")
    private Long elapsedMs;
    @ApiModelProperty("首包耗时")
    private Long firstTokenLatencyMs;
    @ApiModelProperty("Prompt Tokens")
    private Integer promptTokens;
    @ApiModelProperty("Completion Tokens")
    private Integer completionTokens;
    @ApiModelProperty("Total Tokens")
    private Integer totalTokens;
    @ApiModelProperty("是否删除")
    private Integer deleted;
    @ApiModelProperty("创建时间")
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
    private Date createTime;
    @ApiModelProperty("更新时间")
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
    private Date updateTime;
    public String getCreateTime$() {
        if (this.createTime == null) {
            return "";
        }
        return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(this.createTime);
    }
    public String getUpdateTime$() {
        if (this.updateTime == null) {
            return "";
        }
        return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(this.updateTime);
    }
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/entity/AiMcpCallLog.java
New file
@@ -0,0 +1,76 @@
package com.vincent.rsf.server.ai.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import lombok.experimental.Accessors;
import org.springframework.format.annotation.DateTimeFormat;
import java.io.Serializable;
import java.text.SimpleDateFormat;
import java.util.Date;
@Data
@Accessors(chain = true)
@TableName("sys_ai_mcp_call_log")
public class AiMcpCallLog implements Serializable {
    private static final long serialVersionUID = 1L;
    @TableId(value = "id", type = IdType.AUTO)
    private Long id;
    @ApiModelProperty("AI调用日志ID")
    private Long callLogId;
    @ApiModelProperty("请求ID")
    private String requestId;
    @ApiModelProperty("会话ID")
    private Long sessionId;
    @ApiModelProperty("工具调用ID")
    private String toolCallId;
    @ApiModelProperty("挂载名称")
    private String mountName;
    @ApiModelProperty("工具名称")
    private String toolName;
    @ApiModelProperty("状态")
    private String status;
    @ApiModelProperty("输入摘要")
    private String inputSummary;
    @ApiModelProperty("输出摘要")
    private String outputSummary;
    @ApiModelProperty("错误信息")
    private String errorMessage;
    @ApiModelProperty("耗时")
    private Long durationMs;
    @ApiModelProperty("用户ID")
    private Long userId;
    @ApiModelProperty("租户ID")
    private Long tenantId;
    @ApiModelProperty("创建时间")
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
    private Date createTime;
    public String getCreateTime$() {
        if (this.createTime == null) {
            return "";
        }
        return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(this.createTime);
    }
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/mapper/AiCallLogMapper.java
New file
@@ -0,0 +1,11 @@
package com.vincent.rsf.server.ai.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.vincent.rsf.server.ai.entity.AiCallLog;
import org.apache.ibatis.annotations.Mapper;
import org.springframework.stereotype.Repository;
@Mapper
@Repository
public interface AiCallLogMapper extends BaseMapper<AiCallLog> {
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/mapper/AiMcpCallLogMapper.java
New file
@@ -0,0 +1,11 @@
package com.vincent.rsf.server.ai.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.vincent.rsf.server.ai.entity.AiMcpCallLog;
import org.apache.ibatis.annotations.Mapper;
import org.springframework.stereotype.Repository;
@Mapper
@Repository
public interface AiMcpCallLogMapper extends BaseMapper<AiMcpCallLog> {
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiCallLogService.java
New file
@@ -0,0 +1,30 @@
package com.vincent.rsf.server.ai.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.vincent.rsf.server.ai.dto.AiObserveStatsDto;
import com.vincent.rsf.server.ai.entity.AiCallLog;
import com.vincent.rsf.server.ai.entity.AiMcpCallLog;
import java.util.List;
public interface AiCallLogService extends IService<AiCallLog> {
    AiCallLog startCallLog(String requestId, Long sessionId, Long userId, Long tenantId, String promptCode,
                           String promptName, String model, Integer configuredMcpCount,
                           Integer mountedMcpCount, List<String> mountedMcpNames);
    void completeCallLog(Long callLogId, String status, Long elapsedMs, Long firstTokenLatencyMs,
                         Integer promptTokens, Integer completionTokens, Integer totalTokens,
                         long toolSuccessCount, long toolFailureCount);
    void failCallLog(Long callLogId, String status, String errorCategory, String errorStage, String errorMessage,
                     Long elapsedMs, Long firstTokenLatencyMs, long toolSuccessCount, long toolFailureCount);
    void saveMcpCallLog(Long callLogId, String requestId, Long sessionId, String toolCallId, String mountName,
                        String toolName, String status, String inputSummary, String outputSummary,
                        String errorMessage, Long durationMs, Long userId, Long tenantId);
    AiObserveStatsDto getObserveStats(Long tenantId);
    List<AiMcpCallLog> listMcpLogs(Long callLogId, Long tenantId);
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/MountedToolCallback.java
New file
@@ -0,0 +1,8 @@
package com.vincent.rsf.server.ai.service;
import org.springframework.ai.tool.ToolCallback;
public interface MountedToolCallback extends ToolCallback {
    String getMountName();
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiCallLogServiceImpl.java
New file
@@ -0,0 +1,184 @@
package com.vincent.rsf.server.ai.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.vincent.rsf.server.ai.dto.AiObserveStatsDto;
import com.vincent.rsf.server.ai.entity.AiCallLog;
import com.vincent.rsf.server.ai.entity.AiMcpCallLog;
import com.vincent.rsf.server.ai.mapper.AiCallLogMapper;
import com.vincent.rsf.server.ai.mapper.AiMcpCallLogMapper;
import com.vincent.rsf.server.ai.service.AiCallLogService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.Date;
import java.util.List;
import java.util.Objects;
import java.util.regex.Pattern;
@Service("aiCallLogService")
@RequiredArgsConstructor
public class AiCallLogServiceImpl extends ServiceImpl<AiCallLogMapper, AiCallLog> implements AiCallLogService {
    private static final Pattern SECRET_JSON_PATTERN = Pattern.compile("(?i)(\"(?:apiKey|token|accessToken|refreshToken|password|authorization)\"\\s*:\\s*\")([^\"]+)(\")");
    private static final Pattern BEARER_PATTERN = Pattern.compile("(?i)(bearer\\s+)([a-z0-9._-]+)");
    private final AiMcpCallLogMapper aiMcpCallLogMapper;
    @Override
    public AiCallLog startCallLog(String requestId, Long sessionId, Long userId, Long tenantId, String promptCode,
                                  String promptName, String model, Integer configuredMcpCount,
                                  Integer mountedMcpCount, List<String> mountedMcpNames) {
        Date now = new Date();
        AiCallLog callLog = new AiCallLog()
                .setRequestId(requestId)
                .setSessionId(sessionId)
                .setUserId(userId)
                .setTenantId(tenantId)
                .setPromptCode(promptCode)
                .setPromptName(promptName)
                .setModel(model)
                .setStatus("RUNNING")
                .setConfiguredMcpCount(configuredMcpCount)
                .setMountedMcpCount(mountedMcpCount)
                .setMountedMcpNames(joinNames(mountedMcpNames))
                .setToolCallCount(0)
                .setToolSuccessCount(0)
                .setToolFailureCount(0)
                .setDeleted(0)
                .setCreateTime(now)
                .setUpdateTime(now);
        this.save(callLog);
        return callLog;
    }
    @Override
    public void completeCallLog(Long callLogId, String status, Long elapsedMs, Long firstTokenLatencyMs,
                                Integer promptTokens, Integer completionTokens, Integer totalTokens,
                                long toolSuccessCount, long toolFailureCount) {
        if (callLogId == null) {
            return;
        }
        this.update(new LambdaUpdateWrapper<AiCallLog>()
                .eq(AiCallLog::getId, callLogId)
                .set(AiCallLog::getStatus, status)
                .set(AiCallLog::getElapsedMs, elapsedMs)
                .set(AiCallLog::getFirstTokenLatencyMs, firstTokenLatencyMs)
                .set(AiCallLog::getPromptTokens, promptTokens)
                .set(AiCallLog::getCompletionTokens, completionTokens)
                .set(AiCallLog::getTotalTokens, totalTokens)
                .set(AiCallLog::getToolSuccessCount, (int) toolSuccessCount)
                .set(AiCallLog::getToolFailureCount, (int) toolFailureCount)
                .set(AiCallLog::getToolCallCount, (int) (toolSuccessCount + toolFailureCount))
                .set(AiCallLog::getUpdateTime, new Date()));
    }
    @Override
    public void failCallLog(Long callLogId, String status, String errorCategory, String errorStage, String errorMessage,
                            Long elapsedMs, Long firstTokenLatencyMs, long toolSuccessCount, long toolFailureCount) {
        if (callLogId == null) {
            return;
        }
        this.update(new LambdaUpdateWrapper<AiCallLog>()
                .eq(AiCallLog::getId, callLogId)
                .set(AiCallLog::getStatus, status)
                .set(AiCallLog::getErrorCategory, errorCategory)
                .set(AiCallLog::getErrorStage, errorStage)
                .set(AiCallLog::getErrorMessage, maskSensitive(errorMessage))
                .set(AiCallLog::getElapsedMs, elapsedMs)
                .set(AiCallLog::getFirstTokenLatencyMs, firstTokenLatencyMs)
                .set(AiCallLog::getToolSuccessCount, (int) toolSuccessCount)
                .set(AiCallLog::getToolFailureCount, (int) toolFailureCount)
                .set(AiCallLog::getToolCallCount, (int) (toolSuccessCount + toolFailureCount))
                .set(AiCallLog::getUpdateTime, new Date()));
    }
    @Override
    public void saveMcpCallLog(Long callLogId, String requestId, Long sessionId, String toolCallId, String mountName,
                               String toolName, String status, String inputSummary, String outputSummary,
                               String errorMessage, Long durationMs, Long userId, Long tenantId) {
        if (callLogId == null) {
            return;
        }
        aiMcpCallLogMapper.insert(new AiMcpCallLog()
                .setCallLogId(callLogId)
                .setRequestId(requestId)
                .setSessionId(sessionId)
                .setToolCallId(toolCallId)
                .setMountName(mountName)
                .setToolName(toolName)
                .setStatus(status)
                .setInputSummary(maskSensitive(inputSummary))
                .setOutputSummary(maskSensitive(outputSummary))
                .setErrorMessage(maskSensitive(errorMessage))
                .setDurationMs(durationMs)
                .setUserId(userId)
                .setTenantId(tenantId)
                .setCreateTime(new Date()));
    }
    @Override
    public AiObserveStatsDto getObserveStats(Long tenantId) {
        List<AiCallLog> callLogs = this.list(new LambdaQueryWrapper<AiCallLog>()
                .eq(AiCallLog::getTenantId, tenantId)
                .eq(AiCallLog::getDeleted, 0)
                .orderByDesc(AiCallLog::getId));
        List<AiMcpCallLog> mcpCallLogs = aiMcpCallLogMapper.selectList(new LambdaQueryWrapper<AiMcpCallLog>()
                .eq(AiMcpCallLog::getTenantId, tenantId)
                .orderByDesc(AiMcpCallLog::getId));
        long callCount = callLogs.size();
        long successCount = callLogs.stream().filter(item -> "COMPLETED".equals(item.getStatus())).count();
        long failureCount = callLogs.stream().filter(item -> "FAILED".equals(item.getStatus())).count();
        long totalElapsed = callLogs.stream().map(AiCallLog::getElapsedMs).filter(Objects::nonNull).mapToLong(Long::longValue).sum();
        long elapsedCount = callLogs.stream().map(AiCallLog::getElapsedMs).filter(Objects::nonNull).count();
        long totalFirstToken = callLogs.stream().map(AiCallLog::getFirstTokenLatencyMs).filter(Objects::nonNull).mapToLong(Long::longValue).sum();
        long firstTokenCount = callLogs.stream().map(AiCallLog::getFirstTokenLatencyMs).filter(Objects::nonNull).count();
        long totalTokens = callLogs.stream().map(AiCallLog::getTotalTokens).filter(Objects::nonNull).mapToLong(Integer::longValue).sum();
        long tokenCount = callLogs.stream().map(AiCallLog::getTotalTokens).filter(Objects::nonNull).count();
        long toolCallCount = mcpCallLogs.size();
        long toolSuccessCount = mcpCallLogs.stream().filter(item -> "COMPLETED".equals(item.getStatus())).count();
        long toolFailureCount = mcpCallLogs.stream().filter(item -> "FAILED".equals(item.getStatus())).count();
        double toolSuccessRate = toolCallCount == 0 ? 0D : (toolSuccessCount * 100D) / toolCallCount;
        return AiObserveStatsDto.builder()
                .callCount(callCount)
                .successCount(successCount)
                .failureCount(failureCount)
                .avgElapsedMs(elapsedCount == 0 ? 0L : totalElapsed / elapsedCount)
                .avgFirstTokenLatencyMs(firstTokenCount == 0 ? 0L : totalFirstToken / firstTokenCount)
                .totalTokens(totalTokens)
                .avgTotalTokens(tokenCount == 0 ? 0L : totalTokens / tokenCount)
                .toolCallCount(toolCallCount)
                .toolSuccessCount(toolSuccessCount)
                .toolFailureCount(toolFailureCount)
                .toolSuccessRate(toolSuccessRate)
                .build();
    }
    @Override
    public List<AiMcpCallLog> listMcpLogs(Long callLogId, Long tenantId) {
        return aiMcpCallLogMapper.selectList(new LambdaQueryWrapper<AiMcpCallLog>()
                .eq(AiMcpCallLog::getCallLogId, callLogId)
                .eq(AiMcpCallLog::getTenantId, tenantId)
                .orderByDesc(AiMcpCallLog::getId));
    }
    private String joinNames(List<String> names) {
        if (names == null || names.isEmpty()) {
            return "";
        }
        return String.join("、", names);
    }
    private String maskSensitive(String source) {
        if (!StringUtils.hasText(source)) {
            return source;
        }
        String masked = SECRET_JSON_PATTERN.matcher(source).replaceAll("$1***$3");
        return BEARER_PATTERN.matcher(masked).replaceAll("$1***");
    }
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiChatServiceImpl.java
@@ -16,14 +16,17 @@
import com.vincent.rsf.server.ai.dto.AiChatSessionRenameRequest;
import com.vincent.rsf.server.ai.dto.AiChatToolEventDto;
import com.vincent.rsf.server.ai.dto.AiResolvedConfig;
import com.vincent.rsf.server.ai.entity.AiCallLog;
import com.vincent.rsf.server.ai.entity.AiParam;
import com.vincent.rsf.server.ai.entity.AiPrompt;
import com.vincent.rsf.server.ai.entity.AiChatSession;
import com.vincent.rsf.server.ai.enums.AiErrorCategory;
import com.vincent.rsf.server.ai.exception.AiChatException;
import com.vincent.rsf.server.ai.service.AiCallLogService;
import com.vincent.rsf.server.ai.service.AiChatService;
import com.vincent.rsf.server.ai.service.AiChatMemoryService;
import com.vincent.rsf.server.ai.service.AiConfigResolverService;
import com.vincent.rsf.server.ai.service.MountedToolCallback;
import com.vincent.rsf.server.ai.service.McpMountRuntimeFactory;
import io.micrometer.observation.ObservationRegistry;
import lombok.RequiredArgsConstructor;
@@ -78,6 +81,7 @@
    private final AiConfigResolverService aiConfigResolverService;
    private final AiChatMemoryService aiChatMemoryService;
    private final McpMountRuntimeFactory mcpMountRuntimeFactory;
    private final AiCallLogService aiCallLogService;
    private final GenericApplicationContext applicationContext;
    private final ObservationRegistry observationRegistry;
    private final ObjectMapper objectMapper;
@@ -148,7 +152,10 @@
        long startedAt = System.currentTimeMillis();
        AtomicReference<Long> firstTokenAtRef = new AtomicReference<>();
        AtomicLong toolCallSequence = new AtomicLong(0);
        AtomicLong toolSuccessCount = new AtomicLong(0);
        AtomicLong toolFailureCount = new AtomicLong(0);
        Long sessionId = request.getSessionId();
        Long callLogId = null;
        String model = null;
        try {
            ensureIdentity(userId, tenantId);
@@ -159,6 +166,19 @@
            sessionId = session.getId();
            AiChatMemoryDto memory = loadMemory(userId, tenantId, config.getPromptCode(), session.getId());
            List<AiChatMessageDto> mergedMessages = mergeMessages(memory.getShortMemoryMessages(), request.getMessages());
            AiCallLog callLog = aiCallLogService.startCallLog(
                    requestId,
                    session.getId(),
                    userId,
                    tenantId,
                    config.getPromptCode(),
                    config.getPrompt().getName(),
                    config.getAiParam().getModel(),
                    config.getMcpMounts().size(),
                    config.getMcpMounts().size(),
                    config.getMcpMounts().stream().map(item -> item.getName()).toList()
            );
            callLogId = callLog.getId();
            try (McpMountRuntimeFactory.McpMountRuntime runtime = createRuntime(config, userId)) {
                emitStrict(emitter, "start", AiChatRuntimeDto.builder()
                        .requestId(requestId)
@@ -187,7 +207,8 @@
                        requestId, userId, tenantId, session.getId(), resolvedModel);
                ToolCallback[] observableToolCallbacks = wrapToolCallbacks(
                        runtime.getToolCallbacks(), emitter, requestId, session.getId(), toolCallSequence
                        runtime.getToolCallbacks(), emitter, requestId, session.getId(), toolCallSequence,
                        toolSuccessCount, toolFailureCount, callLogId, userId, tenantId
                );
                Prompt prompt = new Prompt(
                        buildPromptMessages(memory, mergedMessages, config.getPrompt(), request.getMetadata()),
@@ -205,6 +226,17 @@
                    }
                    emitDone(emitter, requestId, response.getMetadata(), config.getAiParam().getModel(), session.getId(), startedAt, firstTokenAtRef.get());
                    emitSafely(emitter, "status", buildTerminalStatus(requestId, session.getId(), "COMPLETED", resolvedModel, startedAt, firstTokenAtRef.get()));
                    aiCallLogService.completeCallLog(
                            callLogId,
                            "COMPLETED",
                            System.currentTimeMillis() - startedAt,
                            resolveFirstTokenLatency(startedAt, firstTokenAtRef.get()),
                            response.getMetadata() == null || response.getMetadata().getUsage() == null ? null : response.getMetadata().getUsage().getPromptTokens(),
                            response.getMetadata() == null || response.getMetadata().getUsage() == null ? null : response.getMetadata().getUsage().getCompletionTokens(),
                            response.getMetadata() == null || response.getMetadata().getUsage() == null ? null : response.getMetadata().getUsage().getTotalTokens(),
                            toolSuccessCount.get(),
                            toolFailureCount.get()
                    );
                    log.info("AI chat completed, requestId={}, sessionId={}, elapsedMs={}, firstTokenLatencyMs={}",
                            requestId, session.getId(), System.currentTimeMillis() - startedAt, resolveFirstTokenLatency(startedAt, firstTokenAtRef.get()));
                    emitter.complete();
@@ -232,16 +264,29 @@
                aiChatMemoryService.saveRound(session, userId, tenantId, request.getMessages(), assistantContent.toString());
                emitDone(emitter, requestId, lastMetadata.get(), config.getAiParam().getModel(), session.getId(), startedAt, firstTokenAtRef.get());
                emitSafely(emitter, "status", buildTerminalStatus(requestId, session.getId(), "COMPLETED", resolvedModel, startedAt, firstTokenAtRef.get()));
                aiCallLogService.completeCallLog(
                        callLogId,
                        "COMPLETED",
                        System.currentTimeMillis() - startedAt,
                        resolveFirstTokenLatency(startedAt, firstTokenAtRef.get()),
                        lastMetadata.get() == null || lastMetadata.get().getUsage() == null ? null : lastMetadata.get().getUsage().getPromptTokens(),
                        lastMetadata.get() == null || lastMetadata.get().getUsage() == null ? null : lastMetadata.get().getUsage().getCompletionTokens(),
                        lastMetadata.get() == null || lastMetadata.get().getUsage() == null ? null : lastMetadata.get().getUsage().getTotalTokens(),
                        toolSuccessCount.get(),
                        toolFailureCount.get()
                );
                log.info("AI chat completed, requestId={}, sessionId={}, elapsedMs={}, firstTokenLatencyMs={}",
                        requestId, session.getId(), System.currentTimeMillis() - startedAt, resolveFirstTokenLatency(startedAt, firstTokenAtRef.get()));
                emitter.complete();
            }
        } catch (AiChatException e) {
            handleStreamFailure(emitter, requestId, sessionId, model, startedAt, firstTokenAtRef.get(), e);
            handleStreamFailure(emitter, requestId, sessionId, model, startedAt, firstTokenAtRef.get(), e,
                    callLogId, toolSuccessCount.get(), toolFailureCount.get());
        } catch (Exception e) {
            handleStreamFailure(emitter, requestId, sessionId, model, startedAt, firstTokenAtRef.get(),
                    buildAiException("AI_INTERNAL_ERROR", AiErrorCategory.INTERNAL, "INTERNAL",
                            e == null ? "AI 对话失败" : e.getMessage(), e));
                            e == null ? "AI 对话失败" : e.getMessage(), e),
                    callLogId, toolSuccessCount.get(), toolFailureCount.get());
        } finally {
            log.debug("AI chat stream finished, requestId={}", requestId);
        }
@@ -341,11 +386,24 @@
        return firstTokenAt == null ? null : Math.max(0L, firstTokenAt - startedAt);
    }
    private void handleStreamFailure(SseEmitter emitter, String requestId, Long sessionId, String model, long startedAt, Long firstTokenAt, AiChatException exception) {
    private void handleStreamFailure(SseEmitter emitter, String requestId, Long sessionId, String model, long startedAt,
                                     Long firstTokenAt, AiChatException exception, Long callLogId,
                                     long toolSuccessCount, long toolFailureCount) {
        if (isClientAbortException(exception)) {
            log.warn("AI chat aborted by client, requestId={}, sessionId={}, stage={}, message={}",
                    requestId, sessionId, exception.getStage(), exception.getMessage());
            emitSafely(emitter, "status", buildTerminalStatus(requestId, sessionId, "ABORTED", model, startedAt, firstTokenAt));
            aiCallLogService.failCallLog(
                    callLogId,
                    "ABORTED",
                    exception.getCategory().name(),
                    exception.getStage(),
                    exception.getMessage(),
                    System.currentTimeMillis() - startedAt,
                    resolveFirstTokenLatency(startedAt, firstTokenAt),
                    toolSuccessCount,
                    toolFailureCount
            );
            emitter.completeWithError(exception);
            return;
        }
@@ -361,6 +419,17 @@
                .message(exception.getMessage())
                .timestamp(Instant.now().toEpochMilli())
                .build());
        aiCallLogService.failCallLog(
                callLogId,
                "FAILED",
                exception.getCategory().name(),
                exception.getStage(),
                exception.getMessage(),
                System.currentTimeMillis() - startedAt,
                resolveFirstTokenLatency(startedAt, firstTokenAt),
                toolSuccessCount,
                toolFailureCount
        );
        emitter.completeWithError(exception);
    }
@@ -436,7 +505,9 @@
    }
    private ToolCallback[] wrapToolCallbacks(ToolCallback[] toolCallbacks, SseEmitter emitter, String requestId,
                                             Long sessionId, AtomicLong toolCallSequence) {
                                             Long sessionId, AtomicLong toolCallSequence,
                                             AtomicLong toolSuccessCount, AtomicLong toolFailureCount,
                                             Long callLogId, Long userId, Long tenantId) {
        if (Cools.isEmpty(toolCallbacks)) {
            return toolCallbacks;
        }
@@ -445,7 +516,8 @@
            if (callback == null) {
                continue;
            }
            wrappedCallbacks.add(new ObservableToolCallback(callback, emitter, requestId, sessionId, toolCallSequence));
            wrappedCallbacks.add(new ObservableToolCallback(callback, emitter, requestId, sessionId, toolCallSequence,
                    toolSuccessCount, toolFailureCount, callLogId, userId, tenantId));
        }
        return wrappedCallbacks.toArray(new ToolCallback[0]);
    }
@@ -637,14 +709,26 @@
        private final String requestId;
        private final Long sessionId;
        private final AtomicLong toolCallSequence;
        private final AtomicLong toolSuccessCount;
        private final AtomicLong toolFailureCount;
        private final Long callLogId;
        private final Long userId;
        private final Long tenantId;
        private ObservableToolCallback(ToolCallback delegate, SseEmitter emitter, String requestId,
                                       Long sessionId, AtomicLong toolCallSequence) {
                                       Long sessionId, AtomicLong toolCallSequence,
                                       AtomicLong toolSuccessCount, AtomicLong toolFailureCount,
                                       Long callLogId, Long userId, Long tenantId) {
            this.delegate = delegate;
            this.emitter = emitter;
            this.requestId = requestId;
            this.sessionId = sessionId;
            this.toolCallSequence = toolCallSequence;
            this.toolSuccessCount = toolSuccessCount;
            this.toolFailureCount = toolFailureCount;
            this.callLogId = callLogId;
            this.userId = userId;
            this.tenantId = tenantId;
        }
        @Override
@@ -665,6 +749,7 @@
        @Override
        public String call(String toolInput, ToolContext toolContext) {
            String toolName = delegate.getToolDefinition() == null ? "unknown" : delegate.getToolDefinition().name();
            String mountName = delegate instanceof MountedToolCallback ? ((MountedToolCallback) delegate).getMountName() : null;
            String toolCallId = requestId + "-tool-" + toolCallSequence.incrementAndGet();
            long startedAt = System.currentTimeMillis();
            emitSafely(emitter, "tool_start", AiChatToolEventDto.builder()
@@ -672,36 +757,49 @@
                    .sessionId(sessionId)
                    .toolCallId(toolCallId)
                    .toolName(toolName)
                    .mountName(mountName)
                    .status("STARTED")
                    .inputSummary(summarizeToolPayload(toolInput, 400))
                    .timestamp(startedAt)
                    .build());
            try {
                String output = toolContext == null ? delegate.call(toolInput) : delegate.call(toolInput, toolContext);
                long durationMs = System.currentTimeMillis() - startedAt;
                emitSafely(emitter, "tool_result", AiChatToolEventDto.builder()
                        .requestId(requestId)
                        .sessionId(sessionId)
                        .toolCallId(toolCallId)
                        .toolName(toolName)
                        .mountName(mountName)
                        .status("COMPLETED")
                        .inputSummary(summarizeToolPayload(toolInput, 400))
                        .outputSummary(summarizeToolPayload(output, 600))
                        .durationMs(System.currentTimeMillis() - startedAt)
                        .durationMs(durationMs)
                        .timestamp(System.currentTimeMillis())
                        .build());
                toolSuccessCount.incrementAndGet();
                aiCallLogService.saveMcpCallLog(callLogId, requestId, sessionId, toolCallId, mountName, toolName,
                        "COMPLETED", summarizeToolPayload(toolInput, 400), summarizeToolPayload(output, 600),
                        null, durationMs, userId, tenantId);
                return output;
            } catch (RuntimeException e) {
                long durationMs = System.currentTimeMillis() - startedAt;
                emitSafely(emitter, "tool_error", AiChatToolEventDto.builder()
                        .requestId(requestId)
                        .sessionId(sessionId)
                        .toolCallId(toolCallId)
                        .toolName(toolName)
                        .mountName(mountName)
                        .status("FAILED")
                        .inputSummary(summarizeToolPayload(toolInput, 400))
                        .errorMessage(e.getMessage())
                        .durationMs(System.currentTimeMillis() - startedAt)
                        .durationMs(durationMs)
                        .timestamp(System.currentTimeMillis())
                        .build());
                toolFailureCount.incrementAndGet();
                aiCallLogService.saveMcpCallLog(callLogId, requestId, sessionId, toolCallId, mountName, toolName,
                        "FAILED", summarizeToolPayload(toolInput, 400), null, e.getMessage(),
                        durationMs, userId, tenantId);
                throw e;
            }
        }
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/McpMountRuntimeFactoryImpl.java
@@ -6,6 +6,7 @@
import com.vincent.rsf.server.ai.config.AiDefaults;
import com.vincent.rsf.server.ai.entity.AiMcpMount;
import com.vincent.rsf.server.ai.service.BuiltinMcpToolRegistry;
import com.vincent.rsf.server.ai.service.MountedToolCallback;
import com.vincent.rsf.server.ai.service.McpMountRuntimeFactory;
import io.modelcontextprotocol.client.McpClient;
import io.modelcontextprotocol.client.McpSyncClient;
@@ -47,7 +48,10 @@
        for (AiMcpMount mount : mounts) {
            try {
                if (AiDefaults.MCP_TRANSPORT_BUILTIN.equals(mount.getTransportType())) {
                    callbacks.addAll(builtinMcpToolRegistry.createToolCallbacks(mount, userId));
                    callbacks.addAll(wrapMountedCallbacks(
                            builtinMcpToolRegistry.createToolCallbacks(mount, userId),
                            mount.getName()
                    ));
                    mountedNames.add(mount.getName());
                    continue;
                }
@@ -55,6 +59,10 @@
                client.initialize();
                client.listTools();
                clients.add(client);
                callbacks.addAll(wrapMountedCallbacks(
                        Arrays.asList(SyncMcpToolCallbackProvider.builder().mcpClients(List.of(client)).build().getToolCallbacks()),
                        mount.getName()
                ));
                mountedNames.add(mount.getName());
            } catch (Exception e) {
                String message = mount.getName() + " 挂载失败: " + e.getMessage();
@@ -62,13 +70,19 @@
                log.warn(message, e);
            }
        }
        if (!clients.isEmpty()) {
            callbacks.addAll(Arrays.asList(
                    SyncMcpToolCallbackProvider.builder().mcpClients(clients).build().getToolCallbacks()
            ));
        }
        ensureUniqueToolNames(callbacks);
        return new DefaultMcpMountRuntime(clients, callbacks.toArray(new ToolCallback[0]), mountedNames, errors);
    }
    private List<ToolCallback> wrapMountedCallbacks(List<ToolCallback> source, String mountName) {
        List<ToolCallback> mountedCallbacks = new ArrayList<>();
        for (ToolCallback callback : source) {
            if (callback == null) {
                continue;
            }
            mountedCallbacks.add(new MountedToolCallbackImpl(callback, mountName));
        }
        return mountedCallbacks;
    }
    private void ensureUniqueToolNames(List<ToolCallback> callbacks) {
@@ -208,4 +222,40 @@
            }
        }
    }
    private static class MountedToolCallbackImpl implements MountedToolCallback {
        private final ToolCallback delegate;
        private final String mountName;
        private MountedToolCallbackImpl(ToolCallback delegate, String mountName) {
            this.delegate = delegate;
            this.mountName = mountName;
        }
        @Override
        public String getMountName() {
            return mountName;
        }
        @Override
        public org.springframework.ai.tool.definition.ToolDefinition getToolDefinition() {
            return delegate.getToolDefinition();
        }
        @Override
        public org.springframework.ai.tool.metadata.ToolMetadata getToolMetadata() {
            return delegate.getToolMetadata();
        }
        @Override
        public String call(String toolInput) {
            return delegate.call(toolInput);
        }
        @Override
        public String call(String toolInput, org.springframework.ai.chat.model.ToolContext toolContext) {
            return delegate.call(toolInput, toolContext);
        }
    }
}
version/db/ai_feature.sql
@@ -113,6 +113,59 @@
  KEY `idx_sys_ai_chat_message_session_seq` (`session_id`,`seq_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI 对话消息';
CREATE TABLE IF NOT EXISTS `sys_ai_call_log` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
  `request_id` varchar(128) NOT NULL COMMENT '请求ID',
  `session_id` bigint(20) DEFAULT NULL COMMENT '会话ID',
  `prompt_code` varchar(128) DEFAULT NULL COMMENT 'Prompt编码',
  `prompt_name` varchar(255) DEFAULT NULL COMMENT 'Prompt名称',
  `model` varchar(255) DEFAULT NULL COMMENT '模型',
  `user_id` bigint(20) DEFAULT NULL COMMENT '用户ID',
  `tenant_id` bigint(20) DEFAULT NULL COMMENT '租户ID',
  `status` varchar(32) DEFAULT NULL COMMENT '状态',
  `error_category` varchar(64) DEFAULT NULL COMMENT '错误分类',
  `error_stage` varchar(64) DEFAULT NULL COMMENT '错误阶段',
  `error_message` varchar(1000) DEFAULT NULL COMMENT '错误信息',
  `configured_mcp_count` int(11) DEFAULT NULL COMMENT '配置MCP数量',
  `mounted_mcp_count` int(11) DEFAULT NULL COMMENT '挂载MCP数量',
  `mounted_mcp_names` varchar(1000) DEFAULT NULL COMMENT '挂载MCP名称',
  `tool_call_count` int(11) DEFAULT NULL COMMENT '工具调用总数',
  `tool_success_count` int(11) DEFAULT NULL COMMENT '工具成功数',
  `tool_failure_count` int(11) DEFAULT NULL COMMENT '工具失败数',
  `elapsed_ms` bigint(20) DEFAULT NULL COMMENT '总耗时',
  `first_token_latency_ms` bigint(20) DEFAULT NULL COMMENT '首包耗时',
  `prompt_tokens` int(11) DEFAULT NULL COMMENT 'Prompt Tokens',
  `completion_tokens` int(11) DEFAULT NULL COMMENT 'Completion Tokens',
  `total_tokens` int(11) DEFAULT NULL COMMENT 'Total Tokens',
  `deleted` int(11) DEFAULT '0' COMMENT '删除标记',
  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
  `update_time` datetime DEFAULT NULL COMMENT '更新时间',
  PRIMARY KEY (`id`),
  KEY `idx_sys_ai_call_log_tenant_create` (`tenant_id`,`create_time`),
  KEY `idx_sys_ai_call_log_request` (`request_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI 调用日志';
CREATE TABLE IF NOT EXISTS `sys_ai_mcp_call_log` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
  `call_log_id` bigint(20) NOT NULL COMMENT 'AI调用日志ID',
  `request_id` varchar(128) DEFAULT NULL COMMENT '请求ID',
  `session_id` bigint(20) DEFAULT NULL COMMENT '会话ID',
  `tool_call_id` varchar(128) DEFAULT NULL COMMENT '工具调用ID',
  `mount_name` varchar(255) DEFAULT NULL COMMENT '挂载名称',
  `tool_name` varchar(255) DEFAULT NULL COMMENT '工具名称',
  `status` varchar(32) DEFAULT NULL COMMENT '状态',
  `input_summary` text COMMENT '输入摘要',
  `output_summary` text COMMENT '输出摘要',
  `error_message` varchar(1000) DEFAULT NULL COMMENT '错误信息',
  `duration_ms` bigint(20) DEFAULT NULL COMMENT '耗时',
  `user_id` bigint(20) DEFAULT NULL COMMENT '用户ID',
  `tenant_id` bigint(20) DEFAULT NULL COMMENT '租户ID',
  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
  PRIMARY KEY (`id`),
  KEY `idx_sys_ai_mcp_call_log_call` (`call_log_id`,`create_time`),
  KEY `idx_sys_ai_mcp_call_log_tenant` (`tenant_id`,`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI MCP 调用日志';
SET @ai_param_validate_status_exists := (
  SELECT COUNT(1)
  FROM `information_schema`.`COLUMNS`
@@ -394,7 +447,9 @@
(5312, 'Query AI MCP Mount', 5311, NULL, '1,5311', NULL, NULL, NULL, NULL, NULL, 1, 'system:aiMcpMount:list', NULL, 0, NULL, 1, 1, 0, '2026-03-18 19:00:00', 2, '2026-03-18 19:00:00', 2, NULL),
(5313, 'Create AI MCP Mount', 5311, NULL, '1,5311', NULL, NULL, NULL, NULL, NULL, 1, 'system:aiMcpMount:save', NULL, 1, NULL, 1, 1, 0, '2026-03-18 19:00:00', 2, '2026-03-18 19:00:00', 2, NULL),
(5314, 'Update AI MCP Mount', 5311, NULL, '1,5311', NULL, NULL, NULL, NULL, NULL, 1, 'system:aiMcpMount:update', NULL, 2, NULL, 1, 1, 0, '2026-03-18 19:00:00', 2, '2026-03-18 19:00:00', 2, NULL),
(5315, 'Delete AI MCP Mount', 5311, NULL, '1,5311', NULL, NULL, NULL, NULL, NULL, 1, 'system:aiMcpMount:remove', NULL, 3, NULL, 1, 1, 0, '2026-03-18 19:00:00', 2, '2026-03-18 19:00:00', 2, NULL)
(5315, 'Delete AI MCP Mount', 5311, NULL, '1,5311', NULL, NULL, NULL, NULL, NULL, 1, 'system:aiMcpMount:remove', NULL, 3, NULL, 1, 1, 0, '2026-03-18 19:00:00', 2, '2026-03-18 19:00:00', 2, NULL),
(5316, 'menu.aiCallLog', 1, 'menu.system', '1', 'menu.system', '/system/aiCallLog', 'aiCallLog', NULL, NULL, 0, NULL, 'QueryStats', 14, NULL, 1, 1, 0, '2026-03-19 13:00:00', 2, '2026-03-19 13:00:00', 2, NULL),
(5317, 'Query AI Call Log', 5316, NULL, '1,5316', NULL, NULL, NULL, NULL, NULL, 1, 'system:aiCallLog:list', NULL, 0, NULL, 1, 1, 0, '2026-03-19 13:00:00', 2, '2026-03-19 13:00:00', 2, NULL)
ON DUPLICATE KEY UPDATE
`name` = VALUES(`name`),
`parent_id` = VALUES(`parent_id`),
@@ -428,7 +483,9 @@
(5312, 1, 5312),
(5313, 1, 5313),
(5314, 1, 5314),
(5315, 1, 5315)
(5315, 1, 5315),
(5316, 1, 5316),
(5317, 1, 5317)
ON DUPLICATE KEY UPDATE
`role_id` = VALUES(`role_id`),
`menu_id` = VALUES(`menu_id`);