From d5884d0974d17d96225a5d80e432de33a5ee6552 Mon Sep 17 00:00:00 2001
From: zhou zhou <3272660260@qq.com>
Date: 星期四, 19 三月 2026 13:10:21 +0800
Subject: [PATCH] #AI.日志与审计
---
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/McpMountRuntimeFactoryImpl.java | 62 ++
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiObserveStatsDto.java | 31 +
rsf-server/src/main/java/com/vincent/rsf/server/ai/mapper/AiCallLogMapper.java | 11
version/db/ai_feature.sql | 61 ++
rsf-server/src/main/java/com/vincent/rsf/server/ai/entity/AiMcpCallLog.java | 76 +++
rsf-server/src/main/java/com/vincent/rsf/server/ai/controller/AiCallLogController.java | 76 +++
rsf-admin/src/config/authProvider.js | 1
rsf-server/src/main/java/com/vincent/rsf/server/ai/mapper/AiMcpCallLogMapper.java | 11
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/MountedToolCallback.java | 8
rsf-admin/src/api/ai/observe.js | 19
rsf-admin/src/page/system/aiCallLog/AiCallLogList.jsx | 378 ++++++++++++++++++
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatToolEventDto.java | 2
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiCallLogServiceImpl.java | 184 +++++++++
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiChatServiceImpl.java | 116 +++++
rsf-admin/src/i18n/zh.js | 1
rsf-server/src/main/java/com/vincent/rsf/server/ai/entity/AiCallLog.java | 118 +++++
rsf-admin/src/i18n/en.js | 1
rsf-admin/src/page/ResourceContent.js | 3
rsf-admin/src/page/system/aiCallLog/index.jsx | 6
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiCallLogService.java | 30 +
20 files changed, 1,177 insertions(+), 18 deletions(-)
diff --git a/rsf-admin/src/api/ai/observe.js b/rsf-admin/src/api/ai/observe.js
new file mode 100644
index 0000000..cd659c5
--- /dev/null
+++ b/rsf-admin/src/api/ai/observe.js
@@ -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 璋冪敤鏃ュ織澶辫触");
+};
diff --git a/rsf-admin/src/config/authProvider.js b/rsf-admin/src/config/authProvider.js
index 24655a3..72292d2 100644
--- a/rsf-admin/src/config/authProvider.js
+++ b/rsf-admin/src/config/authProvider.js
@@ -7,7 +7,6 @@
const AI_COMPONENTS = new Set([
'aiDiagnosis',
'aiDiagnosisPlan',
- 'aiCallLog',
'aiRoute',
'aiToolConfig',
]);
diff --git a/rsf-admin/src/i18n/en.js b/rsf-admin/src/i18n/en.js
index 1eaeaa2..f0b39e1 100644
--- a/rsf-admin/src/i18n/en.js
+++ b/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',
diff --git a/rsf-admin/src/i18n/zh.js b/rsf-admin/src/i18n/zh.js
index 95e5cb2..7998128 100644
--- a/rsf-admin/src/i18n/zh.js
+++ b/rsf-admin/src/i18n/zh.js
@@ -154,6 +154,7 @@
aiParam: 'AI 鍙傛暟',
aiPrompt: 'Prompt 绠$悊',
aiMcpMount: 'MCP 鎸傝浇',
+ aiCallLog: 'AI 瑙傛祴',
tenant: '绉熸埛绠$悊',
userLogin: '鐧诲綍鏃ュ織',
customer: '瀹㈡埛琛�',
diff --git a/rsf-admin/src/page/ResourceContent.js b/rsf-admin/src/page/ResourceContent.js
index 910f2c5..c4b4beb 100644
--- a/rsf-admin/src/page/ResourceContent.js
+++ b/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:
diff --git a/rsf-admin/src/page/system/aiCallLog/AiCallLogList.jsx b/rsf-admin/src/page/system/aiCallLog/AiCallLogList.jsx
new file mode 100644
index 0000000..abb70b1
--- /dev/null
+++ b/rsf-admin/src/page/system/aiCallLog/AiCallLogList.jsx
@@ -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;
diff --git a/rsf-admin/src/page/system/aiCallLog/index.jsx b/rsf-admin/src/page/system/aiCallLog/index.jsx
new file mode 100644
index 0000000..ec8fe57
--- /dev/null
+++ b/rsf-admin/src/page/system/aiCallLog/index.jsx
@@ -0,0 +1,6 @@
+import AiCallLogList from "./AiCallLogList";
+
+export default {
+ list: AiCallLogList,
+ recordRepresentation: (record) => `${record?.requestId || ""}`,
+};
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/controller/AiCallLogController.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/controller/AiCallLogController.java
new file mode 100644
index 0000000..4a26db2
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/controller/AiCallLogController.java
@@ -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()));
+ }
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatToolEventDto.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatToolEventDto.java
index c4274b8..dbeb6de 100644
--- a/rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatToolEventDto.java
+++ b/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;
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiObserveStatsDto.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiObserveStatsDto.java
new file mode 100644
index 0000000..6bd71bc
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiObserveStatsDto.java
@@ -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;
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/entity/AiCallLog.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/entity/AiCallLog.java
new file mode 100644
index 0000000..a6cda23
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/entity/AiCallLog.java
@@ -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);
+ }
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/entity/AiMcpCallLog.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/entity/AiMcpCallLog.java
new file mode 100644
index 0000000..c4a34bf
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/entity/AiMcpCallLog.java
@@ -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);
+ }
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/mapper/AiCallLogMapper.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/mapper/AiCallLogMapper.java
new file mode 100644
index 0000000..8b3dd47
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/mapper/AiCallLogMapper.java
@@ -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> {
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/mapper/AiMcpCallLogMapper.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/mapper/AiMcpCallLogMapper.java
new file mode 100644
index 0000000..b224cff
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/mapper/AiMcpCallLogMapper.java
@@ -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> {
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiCallLogService.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiCallLogService.java
new file mode 100644
index 0000000..0b3d078
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiCallLogService.java
@@ -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);
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/MountedToolCallback.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/MountedToolCallback.java
new file mode 100644
index 0000000..efc6f88
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/MountedToolCallback.java
@@ -0,0 +1,8 @@
+package com.vincent.rsf.server.ai.service;
+
+import org.springframework.ai.tool.ToolCallback;
+
+public interface MountedToolCallback extends ToolCallback {
+
+ String getMountName();
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiCallLogServiceImpl.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiCallLogServiceImpl.java
new file mode 100644
index 0000000..d3827a6
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiCallLogServiceImpl.java
@@ -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***");
+ }
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiChatServiceImpl.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiChatServiceImpl.java
index 5e3c7ab..8a784ea 100644
--- a/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiChatServiceImpl.java
+++ b/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;
}
}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/McpMountRuntimeFactoryImpl.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/McpMountRuntimeFactoryImpl.java
index 6b36785..a290fd7 100644
--- a/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/McpMountRuntimeFactoryImpl.java
+++ b/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);
+ }
+ }
}
diff --git a/version/db/ai_feature.sql b/version/db/ai_feature.sql
index 8ca9631..ab0819b 100644
--- a/version/db/ai_feature.sql
+++ b/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`);
--
Gitblit v1.9.1