package com.wok.supportbot.controller; import com.wok.supportbot.entity.LlmCallTrace; import com.wok.supportbot.service.LlmCallTraceService; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; 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.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; /** * LLM 调用追踪接口(仅 admin 可访问)。 *

* 提供调用记录的分页查询、详情、聚合统计与清理。 * 端点仅挂管理路径(/llm-trace),绝不进入 /ai/** 或 /open-api/**(SDK 开放路径), * 防止 conversation_id 跨租户越权。 */ @Slf4j @RestController @RequestMapping("/llm-trace") @PreAuthorize("hasRole('admin')") public class LlmCallTraceController { @Autowired private LlmCallTraceService llmCallTraceService; /** 清理的最小保留天数(防止误清全部) */ private static final int MIN_KEEP_DAYS = 1; /** 清理的最大保留天数(防止超过 PostgreSQL make_interval 合理范围) */ private static final int MAX_KEEP_DAYS = 3650; /** * 分页查询调用记录(列表不含大 TEXT 字段)。 */ @GetMapping("/list") public ResponseEntity> list( @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size, @RequestParam(required = false) Long roleId, @RequestParam(required = false) String conversationId, @RequestParam(required = false) String intent, @RequestParam(required = false) String startTime, @RequestParam(required = false) String endTime, @RequestParam(required = false) String keyword, @RequestParam(required = false) String errorType, @RequestParam(required = false) String sortField, @RequestParam(required = false) String sortOrder) { try { Map result = llmCallTraceService.pageQuery( page, size, roleId, conversationId, intent, startTime, endTime, keyword, errorType, sortField, sortOrder); Map data = new LinkedHashMap<>(); data.put("success", true); data.put("data", result.get("records")); data.put("total", result.get("total")); data.put("page", result.get("page")); data.put("size", result.get("size")); data.put("pages", result.get("pages")); return ResponseEntity.ok(data); } catch (Exception e) { log.error("查询 LLM 调用追踪失败", e); return ResponseEntity.status(500).body(Map.of( "success", false, "message", "查询失败:" + e.getMessage() )); } } /** * 单条详情(含完整 system prompt)。 */ @GetMapping("/{id}") public ResponseEntity> detail(@PathVariable Long id) { try { LlmCallTrace trace = llmCallTraceService.getDetail(id); if (trace == null) { return ResponseEntity.status(404).body(Map.of( "success", false, "message", "调用记录不存在" )); } return ResponseEntity.ok(Map.of("success", true, "data", trace)); } catch (Exception e) { log.error("查询 LLM 调用追踪详情失败: id={}", id, e); return ResponseEntity.status(500).body(Map.of( "success", false, "message", "查询失败:" + e.getMessage() )); } } /** * 清理 N 天前的记录(POST,避免 DELETE + RequestBody 路径冲突)。 * 保留天数设下限,防止误清全部。 */ @PostMapping("/clean") public ResponseEntity> clean(@RequestBody Map body) { try { Object raw = body.get("keepDays"); int keepDays = raw instanceof Number n ? n.intValue() : Integer.parseInt(String.valueOf(raw)); if (keepDays < MIN_KEEP_DAYS || keepDays > MAX_KEEP_DAYS) { return ResponseEntity.badRequest().body(Map.of( "success", false, "message", "保留天数必须在 " + MIN_KEEP_DAYS + " ~ " + MAX_KEEP_DAYS + " 天之间" )); } int deleted = llmCallTraceService.cleanBefore(keepDays); log.info("管理员清理 LLM 调用追踪:操作人={}, 保留 {} 天, 删除 {} 条", currentUser(), keepDays, deleted); return ResponseEntity.ok(Map.of( "success", true, "message", "清理完成", "deleted", deleted )); } catch (Exception e) { log.error("清理 LLM 调用追踪失败", e); return ResponseEntity.status(500).body(Map.of( "success", false, "message", "清理失败:" + e.getMessage() )); } } /** * 聚合统计(按角色或模型分组)。 */ @GetMapping("/stats") public ResponseEntity> stats(@RequestParam(defaultValue = "role") String groupBy) { try { List> rows = llmCallTraceService.stats(groupBy); return ResponseEntity.ok(Map.of("success", true, "data", rows)); } catch (Exception e) { log.error("查询 LLM 调用追踪统计失败", e); return ResponseEntity.status(500).body(Map.of( "success", false, "message", "统计失败:" + e.getMessage() )); } } /** * 可选错误类型列表(供前端筛选下拉)。 */ @GetMapping("/error-types") public ResponseEntity> errorTypes() { List> types = List.of( Map.of("label", "LLM 调用失败", "value", "LLM_API"), Map.of("label", "MCP 工具异常", "value", "MCP"), Map.of("label", "熔断降级", "value", "CIRCUIT_BREAK"), Map.of("label", "参数校验", "value", "VALIDATION"), Map.of("label", "未知", "value", "UNKNOWN") ); return ResponseEntity.ok(Map.of("success", true, "data", types)); } /** * 时间维度趋势统计(按小时或天聚合)。 */ @GetMapping("/trend") public ResponseEntity> trend( @RequestParam(defaultValue = "HOUR") String groupBy, @RequestParam(required = false) String startTime, @RequestParam(required = false) String endTime) { try { List> rows = llmCallTraceService.trend(groupBy, startTime, endTime); return ResponseEntity.ok(Map.of("success", true, "data", rows)); } catch (Exception e) { log.error("查询 LLM 调用追踪趋势失败", e); return ResponseEntity.status(500).body(Map.of( "success", false, "message", "统计失败:" + e.getMessage() )); } } /** * 获取当前登录用户名(用于审计日志,best-effort)。 */ private String currentUser() { Authentication auth = SecurityContextHolder.getContext().getAuthentication(); return auth != null ? auth.getName() : "unknown"; } }