Files
happy-life-star/docs/superpowers/specs/2026-05-24-ai-call-log-detail-design.md
T
peanut 72faa34954 docs: 修复第四轮 spec review 问题,定稿设计文档
- 删除 LogQueryParams 重复定义
- JSON 高亮改为无依赖正则方案(highlight.js 未安装)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 11:38:20 +08:00

16 KiB
Raw Blame History

author, created_at, purpose
author created_at purpose
Peanut 2026-05-24 AI 调用日志详情查看功能设计文档

AI 调用日志详情查看功能设计

背景

后台管理 web-admin 中 AI 配置管理的「调用日志」页签目前仅展示基础列表信息(调用时间、场景、服务商、接口、状态、片段数、首字耗时、总耗时、错误码)。入参(inputText)、出参(outputText)、错误详情(errorMessage)等字段已有存储但完全隐藏,不利于后期统计和问题排查。

目标

  1. 在调用日志列表中支持展开行快速预览入参/出参
  2. 提供详情弹窗,完整展示所有字段,入参和出参需 JSON 格式化高亮显示
  3. 支持一键复制入参/出参 JSON 内容
  4. 后端支持分页、多条件筛选、关键词搜索入参出参内容

交互设计

列表页(调用日志 Tab

┌─────────────────────────────────────────────────────────────────────┐
│  [筛选栏] 状态[全部▼] 场景[全部▼] 时间[近7天▼] 关键词[____] [搜索]   │
├─────────────────────────────────────────────────────────────────────┤
│  ▼ 调用时间    场景        服务商      接口            状态  ...    │
│  ─────────────────────────────────────────────────────────────────  │
│  ▶ 2025-05-24  script_gen  dify       dify.xxx      成功   ...    │
│  ▼ 2025-05-24  emotion    coze        coze.summary  失败   ...    │
│    ┌────────────────────────────────────────────────────────────┐  │
│    │ 入参预览: {"messages":[{"role":"user","content":"..."}]}   │  │
│    │ 出参预览: AI 分析完成,用户情绪状态为...                     │  │
│    │ [查看完整详情]                                            │  │
│    └────────────────────────────────────────────────────────────┘  │
│  ▶ 2025-05-23  short_story dify       dify.story    成功   ...    │
├─────────────────────────────────────────────────────────────────────┤
│  共 156 条  [1][2][3]...[16]  每页 10/20/50 条                    │
└─────────────────────────────────────────────────────────────────────┘

展开行交互:

  • 点击行首展开/收起图标切换展开状态
  • 展开区域显示入参和出参的前 200 字符预览(超长截断 + ...
  • 截断逻辑确保不在 Unicode 转义序列中间截断(如 "中"),优先在空白字符处截断,保证预览可读性
  • 提供「查看完整详情」按钮,点击打开详情弹窗

详情弹窗

┌────────────────────────────────────────────────────────────┐
│  调用详情  [×]                                             │
├────────────────────────────────────────────────────────────┤
│  基本信息                                                  │
│  ┌────────────┬────────────┬────────────┬────────────┐    │
│  │ 调用时间   │ 场景       │ 服务商     │ 接口       │    │
│  │ 状态       │ 首字耗时   │ 总耗时     │ 片段数     │    │
│  │ 用户 ID    │ 请求 ID    │            │            │    │
│  └────────────┴────────────┴────────────┴────────────┘    │
│                                                            │
│  入参 [📋 复制]                                            │
│  ┌────────────────────────────────────────────────────┐   │
│  │ {                                                  │   │
│  │   "messages": [                                    │   │
│  │     { "role": "user", "content": "我最近很焦虑..." }│   │
│  │   ]                                                │   │
│  │ }                                                  │   │
│  └────────────────────────────────────────────────────┘   │
│                                                            │
│  出参 [📋 复制]                                            │
│  ┌────────────────────────────────────────────────────┐   │
│  │ {                                                  │   │
│  │   "response": "我理解你的感受..."                   │   │
│  │ }                                                  │   │
│  └────────────────────────────────────────────────────┘   │
│                                                            │
│  错误信息(仅失败时显示)                                   │
│  ┌────────────────────────────────────────────────────┐   │
│  │ AI_STREAM_INTERRUPTED: 连接超时                    │   │
│  └────────────────────────────────────────────────────┘   │
├────────────────────────────────────────────────────────────┤
│                                        [关闭]              │
└────────────────────────────────────────────────────────────┘

弹窗特性:

  • 入参/出参使用代码高亮 + 等宽字体,支持纵向滚动
  • 每个代码块右上角有「复制」按钮,点击复制原始 JSON 到剪贴板
  • 错误信息区域仅在 status === 'failed' 时显示,格式为 {errorCode}: {errorMessage}(如 AI_STREAM_INTERRUPTED: 连接超时
  • 弹窗宽度 800px,响应式限制 max-width: 90vw,防止窄屏溢出
  • 支持 ESC 关闭和点击遮罩关闭(Element Plus Dialog 默认行为)

前端组件设计

文件变更

文件 说明
web-admin/src/views/aiconfig/AiRoutingList.vue 修改:增加展开行、筛选栏、分页逻辑
web-admin/src/views/aiconfig/components/AiCallLogDetailDialog.vue 新增:详情弹窗
web-admin/src/components/JsonViewer.vue 新增:通用 JSON 格式化展示组件

JSON 高亮方案

JSON 格式化使用原生 JSON.stringify(obj, null, 2) 生成,再通过简单正则替换为带 class 的 HTML 实现语法高亮。不引入新依赖,实现轻量。高亮规则:key、字符串、数字/布尔/null、标点符号分别用正则匹配并包裹 <span class="..."> 后输出。

JsonViewer.vue Props 接口:

interface JsonViewerProps {
  data: string | object      // JSON 字符串或对象
  title?: string             // 标题,如"入参""出参"
  showCopy?: boolean         // 是否显示复制按钮,默认 true
  maxHeight?: string         // 最大高度,默认 "300px"
}

配色方案:

  • 字符串值 → #a5d6a7(浅绿)
  • 数字/布尔 → #90caf9(浅蓝)
  • key → #ce93d8(浅紫)
  • 标点符号 → #b0bec5(灰)

背景使用深色主题(#1e1e1e),与当前管理后台暗色风格一致。

非法 JSON 处理:inputText/outputText 不是合法 JSON,直接展示原始文本(等宽字体、自动换行),不报错。

筛选栏参数

使用 web-admin/src/types/common.ts 中定义的 LogQueryParams 接口(见「类型定义」章节)。

时间筛选交互: 前端筛选栏提供快捷选项(近7天、近30天、自定义范围),由前端组件将快捷选项转换为 startTime/endTime 后传给后端。自定义范围时使用 Element Plus el-date-picker 选择起止时间。

  • "近7天":今天 00:00:00 往前推 6 天(含今天)
  • "近30天":今天 00:00:00 往前推 29 天(含今天)
  • 自定义:用户选择起止日期,开始时间为所选日期 00:00:00,结束时间为所选日期 23:59:59

下拉数据来源: 场景、服务商、接口下拉选项复用页面已加载的 providers / endpoints / scenes 数据(loadAll() 中已有),无需额外接口。

展开行预览逻辑

function previewText(jsonStr: string, maxLen = 200): string {
  if (!jsonStr) return '-'
  if (jsonStr.length <= maxLen) return jsonStr
  const truncated = jsonStr.slice(0, maxLen)
  // 优先在空白字符处截断;无空白字符时直接截断到 maxLen
  const lastSpace = truncated.lastIndexOf(' ')
  const cutAt = lastSpace > maxLen * 0.5 ? lastSpace : maxLen
  return truncated.slice(0, cutAt) + '...'
}

类型定义

web-admin/src/types/common.ts 中新增通用分页类型(如文件不存在则创建):

export interface LogQueryParams {
  status?: string
  sceneCode?: string
  providerCode?: string
  endpointCode?: string
  startTime?: string
  endTime?: string
  keyword?: string
  pageNum?: number
  pageSize?: number
}

export interface PageResult<T> {
  records: T[]
  total: number
  pageNum: number
  pageSize: number
}

AiRoutingList.vue 中的 logs 数据从 AiCallLog[] 改为分页结构:const logs = ref<PageResult<AiCallLog>>({ records: [], total: 0, pageNum: 1, pageSize: 20 }),表格数据源绑定 logs.value.records

API 变更

// 原接口(废弃,仅 AiRoutingList.vue 中使用,直接替换)
export function listAiCallLogs(limit = 50) { ... }

// 新接口
export function queryAiCallLogs(params: LogQueryParams) {
  return request({ url: '/ai/call-logs', method: 'post', data: params })
}

兼容性说明: 经检查,listAiCallLogs 仅在 AiRoutingList.vue 的调用日志 Tab 中使用,无其他调用方。新接口使用 POST,旧接口使用 GET,路径相同方法不同,技术上可共存。实现时直接替换前端调用为 POST 新接口,旧 GET 接口保留不删除(避免影响其他潜在调用方)。

后端 API 设计

接口定义

@PostMapping("/call-logs")
@Operation(summary = "分页查询 AI 调用日志")
public Result<PageResult<AiCallLog>> queryCallLogs(@RequestBody @Valid AiCallLogQueryRequest request)

Request 对象

存放路径:backend-single/src/main/java/com/emotion/dto/request/ai/AiCallLogQueryRequest.java

@Schema(description = "AI 调用日志查询请求")
public class AiCallLogQueryRequest {

    @Schema(description = "状态:running / success / failed")
    private String status;

    @Schema(description = "场景编码")
    private String sceneCode;

    @Schema(description = "服务商编码")
    private String providerCode;

    @Schema(description = "接口编码")
    private String endpointCode;

    @Schema(description = "开始时间")
    private LocalDateTime startTime;

    @Schema(description = "结束时间")
    private LocalDateTime endTime;

    @Schema(description = "入参/出参关键词搜索")
    @Size(max = 200, message = "关键词长度不能超过 200")
    private String keyword;

    @Schema(description = "页码", example = "1")
    @Min(value = 1, message = "页码必须大于 0")
    private Integer pageNum = 1;

    @Schema(description = "每页条数", example = "20")
    @Min(value = 1, message = "每页条数必须大于 0")
    @Max(value = 100, message = "每页条数不能超过 100")
    private Integer pageSize = 20;
}

Service 接口变更

AiCallLogService 接口中新增方法:

PageResult<AiCallLog> query(AiCallLogQueryRequest request);

latest(Integer limit) 方法保留(前端旧接口调用或后台其他功能可能依赖)。

Service 查询逻辑

@Override
public PageResult<AiCallLog> query(AiCallLogQueryRequest request) {
    Page<AiCallLog> pageParam = new Page<>(request.getPageNum(), request.getPageSize());
    LambdaQueryWrapper<AiCallLog> wrapper = new LambdaQueryWrapper<>();

    wrapper.eq(AiCallLog::getIsDeleted, 0)
           .eq(StringUtils.isNotBlank(request.getStatus()), AiCallLog::getStatus, request.getStatus())
           .eq(StringUtils.isNotBlank(request.getSceneCode()), AiCallLog::getSceneCode, request.getSceneCode())
           .eq(StringUtils.isNotBlank(request.getProviderCode()), AiCallLog::getProviderCode, request.getProviderCode())
           .eq(StringUtils.isNotBlank(request.getEndpointCode()), AiCallLog::getEndpointCode, request.getEndpointCode())
           .ge(request.getStartTime() != null, AiCallLog::getCreateTime, request.getStartTime())
           .le(request.getEndTime() != null, AiCallLog::getCreateTime, request.getEndTime())
           .orderByDesc(AiCallLog::getCreateTime);

    // keyword 为空字符串或纯空格时不执行 LIKE 搜索(isNotBlank 已处理)
    // MyBatis-Plus like 默认使用预编译语句,无 SQL 注入风险
    // LIKE 是否区分大小写取决于数据库字符集(项目使用 utf8mb4,默认不区分大小写)
    if (StringUtils.isNotBlank(request.getKeyword())) {
        wrapper.and(w -> w.like(AiCallLog::getInputText, request.getKeyword())
                          .or()
                          .like(AiCallLog::getOutputText, request.getKeyword()));
    }

    Page<AiCallLog> page = page(pageParam, wrapper);
    // 转换为 PageResult,字段名与前端对齐
    PageResult<AiCallLog> result = new PageResult<>();
    result.setRecords(page.getRecords());
    result.setTotal(page.getTotal());
    result.setPageNum((int) page.getCurrent());
    result.setPageSize((int) page.getSize());
    return result;
}

性能考虑

  • input_textoutput_text 可能存储大 JSON(聊天记录),LIKE 搜索在数据量极大时会变慢
  • 量化阈值: 当日志表数据量超过 10 万条,或 keyword 搜索响应时间超过 500ms 时,启动优化评估:
    • 方案 1:为 input_textoutput_text 增加 MySQL 全文索引(FULLTEXT
    • 方案 2:限制 keyword 搜索只查询近 30 天的数据(配合 create_time 索引)
  • 当前日志量不大,先使用 LIKE。

数据模型

复用现有 AiCallLog 实体,无需新增表或字段。详情弹窗展示字段:

字段 说明
createTime 调用时间
sceneCode 场景编码
providerCode 服务商编码
endpointCode 接口编码
status 状态
firstTokenMs 首字耗时
durationMs 总耗时
streamChunks 片段数
inputText 入参(JSON 格式化)
outputText 出参(JSON 格式化)
errorCode 错误码
errorMessage 错误信息
userId 用户 ID
requestId 请求 ID