diff --git a/docs/script/sql/update/update-0615-trace.sql b/docs/script/sql/update/update-0615-trace.sql
new file mode 100644
index 00000000..974d9f63
--- /dev/null
+++ b/docs/script/sql/update/update-0615-trace.sql
@@ -0,0 +1,128 @@
+-- 链路追踪运行记录表
+CREATE TABLE IF NOT EXISTS `trace_run` (
+ `id` bigint NOT NULL COMMENT '主键',
+ `trace_id` varchar(64) NOT NULL COMMENT '链路ID',
+ `trace_name` varchar(128) NOT NULL COMMENT '链路名称',
+ `business_type` varchar(64) NOT NULL COMMENT '业务类型',
+ `business_id` varchar(128) DEFAULT NULL COMMENT '业务ID',
+ `user_id` bigint DEFAULT NULL COMMENT '用户ID',
+ `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号',
+ `status` varchar(32) NOT NULL COMMENT '状态',
+ `start_time` datetime NOT NULL COMMENT '开始时间',
+ `end_time` datetime DEFAULT NULL COMMENT '结束时间',
+ `duration_ms` bigint DEFAULT NULL COMMENT '耗时毫秒',
+ `error_message` varchar(1000) DEFAULT NULL COMMENT '错误摘要',
+ `metadata` text DEFAULT NULL COMMENT '元数据JSON',
+ `create_dept` bigint DEFAULT NULL COMMENT '创建部门',
+ `create_by` bigint DEFAULT NULL COMMENT '创建者',
+ `create_time` datetime DEFAULT NULL COMMENT '创建时间',
+ `update_by` bigint DEFAULT NULL COMMENT '更新者',
+ `update_time` datetime DEFAULT NULL COMMENT '更新时间',
+ `del_flag` char(1) DEFAULT '0' COMMENT '删除标志(0代表存在 2代表删除)',
+ PRIMARY KEY (`id`) USING BTREE,
+ UNIQUE KEY `uk_trace_run_trace_id` (`trace_id`) USING BTREE,
+ KEY `idx_trace_run_business` (`business_type`, `business_id`) USING BTREE,
+ KEY `idx_trace_run_status_time` (`status`, `start_time`) USING BTREE,
+ KEY `idx_trace_run_tenant_time` (`tenant_id`, `start_time`) USING BTREE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='链路追踪运行记录表' ROW_FORMAT=DYNAMIC;
+
+-- 链路追踪节点记录表
+CREATE TABLE IF NOT EXISTS `trace_node` (
+ `id` bigint NOT NULL COMMENT '主键',
+ `trace_id` varchar(64) NOT NULL COMMENT '链路ID',
+ `node_id` varchar(64) NOT NULL COMMENT '节点ID',
+ `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号',
+ `parent_node_id` varchar(64) DEFAULT NULL COMMENT '父节点ID',
+ `node_name` varchar(128) NOT NULL COMMENT '节点名称',
+ `node_type` varchar(64) NOT NULL COMMENT '节点类型',
+ `depth` int DEFAULT 0 COMMENT '节点深度',
+ `sort_order` int DEFAULT 0 COMMENT '排序',
+ `class_name` varchar(255) DEFAULT NULL COMMENT '类名',
+ `method_name` varchar(128) DEFAULT NULL COMMENT '方法名',
+ `status` varchar(32) NOT NULL COMMENT '状态',
+ `start_time` datetime NOT NULL COMMENT '开始时间',
+ `end_time` datetime DEFAULT NULL COMMENT '结束时间',
+ `duration_ms` bigint DEFAULT NULL COMMENT '耗时毫秒',
+ `error_message` varchar(1000) DEFAULT NULL COMMENT '错误摘要',
+ `input_payload` text DEFAULT NULL COMMENT '输入JSON',
+ `output_payload` text DEFAULT NULL COMMENT '输出JSON',
+ `metadata` text DEFAULT NULL COMMENT '元数据JSON',
+ `create_dept` bigint DEFAULT NULL COMMENT '创建部门',
+ `create_by` bigint DEFAULT NULL COMMENT '创建者',
+ `create_time` datetime DEFAULT NULL COMMENT '创建时间',
+ `update_by` bigint DEFAULT NULL COMMENT '更新者',
+ `update_time` datetime DEFAULT NULL COMMENT '更新时间',
+ `del_flag` char(1) DEFAULT '0' COMMENT '删除标志(0代表存在 2代表删除)',
+ PRIMARY KEY (`id`) USING BTREE,
+ KEY `idx_trace_node_trace_id` (`trace_id`) USING BTREE,
+ KEY `idx_trace_node_parent` (`trace_id`, `parent_node_id`) USING BTREE,
+ KEY `idx_trace_node_time` (`trace_id`, `start_time`) USING BTREE,
+ KEY `idx_trace_node_tenant_time` (`tenant_id`, `start_time`) USING BTREE,
+ KEY `idx_trace_node_type_status` (`node_type`, `status`) USING BTREE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='链路追踪节点记录表' ROW_FORMAT=DYNAMIC;
+
+-- 兼容已存在的旧 trace 表:CREATE TABLE IF NOT EXISTS 不会给旧表补新字段
+SET @trace_run_add_tenant_sql = (
+ SELECT IF(COUNT(*) = 0,
+ 'ALTER TABLE `trace_run` ADD COLUMN `tenant_id` varchar(20) DEFAULT ''000000'' COMMENT ''租户编号'' AFTER `user_id`',
+ 'SELECT 1'
+ )
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'trace_run'
+ AND COLUMN_NAME = 'tenant_id'
+);
+PREPARE trace_run_add_tenant_stmt FROM @trace_run_add_tenant_sql;
+EXECUTE trace_run_add_tenant_stmt;
+DEALLOCATE PREPARE trace_run_add_tenant_stmt;
+
+SET @trace_run_add_tenant_idx_sql = (
+ SELECT IF(COUNT(*) = 0,
+ 'ALTER TABLE `trace_run` ADD INDEX `idx_trace_run_tenant_time` (`tenant_id`, `start_time`) USING BTREE',
+ 'SELECT 1'
+ )
+ FROM information_schema.STATISTICS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'trace_run'
+ AND INDEX_NAME = 'idx_trace_run_tenant_time'
+);
+PREPARE trace_run_add_tenant_idx_stmt FROM @trace_run_add_tenant_idx_sql;
+EXECUTE trace_run_add_tenant_idx_stmt;
+DEALLOCATE PREPARE trace_run_add_tenant_idx_stmt;
+
+SET @trace_node_add_tenant_sql = (
+ SELECT IF(COUNT(*) = 0,
+ 'ALTER TABLE `trace_node` ADD COLUMN `tenant_id` varchar(20) DEFAULT ''000000'' COMMENT ''租户编号'' AFTER `node_id`',
+ 'SELECT 1'
+ )
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'trace_node'
+ AND COLUMN_NAME = 'tenant_id'
+);
+PREPARE trace_node_add_tenant_stmt FROM @trace_node_add_tenant_sql;
+EXECUTE trace_node_add_tenant_stmt;
+DEALLOCATE PREPARE trace_node_add_tenant_stmt;
+
+SET @trace_node_add_tenant_idx_sql = (
+ SELECT IF(COUNT(*) = 0,
+ 'ALTER TABLE `trace_node` ADD INDEX `idx_trace_node_tenant_time` (`tenant_id`, `start_time`) USING BTREE',
+ 'SELECT 1'
+ )
+ FROM information_schema.STATISTICS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'trace_node'
+ AND INDEX_NAME = 'idx_trace_node_tenant_time'
+);
+PREPARE trace_node_add_tenant_idx_stmt FROM @trace_node_add_tenant_idx_sql;
+EXECUTE trace_node_add_tenant_idx_stmt;
+DEALLOCATE PREPARE trace_node_add_tenant_idx_stmt;
+
+-- 链路追踪监控菜单 & 按钮权限
+INSERT INTO `sys_menu`
+(`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query_param`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_dept`, `create_by`, `create_time`, `remark`)
+SELECT (SELECT COALESCE(MAX(`menu_id`), 0) + 1 FROM (SELECT `menu_id` FROM `sys_menu`) t), '链路追踪', 2, 7, 'trace', 'monitor/trace/index', '', 1, 0, 'C', '0', '0', 'monitor:trace:list', 'tabler:route', 103, 1, NOW(), '链路追踪监控菜单';
+
+INSERT INTO `sys_menu`
+(`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query_param`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_dept`, `create_by`, `create_time`, `remark`)
+SELECT (SELECT COALESCE(MAX(`menu_id`), 0) + 1 FROM (SELECT `menu_id` FROM `sys_menu`) t), '链路追踪查询', (SELECT `menu_id` FROM `sys_menu` WHERE `perms` = 'monitor:trace:list' AND `menu_type` = 'C' LIMIT 1), 1, '#', '', '', 1, 0, 'F', '0', '0', 'monitor:trace:query', '#', 103, 1, NOW(), '';
diff --git a/ruoyi-admin/src/main/resources/application.yml b/ruoyi-admin/src/main/resources/application.yml
index 14d4faa4..a528f646 100644
--- a/ruoyi-admin/src/main/resources/application.yml
+++ b/ruoyi-admin/src/main/resources/application.yml
@@ -227,6 +227,24 @@ xss:
excludeUrls:
- /system/notice
+--- # 链路追踪配置
+trace:
+ # 是否启用链路追踪,默认 true
+ # 关闭后所有埋点代码会直接透传业务逻辑,不写库、不创建上下文,零性能开销
+ enabled: true
+ payload:
+ # 是否记录方法真实入参和返回值,默认 false(仅存摘要)
+ # 仅对 @TraceNode 注解方式生效,编程式埋点使用 TracePayloadBuilder 手动构建的摘要不受此开关影响
+ # true → 自动序列化原始入参/返回值,截断后写入 input_payload / output_payload
+ # false → 只存 nodeName / 耗时 / 状态等元信息,input_payload / output_payload 为空
+ record-detail: false
+ # input payload 最大字符长度,超过部分会被截断丢弃
+ max-input-length: 1000
+ # output payload 最大字符长度,超过部分会被截断丢弃
+ max-output-length: 2000
+ # 错误信息最大字符长度(格式: "异常类名: 异常消息"),超过部分会被截断丢弃
+ max-error-length: 1000
+
--- # 分布式锁 lock4j 全局配置
lock4j:
# 获取分布式锁超时时间,默认为 3000 毫秒
diff --git a/ruoyi-common/pom.xml b/ruoyi-common/pom.xml
index a91e84b0..e6b49eaa 100644
--- a/ruoyi-common/pom.xml
+++ b/ruoyi-common/pom.xml
@@ -35,6 +35,7 @@
ruoyi-common-tenant
ruoyi-common-websocket
ruoyi-common-sse
+ ruoyi-common-trace
ruoyi-common
diff --git a/ruoyi-common/ruoyi-common-bom/pom.xml b/ruoyi-common/ruoyi-common-bom/pom.xml
index ec18b08a..9b0acbdd 100644
--- a/ruoyi-common/ruoyi-common-bom/pom.xml
+++ b/ruoyi-common/ruoyi-common-bom/pom.xml
@@ -186,6 +186,13 @@
${revision}
+
+
+ org.ruoyi
+ ruoyi-common-trace
+ ${revision}
+
+
diff --git a/ruoyi-common/ruoyi-common-trace/pom.xml b/ruoyi-common/ruoyi-common-trace/pom.xml
new file mode 100644
index 00000000..011726ae
--- /dev/null
+++ b/ruoyi-common/ruoyi-common-trace/pom.xml
@@ -0,0 +1,42 @@
+
+
+
+ org.ruoyi
+ ruoyi-common
+ ${revision}
+
+ 4.0.0
+
+ ruoyi-common-trace
+
+
+ ruoyi-common-trace 通用链路追踪
+
+
+
+
+ org.ruoyi
+ ruoyi-common-core
+
+
+ org.ruoyi
+ ruoyi-common-mybatis
+
+
+ org.ruoyi
+ ruoyi-common-json
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+ org.mockito
+ mockito-junit-jupiter
+ test
+
+
+
diff --git a/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/annotation/TraceNode.java b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/annotation/TraceNode.java
new file mode 100644
index 00000000..7e8a8761
--- /dev/null
+++ b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/annotation/TraceNode.java
@@ -0,0 +1,31 @@
+package org.ruoyi.common.trace.annotation;
+
+import org.ruoyi.common.trace.constant.TraceConstants;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Trace 方法节点标记。
+ */
+@Target(ElementType.METHOD)
+@Retention(RetentionPolicy.RUNTIME)
+public @interface TraceNode {
+
+ /**
+ * 节点名称。
+ */
+ String name() default "";
+
+ /**
+ * 节点类型。
+ */
+ String type() default TraceConstants.NODE_METHOD;
+
+ /**
+ * 安全输入摘要。
+ */
+ String input() default "";
+}
diff --git a/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/aspect/TraceNodeAspect.java b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/aspect/TraceNodeAspect.java
new file mode 100644
index 00000000..606afaf1
--- /dev/null
+++ b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/aspect/TraceNodeAspect.java
@@ -0,0 +1,89 @@
+package org.ruoyi.common.trace.aspect;
+
+import cn.hutool.core.util.IdUtil;
+import cn.hutool.core.util.StrUtil;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.aspectj.lang.ProceedingJoinPoint;
+import org.aspectj.lang.annotation.Around;
+import org.aspectj.lang.annotation.Aspect;
+import org.aspectj.lang.reflect.MethodSignature;
+import org.ruoyi.common.trace.annotation.TraceNode;
+import org.ruoyi.common.trace.config.TraceProperties;
+import org.ruoyi.common.trace.constant.TraceConstants;
+import org.ruoyi.common.trace.core.TraceContext;
+import org.ruoyi.common.trace.service.TraceRecordService;
+import org.ruoyi.common.trace.util.TracePayloadUtils;
+import org.springframework.core.Ordered;
+import org.springframework.core.annotation.Order;
+
+import java.lang.reflect.Method;
+import java.util.Date;
+
+/**
+ * Trace 方法节点采集切面。
+ */
+@Slf4j
+@Aspect
+@Order(Ordered.HIGHEST_PRECEDENCE + 10)
+@RequiredArgsConstructor
+public class TraceNodeAspect {
+
+ private final TraceRecordService traceRecordService;
+ private final TraceProperties traceProperties;
+
+ @Around("@annotation(traceNode)")
+ public Object aroundNode(ProceedingJoinPoint joinPoint, TraceNode traceNode) throws Throwable {
+ if (!traceProperties.isEnabled() || StrUtil.isBlank(TraceContext.getTraceId())) {
+ return joinPoint.proceed();
+ }
+ Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
+ String nodeId = IdUtil.getSnowflakeNextIdStr();
+ String traceId = TraceContext.getTraceId();
+ long startMillis = System.currentTimeMillis();
+ Date startTime = new Date();
+
+ org.ruoyi.common.trace.domain.TraceNode node = new org.ruoyi.common.trace.domain.TraceNode();
+ node.setTraceId(traceId);
+ node.setNodeId(nodeId);
+ node.setParentNodeId(TraceContext.currentNodeId());
+ node.setDepth(TraceContext.depth());
+ node.setNodeName(StrUtil.blankToDefault(traceNode.name(), method.getName()));
+ node.setNodeType(StrUtil.blankToDefault(traceNode.type(), TraceConstants.NODE_METHOD));
+ node.setClassName(method.getDeclaringClass().getName());
+ node.setMethodName(method.getName());
+ node.setStatus(TraceConstants.STATUS_RUNNING);
+ node.setStartTime(startTime);
+ node.setInputPayload(StrUtil.isBlank(traceNode.input()) ? null : traceNode.input());
+
+ try {
+ traceRecordService.startNode(node);
+ } catch (Exception ex) {
+ log.warn("写入 trace 节点失败,traceId={}", traceId, ex);
+ return joinPoint.proceed();
+ }
+
+ TraceContext.pushNode(nodeId);
+ try {
+ Object result = joinPoint.proceed();
+ safeFinishNode(traceId, nodeId, TraceConstants.STATUS_SUCCESS, null,
+ TracePayloadUtils.output(result, traceProperties), startMillis);
+ return result;
+ } catch (Throwable ex) {
+ safeFinishNode(traceId, nodeId, TraceConstants.STATUS_ERROR,
+ TracePayloadUtils.error(ex, traceProperties), null, startMillis);
+ throw ex;
+ } finally {
+ TraceContext.popNode();
+ }
+ }
+
+ private void safeFinishNode(String traceId, String nodeId, String status, String errorMessage, String outputPayload, long startMillis) {
+ try {
+ traceRecordService.finishNode(traceId, nodeId, status, errorMessage, outputPayload,
+ new Date(), System.currentTimeMillis() - startMillis);
+ } catch (Exception ex) {
+ log.warn("更新 trace 节点失败,traceId={}, nodeId={}", traceId, nodeId, ex);
+ }
+ }
+}
diff --git a/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/config/TraceAutoConfiguration.java b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/config/TraceAutoConfiguration.java
new file mode 100644
index 00000000..d0475311
--- /dev/null
+++ b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/config/TraceAutoConfiguration.java
@@ -0,0 +1,24 @@
+package org.ruoyi.common.trace.config;
+
+import org.ruoyi.common.trace.aspect.TraceNodeAspect;
+import org.ruoyi.common.trace.service.TraceRecordService;
+import org.springframework.boot.autoconfigure.AutoConfiguration;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+
+/**
+ * 通用链路追踪自动配置。
+ */
+@AutoConfiguration
+@EnableConfigurationProperties(TraceProperties.class)
+public class TraceAutoConfiguration {
+
+ @Bean
+ @ConditionalOnMissingBean
+ @ConditionalOnProperty(prefix = "trace", name = "enabled", havingValue = "true", matchIfMissing = true)
+ public TraceNodeAspect traceNodeAspect(TraceRecordService traceRecordService, TraceProperties traceProperties) {
+ return new TraceNodeAspect(traceRecordService, traceProperties);
+ }
+}
diff --git a/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/config/TraceProperties.java b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/config/TraceProperties.java
new file mode 100644
index 00000000..4e3c5247
--- /dev/null
+++ b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/config/TraceProperties.java
@@ -0,0 +1,46 @@
+package org.ruoyi.common.trace.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+/**
+ * 通用链路追踪配置。
+ */
+@Data
+@ConfigurationProperties(prefix = "trace")
+public class TraceProperties {
+
+ /**
+ * 是否启用链路追踪。
+ */
+ private boolean enabled = true;
+
+ /**
+ * payload 记录策略。
+ */
+ private Payload payload = new Payload();
+
+ @Data
+ public static class Payload {
+
+ /**
+ * 是否记录截断后的详情。默认 false,只记录摘要。
+ */
+ private boolean recordDetail = false;
+
+ /**
+ * input payload 最大长度。
+ */
+ private int maxInputLength = 1000;
+
+ /**
+ * output payload 最大长度。
+ */
+ private int maxOutputLength = 2000;
+
+ /**
+ * 错误信息最大长度。
+ */
+ private int maxErrorLength = 1000;
+ }
+}
diff --git a/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/constant/TraceConstants.java b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/constant/TraceConstants.java
new file mode 100644
index 00000000..846d1589
--- /dev/null
+++ b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/constant/TraceConstants.java
@@ -0,0 +1,22 @@
+package org.ruoyi.common.trace.constant;
+
+/**
+ * 通用链路追踪常量。
+ */
+public final class TraceConstants {
+
+ private TraceConstants() {
+ }
+
+ public static final String STATUS_RUNNING = "RUNNING";
+ public static final String STATUS_SUCCESS = "SUCCESS";
+ public static final String STATUS_ERROR = "ERROR";
+ public static final String STATUS_CANCELLED = "CANCELLED";
+
+ public static final String NODE_METHOD = "METHOD";
+ public static final String NODE_HTTP = "HTTP";
+ public static final String NODE_DB = "DB";
+ public static final String NODE_CACHE = "CACHE";
+ public static final String NODE_TASK = "TASK";
+ public static final String NODE_STREAM = "STREAM";
+}
diff --git a/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/constant/TraceDisplayConstants.java b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/constant/TraceDisplayConstants.java
new file mode 100644
index 00000000..48fc9f20
--- /dev/null
+++ b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/constant/TraceDisplayConstants.java
@@ -0,0 +1,163 @@
+package org.ruoyi.common.trace.constant;
+
+import java.util.Map;
+
+/**
+ * 链路追踪中文展示常量。
+ *
+ * 将技术标识映射为用户可读的中文标签,供前端直接展示。
+ */
+public final class TraceDisplayConstants {
+
+ private TraceDisplayConstants() {
+ }
+
+ // ======================== 节点类型中文映射 ========================
+
+ /**
+ * 通用节点类型中文标签。
+ */
+ public static final Map NODE_TYPE_LABELS = Map.ofEntries(
+ Map.entry("RETRIEVAL", "知识检索"),
+ Map.entry("RERANK", "重排序"),
+ Map.entry("LLM_CALL", "LLM 调用"),
+ Map.entry("STREAM", "流式输出"),
+ Map.entry("METHOD", "方法调用"),
+ Map.entry("HTTP", "HTTP 请求"),
+ Map.entry("DB", "数据库"),
+ Map.entry("CACHE", "缓存操作"),
+ Map.entry("TASK", "异步任务"),
+ Map.entry("ROOT", "根节点")
+ );
+
+ /**
+ * 业务类型中文标签。
+ */
+ public static final Map BUSINESS_TYPE_LABELS = Map.ofEntries(
+ Map.entry("RAG_CHAT", "RAG 对话"),
+ Map.entry("API", "API 调用"),
+ Map.entry("SCHEDULED", "定时任务")
+ );
+
+ /**
+ * 状态中文标签。
+ */
+ public static final Map STATUS_LABELS = Map.ofEntries(
+ Map.entry("RUNNING", "运行中"),
+ Map.entry("SUCCESS", "成功"),
+ Map.entry("ERROR", "失败"),
+ Map.entry("CANCELLED", "已取消"),
+ Map.entry("TIMEOUT", "超时")
+ );
+
+ // ======================== 工具方法 ========================
+
+ /**
+ * 获取节点类型中文标签,未匹配时返回原始值。
+ */
+ public static String nodeTypeLabel(String nodeType) {
+ if (nodeType == null) {
+ return "-";
+ }
+ return NODE_TYPE_LABELS.getOrDefault(nodeType.toUpperCase(), nodeType);
+ }
+
+ /**
+ * 获取业务类型中文标签,未匹配时返回原始值。
+ */
+ public static String businessTypeLabel(String businessType) {
+ if (businessType == null) {
+ return "-";
+ }
+ return BUSINESS_TYPE_LABELS.getOrDefault(businessType.toUpperCase(), businessType);
+ }
+
+ /**
+ * 获取状态中文标签,未匹配时返回原始值。
+ */
+ public static String statusLabel(String status) {
+ if (status == null) {
+ return "未知";
+ }
+ return STATUS_LABELS.getOrDefault(status.toUpperCase(), status);
+ }
+
+ /**
+ * 将技术节点名称转为可读展示名。
+ *
+ * 支持 kebab-case / snake_case / camelCase → 首字母大写空格分隔。
+ */
+ public static String prettifyNodeName(String raw) {
+ if (raw == null || raw.isBlank()) {
+ return "-";
+ }
+ String trimmed = raw.trim();
+ // 已知映射优先
+ Map known = Map.ofEntries(
+ Map.entry("rag-chat", "RAG 流式对话"),
+ Map.entry("rag-stream-chat", "RAG 流式对话"),
+ Map.entry("retrieval-engine", "知识库检索"),
+ Map.entry("multi-channel-retrieval", "多路召回"),
+ Map.entry("context-build", "上下文组装"),
+ Map.entry("prompt-render", "Prompt 渲染"),
+ Map.entry("query-rewrite-and-split", "问题改写与拆分"),
+ Map.entry("intent-resolve", "意图识别"),
+ Map.entry("guidance-detect", "歧义引导"),
+ Map.entry("conversation-title-gen", "会话标题生成"),
+ Map.entry("user-first-packet", "用户感知首包"),
+ Map.entry("llm-first-packet", "LLM 首包"),
+ Map.entry("llm-chat-routing", "LLM 路由调度"),
+ Map.entry("llm-stream-routing", "LLM 流式路由")
+ );
+ if (known.containsKey(trimmed)) {
+ return known.get(trimmed);
+ }
+ // 通用格式化: 按 [-_] 分割,每段首字母大写
+ String[] parts = trimmed.split("[-_\\s]+");
+ StringBuilder sb = new StringBuilder();
+ for (String part : parts) {
+ if (part.isEmpty()) {
+ continue;
+ }
+ if (sb.length() > 0) {
+ sb.append(' ');
+ }
+ sb.append(Character.toUpperCase(part.charAt(0)));
+ if (part.length() > 1) {
+ sb.append(part.substring(1));
+ }
+ }
+ return sb.length() > 0 ? sb.toString() : trimmed;
+ }
+
+ /**
+ * 判断状态是否为失败。
+ */
+ public static boolean isFailed(String status) {
+ if (status == null) {
+ return false;
+ }
+ String upper = status.toUpperCase();
+ return "ERROR".equals(upper) || "FAILED".equals(upper) || "TIMEOUT".equals(upper);
+ }
+
+ /**
+ * 判断状态是否为成功。
+ */
+ public static boolean isSuccess(String status) {
+ if (status == null) {
+ return false;
+ }
+ return "SUCCESS".equalsIgnoreCase(status);
+ }
+
+ /**
+ * 判断状态是否为运行中。
+ */
+ public static boolean isRunning(String status) {
+ if (status == null) {
+ return false;
+ }
+ return "RUNNING".equalsIgnoreCase(status);
+ }
+}
diff --git a/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/core/DefaultTraceStreamSpan.java b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/core/DefaultTraceStreamSpan.java
new file mode 100644
index 00000000..a9bb9d64
--- /dev/null
+++ b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/core/DefaultTraceStreamSpan.java
@@ -0,0 +1,71 @@
+package org.ruoyi.common.trace.core;
+
+import lombok.extern.slf4j.Slf4j;
+import org.ruoyi.common.trace.config.TraceProperties;
+import org.ruoyi.common.trace.constant.TraceConstants;
+import org.ruoyi.common.trace.service.TraceRecordService;
+import org.ruoyi.common.trace.util.TracePayloadUtils;
+
+import java.util.Date;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * 默认流式 trace 节点实现。
+ */
+@Slf4j
+public class DefaultTraceStreamSpan implements TraceStreamSpan {
+
+ private final TraceRecordService traceRecordService;
+ private final TraceProperties traceProperties;
+ private final String traceId;
+ private final String nodeId;
+ private final long startMillis;
+ private final AtomicBoolean finished = new AtomicBoolean(false);
+ private final AtomicBoolean detached = new AtomicBoolean(false);
+
+ public DefaultTraceStreamSpan(TraceRecordService traceRecordService,
+ TraceProperties traceProperties,
+ String traceId,
+ String nodeId,
+ long startMillis) {
+ this.traceRecordService = traceRecordService;
+ this.traceProperties = traceProperties;
+ this.traceId = traceId;
+ this.nodeId = nodeId;
+ this.startMillis = startMillis;
+ }
+
+ @Override
+ public void detach() {
+ if (detached.compareAndSet(false, true)) {
+ TraceContext.popNode();
+ }
+ }
+
+ @Override
+ public void finishSuccess() {
+ finish(TraceConstants.STATUS_SUCCESS, null);
+ }
+
+ @Override
+ public void finishError(Throwable throwable) {
+ finish(TraceConstants.STATUS_ERROR, TracePayloadUtils.error(throwable, traceProperties));
+ }
+
+ @Override
+ public void finishCancelledIfRunning() {
+ finish(TraceConstants.STATUS_CANCELLED, null);
+ }
+
+ private void finish(String status, String errorMessage) {
+ if (!finished.compareAndSet(false, true)) {
+ return;
+ }
+ try {
+ traceRecordService.finishNode(traceId, nodeId, status, errorMessage, null,
+ new Date(), System.currentTimeMillis() - startMillis);
+ } catch (Exception e) {
+ log.warn("结束 trace stream span 失败,traceId={}, nodeId={}", traceId, nodeId, e);
+ }
+ }
+}
diff --git a/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/core/TraceContext.java b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/core/TraceContext.java
new file mode 100644
index 00000000..85db1dc0
--- /dev/null
+++ b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/core/TraceContext.java
@@ -0,0 +1,115 @@
+package org.ruoyi.common.trace.core;
+
+import java.util.ArrayDeque;
+import java.util.Deque;
+
+/**
+ * 通用链路追踪上下文。
+ */
+public final class TraceContext {
+
+ private static final ThreadLocal TRACE_ID = new ThreadLocal<>();
+ private static final ThreadLocal BUSINESS_TYPE = new ThreadLocal<>();
+ private static final ThreadLocal BUSINESS_ID = new ThreadLocal<>();
+ private static final ThreadLocal USER_ID = new ThreadLocal<>();
+ private static final ThreadLocal TENANT_ID = new ThreadLocal<>();
+ private static final ThreadLocal> NODE_STACK = new ThreadLocal<>();
+
+ private TraceContext() {
+ }
+
+ public static TraceScope begin(String traceId, String businessType, String businessId, Long userId, String tenantId) {
+ TraceScope scope = new TraceScope(getTraceId(), getBusinessType(), getBusinessId(), getUserId(), getTenantId());
+ TRACE_ID.set(traceId);
+ BUSINESS_TYPE.set(businessType);
+ BUSINESS_ID.set(businessId);
+ USER_ID.set(userId);
+ TENANT_ID.set(tenantId);
+ NODE_STACK.remove();
+ return scope;
+ }
+
+ static void restore(String traceId, String businessType, String businessId, Long userId, String tenantId) {
+ setOrRemove(TRACE_ID, traceId);
+ setOrRemove(BUSINESS_TYPE, businessType);
+ setOrRemove(BUSINESS_ID, businessId);
+ setOrRemove(USER_ID, userId);
+ setOrRemove(TENANT_ID, tenantId);
+ NODE_STACK.remove();
+ }
+
+ private static void setOrRemove(ThreadLocal holder, T value) {
+ if (value == null) {
+ holder.remove();
+ } else {
+ holder.set(value);
+ }
+ }
+
+ public static String getTraceId() {
+ return TRACE_ID.get();
+ }
+
+ public static String getBusinessType() {
+ return BUSINESS_TYPE.get();
+ }
+
+ public static String getBusinessId() {
+ return BUSINESS_ID.get();
+ }
+
+ public static Long getUserId() {
+ return USER_ID.get();
+ }
+
+ public static String getTenantId() {
+ return TENANT_ID.get();
+ }
+
+ public static String currentNodeId() {
+ Deque stack = NODE_STACK.get();
+ return stack == null ? null : stack.peek();
+ }
+
+ public static int depth() {
+ Deque stack = NODE_STACK.get();
+ return stack == null ? 0 : stack.size();
+ }
+
+ public static void pushNode(String nodeId) {
+ Deque stack = NODE_STACK.get();
+ if (stack == null) {
+ stack = new ArrayDeque<>();
+ NODE_STACK.set(stack);
+ }
+ stack.push(nodeId);
+ }
+
+ public static void popNode() {
+ Deque stack = NODE_STACK.get();
+ if (stack == null || stack.isEmpty()) {
+ return;
+ }
+ stack.pop();
+ if (stack.isEmpty()) {
+ NODE_STACK.remove();
+ }
+ }
+
+ /**
+ * 为未来的跨线程上下文传播保留的拷贝入口,避免共享可变栈。
+ */
+ public static Deque copyNodeStack() {
+ Deque stack = NODE_STACK.get();
+ return stack == null ? new ArrayDeque<>() : new ArrayDeque<>(stack);
+ }
+
+ public static void clear() {
+ TRACE_ID.remove();
+ BUSINESS_TYPE.remove();
+ BUSINESS_ID.remove();
+ USER_ID.remove();
+ TENANT_ID.remove();
+ NODE_STACK.remove();
+ }
+}
diff --git a/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/core/TraceNodeTemplate.java b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/core/TraceNodeTemplate.java
new file mode 100644
index 00000000..d60cb637
--- /dev/null
+++ b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/core/TraceNodeTemplate.java
@@ -0,0 +1,159 @@
+package org.ruoyi.common.trace.core;
+
+import lombok.extern.slf4j.Slf4j;
+import org.ruoyi.common.core.utils.StringUtils;
+import org.ruoyi.common.trace.config.TraceProperties;
+import org.ruoyi.common.trace.constant.TraceConstants;
+import org.ruoyi.common.trace.domain.TraceNode;
+import org.ruoyi.common.trace.service.TraceRecordService;
+import org.ruoyi.common.trace.util.TracePayloadUtils;
+
+import java.util.Date;
+import java.util.UUID;
+import java.util.function.Function;
+
+/**
+ * 链路追踪节点模板,封装节点创建、上下文压栈、执行、结束、出栈的标准生命周期。
+ *
+ * 用于同步方法内的 trace 埋点,消除手写 start/finish/pop 的重复代码。
+ * 对于需要异步结束的场景(如流式响应),请使用 {@link DefaultTraceStreamSpan}。
+ *
+ * @see DefaultTraceStreamSpan
+ */
+@Slf4j
+public final class TraceNodeTemplate {
+
+ private TraceNodeTemplate() {
+ }
+
+ /**
+ * 在 trace 节点上下文中执行业务逻辑,成功后使用 outputBuilder 生成输出摘要。
+ *
+ * @param traceRecordService 记录服务
+ * @param traceProperties 配置
+ * @param nodeName 节点名称
+ * @param nodeType 节点类型
+ * @param className 类名
+ * @param methodName 方法名
+ * @param inputPayload 输入摘要
+ * @param action 业务逻辑
+ * @param successOutput 成功时从结果构建输出摘要
+ * @param 业务返回值类型
+ * @return 业务执行结果
+ */
+ public static T withNode(
+ TraceRecordService traceRecordService,
+ TraceProperties traceProperties,
+ String nodeName,
+ String nodeType,
+ String className,
+ String methodName,
+ String inputPayload,
+ NodeAction action,
+ Function successOutput) {
+
+ if (!traceProperties.isEnabled() || StringUtils.isBlank(TraceContext.getTraceId())) {
+ return unwrap(action);
+ }
+
+ String traceId = TraceContext.getTraceId();
+ String nodeId = UUID.randomUUID().toString().replace("-", "");
+ long startMillis = System.currentTimeMillis();
+
+ TraceNode node = buildNode(traceId, nodeId, nodeName, nodeType,
+ className, methodName, inputPayload, startMillis);
+
+ try {
+ traceRecordService.startNode(node);
+ } catch (Exception e) {
+ log.warn("写入 trace 节点失败,traceId={}, nodeId={}", traceId, nodeId, e);
+ return unwrap(action);
+ }
+
+ TraceContext.pushNode(nodeId);
+ try {
+ T result = action.execute();
+ String output = successOutput != null && result != null ? successOutput.apply(result) : null;
+ finishNode(traceRecordService, traceProperties, traceId, nodeId,
+ TraceConstants.STATUS_SUCCESS, null, output, startMillis);
+ return result;
+ } catch (Throwable ex) {
+ finishNode(traceRecordService, traceProperties, traceId, nodeId,
+ TraceConstants.STATUS_ERROR, ex, null, startMillis);
+ throw rethrow(ex);
+ } finally {
+ TraceContext.popNode();
+ }
+ }
+
+ /**
+ * 在 trace 节点上下文中执行业务逻辑(无输出摘要)。
+ */
+ public static T withNode(
+ TraceRecordService traceRecordService,
+ TraceProperties traceProperties,
+ String nodeName,
+ String nodeType,
+ String className,
+ String methodName,
+ String inputPayload,
+ NodeAction action) {
+ return withNode(traceRecordService, traceProperties, nodeName, nodeType,
+ className, methodName, inputPayload, action, null);
+ }
+
+ // ======================== 内部工具方法 ========================
+
+ private static TraceNode buildNode(String traceId, String nodeId, String nodeName,
+ String nodeType, String className, String methodName,
+ String inputPayload, long startMillis) {
+ TraceNode node = new TraceNode();
+ node.setTraceId(traceId);
+ node.setNodeId(nodeId);
+ node.setParentNodeId(TraceContext.currentNodeId());
+ node.setDepth(TraceContext.depth());
+ node.setNodeName(nodeName);
+ node.setNodeType(nodeType);
+ node.setClassName(className);
+ node.setMethodName(methodName);
+ node.setStatus(TraceConstants.STATUS_RUNNING);
+ node.setStartTime(new Date(startMillis));
+ node.setInputPayload(inputPayload);
+ return node;
+ }
+
+ private static void finishNode(TraceRecordService service, TraceProperties props,
+ String traceId, String nodeId, String status,
+ Throwable error, String outputPayload, long startMillis) {
+ try {
+ service.finishNode(traceId, nodeId, status,
+ TracePayloadUtils.error(error, props), outputPayload,
+ new Date(), System.currentTimeMillis() - startMillis);
+ } catch (Exception e) {
+ log.warn("结束 trace 节点失败,traceId={}, nodeId={}", traceId, nodeId, e);
+ }
+ }
+
+ private static T unwrap(NodeAction action) {
+ try {
+ return action.execute();
+ } catch (RuntimeException | Error e) {
+ throw e;
+ } catch (Throwable e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ private static T rethrow(Throwable t) throws T {
+ throw (T) t;
+ }
+
+ /**
+ * 可抛出 Throwable 的业务动作。
+ */
+ @FunctionalInterface
+ public interface NodeAction {
+ T execute() throws Throwable;
+ }
+}
diff --git a/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/core/TraceScope.java b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/core/TraceScope.java
new file mode 100644
index 00000000..df05888c
--- /dev/null
+++ b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/core/TraceScope.java
@@ -0,0 +1,30 @@
+package org.ruoyi.common.trace.core;
+
+/**
+ * Trace 上下文作用域。
+ */
+public final class TraceScope implements AutoCloseable {
+
+ private final String previousTraceId;
+ private final String previousBusinessType;
+ private final String previousBusinessId;
+ private final Long previousUserId;
+ private final String previousTenantId;
+
+ TraceScope(String previousTraceId,
+ String previousBusinessType,
+ String previousBusinessId,
+ Long previousUserId,
+ String previousTenantId) {
+ this.previousTraceId = previousTraceId;
+ this.previousBusinessType = previousBusinessType;
+ this.previousBusinessId = previousBusinessId;
+ this.previousUserId = previousUserId;
+ this.previousTenantId = previousTenantId;
+ }
+
+ @Override
+ public void close() {
+ TraceContext.restore(previousTraceId, previousBusinessType, previousBusinessId, previousUserId, previousTenantId);
+ }
+}
diff --git a/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/core/TraceStreamSpan.java b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/core/TraceStreamSpan.java
new file mode 100644
index 00000000..349238d5
--- /dev/null
+++ b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/core/TraceStreamSpan.java
@@ -0,0 +1,15 @@
+package org.ruoyi.common.trace.core;
+
+/**
+ * 可跨回调结束的流式 trace 节点。
+ */
+public interface TraceStreamSpan {
+
+ void detach();
+
+ void finishSuccess();
+
+ void finishError(Throwable throwable);
+
+ void finishCancelledIfRunning();
+}
diff --git a/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/domain/TraceNode.java b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/domain/TraceNode.java
new file mode 100644
index 00000000..dece4bbb
--- /dev/null
+++ b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/domain/TraceNode.java
@@ -0,0 +1,45 @@
+package org.ruoyi.common.trace.domain;
+
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableLogic;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import org.ruoyi.common.mybatis.core.domain.BaseEntity;
+
+import java.io.Serial;
+import java.util.Date;
+
+/**
+ * 链路追踪节点记录 trace_node。
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("trace_node")
+public class TraceNode extends BaseEntity {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ @TableId(value = "id")
+ private Long id;
+ private String traceId;
+ private String nodeId;
+ private String parentNodeId;
+ private String nodeName;
+ private String nodeType;
+ private Integer depth;
+ private Integer sortOrder;
+ private String className;
+ private String methodName;
+ private String status;
+ private Date startTime;
+ private Date endTime;
+ private Long durationMs;
+ private String errorMessage;
+ private String inputPayload;
+ private String outputPayload;
+ private String metadata;
+ @TableLogic
+ private String delFlag;
+}
diff --git a/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/domain/TraceRun.java b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/domain/TraceRun.java
new file mode 100644
index 00000000..ac7c05c4
--- /dev/null
+++ b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/domain/TraceRun.java
@@ -0,0 +1,40 @@
+package org.ruoyi.common.trace.domain;
+
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableLogic;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import org.ruoyi.common.mybatis.core.domain.BaseEntity;
+
+import java.io.Serial;
+import java.util.Date;
+
+/**
+ * 链路追踪运行记录 trace_run。
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("trace_run")
+public class TraceRun extends BaseEntity {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ @TableId(value = "id")
+ private Long id;
+ private String traceId;
+ private String traceName;
+ private String businessType;
+ private String businessId;
+ private Long userId;
+ private String tenantId;
+ private String status;
+ private Date startTime;
+ private Date endTime;
+ private Long durationMs;
+ private String errorMessage;
+ private String metadata;
+ @TableLogic
+ private String delFlag;
+}
diff --git a/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/domain/bo/TraceRunBo.java b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/domain/bo/TraceRunBo.java
new file mode 100644
index 00000000..bdcc81e2
--- /dev/null
+++ b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/domain/bo/TraceRunBo.java
@@ -0,0 +1,32 @@
+package org.ruoyi.common.trace.domain.bo;
+
+import io.github.linpeilie.annotations.AutoMapper;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import org.ruoyi.common.mybatis.core.domain.BaseEntity;
+import org.ruoyi.common.trace.domain.TraceRun;
+
+import java.util.Date;
+
+/**
+ * 链路追踪运行记录查询对象。
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@AutoMapper(target = TraceRun.class, reverseConvertGenerate = false)
+public class TraceRunBo extends BaseEntity {
+
+ private Long id;
+ private String traceId;
+ private String traceName;
+ private String businessType;
+ private String businessId;
+ private Long userId;
+ private String tenantId;
+ private String status;
+ private Date startTime;
+ private Date endTime;
+ private Long durationMs;
+ private String errorMessage;
+ private String metadata;
+}
diff --git a/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/domain/vo/TraceDetailVo.java b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/domain/vo/TraceDetailVo.java
new file mode 100644
index 00000000..43a07694
--- /dev/null
+++ b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/domain/vo/TraceDetailVo.java
@@ -0,0 +1,83 @@
+package org.ruoyi.common.trace.domain.vo;
+
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 链路追踪详情视图对象。
+ *
+ * 包含运行信息、节点树以及统计摘要。
+ */
+@Data
+public class TraceDetailVo implements Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ private TraceRunVo run;
+ private List nodes;
+ private TraceStatistics statistics;
+
+ /**
+ * 链路追踪统计摘要,帮助快速了解整体执行情况。
+ */
+ @Data
+ public static class TraceStatistics implements Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ /** 总节点数 */
+ private int totalNodes;
+
+ /** 成功节点数 */
+ private int successCount;
+
+ /** 失败节点数 */
+ private int failedCount;
+
+ /** 运行中节点数 */
+ private int runningCount;
+
+ /** 最大调用深度 */
+ private int maxDepth;
+
+ /** 平均耗时 (ms) */
+ private long avgDurationMs;
+
+ /** 总链路耗时 (ms) */
+ private long totalDurationMs;
+
+ /** 慢节点 Top N */
+ private List topSlowNodes = new ArrayList<>();
+ }
+
+ /**
+ * 慢节点简要信息。
+ */
+ @Data
+ public static class SlowNodeInfo implements Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ /** 节点 ID */
+ private String nodeId;
+
+ /** 节点展示名称 */
+ private String nodeDisplayName;
+
+ /** 节点类型中文标签 */
+ private String nodeTypeLabel;
+
+ /** 耗时 (ms) */
+ private long durationMs;
+
+ /** 占总耗时百分比 */
+ private double percentOfTotal;
+ }
+}
diff --git a/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/domain/vo/TraceNodeVo.java b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/domain/vo/TraceNodeVo.java
new file mode 100644
index 00000000..1ac81555
--- /dev/null
+++ b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/domain/vo/TraceNodeVo.java
@@ -0,0 +1,80 @@
+package org.ruoyi.common.trace.domain.vo;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import io.github.linpeilie.annotations.AutoMapper;
+import lombok.Data;
+import org.ruoyi.common.trace.domain.TraceNode;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 链路追踪节点记录视图对象。
+ *
+ * 除实体映射字段外,还提供前端可直接展示的显示标签和解析后的 payload 对象。
+ */
+@Data
+@AutoMapper(target = TraceNode.class)
+public class TraceNodeVo implements Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ private Long id;
+ private String traceId;
+ private String nodeId;
+ private String parentNodeId;
+ private String nodeName;
+ private String nodeType;
+ private Integer depth;
+ private Integer sortOrder;
+ private String className;
+ private String methodName;
+ private String status;
+ private Date startTime;
+ private Date endTime;
+ private Long durationMs;
+ private String errorMessage;
+
+ /** 原始 payload 字符串(兼容旧版),新代码请使用 parsedInput */
+ private String inputPayload;
+
+ /** 原始 payload 字符串(兼容旧版),新代码请使用 parsedOutput */
+ private String outputPayload;
+
+ /** 原始 metadata 字符串(兼容旧版),新代码请使用 parsedMetadata */
+ private String metadata;
+
+ private List children;
+
+ // ======================== 展示用计算字段 ========================
+
+ /** 节点类型中文标签,如 "知识检索"、"LLM 调用" */
+ @JsonProperty("nodeTypeLabel")
+ private String nodeTypeLabel;
+
+ /** 状态中文标签,如 "成功"、"失败"、"运行中" */
+ @JsonProperty("statusLabel")
+ private String statusLabel;
+
+ /** 节点展示名称(中文友好),从 nodeName 转换 */
+ @JsonProperty("nodeDisplayName")
+ private String nodeDisplayName;
+
+ // ======================== 解析后的 payload ========================
+
+ /** input payload 解析为 Map,前端可直接读取结构化字段 */
+ @JsonProperty("parsedInput")
+ private Map parsedInput;
+
+ /** output payload 解析为 Map,前端可直接读取结构化字段 */
+ @JsonProperty("parsedOutput")
+ private Map parsedOutput;
+
+ /** metadata 解析为 Map */
+ @JsonProperty("parsedMetadata")
+ private Map parsedMetadata;
+}
diff --git a/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/domain/vo/TraceRunVo.java b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/domain/vo/TraceRunVo.java
new file mode 100644
index 00000000..db97e06e
--- /dev/null
+++ b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/domain/vo/TraceRunVo.java
@@ -0,0 +1,54 @@
+package org.ruoyi.common.trace.domain.vo;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import io.github.linpeilie.annotations.AutoMapper;
+import lombok.Data;
+import org.ruoyi.common.trace.domain.TraceRun;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.util.Date;
+import java.util.Map;
+
+/**
+ * 链路追踪运行记录视图对象。
+ *
+ * 除实体映射字段外,还提供前端可直接展示的显示标签和解析后的 metadata 对象。
+ */
+@Data
+@AutoMapper(target = TraceRun.class)
+public class TraceRunVo implements Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ private Long id;
+ private String traceId;
+ private String traceName;
+ private String businessType;
+ private String businessId;
+ private Long userId;
+ private String tenantId;
+ private String status;
+ private Date startTime;
+ private Date endTime;
+ private Long durationMs;
+ private String errorMessage;
+
+ /** 原始 metadata 字符串(兼容旧版),新代码请使用 parsedMetadata */
+ private String metadata;
+
+ // ======================== 展示用计算字段 ========================
+
+ /** 状态中文标签,如 "成功"、"失败"、"运行中" */
+ @JsonProperty("statusLabel")
+ private String statusLabel;
+
+ /** 业务类型中文标签,如 "RAG 对话" */
+ @JsonProperty("businessTypeLabel")
+ private String businessTypeLabel;
+
+ /** metadata 解析为 Map */
+ @JsonProperty("parsedMetadata")
+ private Map parsedMetadata;
+}
diff --git a/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/mapper/TraceNodeMapper.java b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/mapper/TraceNodeMapper.java
new file mode 100644
index 00000000..b5100e77
--- /dev/null
+++ b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/mapper/TraceNodeMapper.java
@@ -0,0 +1,11 @@
+package org.ruoyi.common.trace.mapper;
+
+import org.ruoyi.common.mybatis.core.mapper.BaseMapperPlus;
+import org.ruoyi.common.trace.domain.TraceNode;
+import org.ruoyi.common.trace.domain.vo.TraceNodeVo;
+
+/**
+ * 链路追踪节点记录 Mapper。
+ */
+public interface TraceNodeMapper extends BaseMapperPlus {
+}
diff --git a/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/mapper/TraceRunMapper.java b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/mapper/TraceRunMapper.java
new file mode 100644
index 00000000..ae88b5fb
--- /dev/null
+++ b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/mapper/TraceRunMapper.java
@@ -0,0 +1,11 @@
+package org.ruoyi.common.trace.mapper;
+
+import org.ruoyi.common.mybatis.core.mapper.BaseMapperPlus;
+import org.ruoyi.common.trace.domain.TraceRun;
+import org.ruoyi.common.trace.domain.vo.TraceRunVo;
+
+/**
+ * 链路追踪运行记录 Mapper。
+ */
+public interface TraceRunMapper extends BaseMapperPlus {
+}
diff --git a/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/service/TraceRecordService.java b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/service/TraceRecordService.java
new file mode 100644
index 00000000..69a7745d
--- /dev/null
+++ b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/service/TraceRecordService.java
@@ -0,0 +1,35 @@
+package org.ruoyi.common.trace.service;
+
+import org.ruoyi.common.mybatis.core.page.PageQuery;
+import org.ruoyi.common.mybatis.core.page.TableDataInfo;
+import org.ruoyi.common.trace.domain.TraceNode;
+import org.ruoyi.common.trace.domain.TraceRun;
+import org.ruoyi.common.trace.domain.bo.TraceRunBo;
+import org.ruoyi.common.trace.domain.vo.TraceDetailVo;
+import org.ruoyi.common.trace.domain.vo.TraceNodeVo;
+import org.ruoyi.common.trace.domain.vo.TraceRunVo;
+
+import java.util.Date;
+import java.util.List;
+
+/**
+ * 链路追踪记录服务。
+ */
+public interface TraceRecordService {
+
+ void startRun(TraceRun run);
+
+ void finishRun(String traceId, String status, String errorMessage, Date endTime, long durationMs);
+
+ void startNode(TraceNode node);
+
+ void finishNode(String traceId, String nodeId, String status, String errorMessage, String outputPayload, Date endTime, long durationMs);
+
+ TableDataInfo pageRuns(TraceRunBo bo, PageQuery pageQuery);
+
+ TraceRunVo getRun(String traceId);
+
+ List listNodes(String traceId);
+
+ TraceDetailVo getDetail(String traceId);
+}
diff --git a/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/service/impl/TraceRecordServiceImpl.java b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/service/impl/TraceRecordServiceImpl.java
new file mode 100644
index 00000000..1b47173e
--- /dev/null
+++ b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/service/impl/TraceRecordServiceImpl.java
@@ -0,0 +1,256 @@
+package org.ruoyi.common.trace.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.ruoyi.common.core.utils.StringUtils;
+import org.ruoyi.common.mybatis.core.page.PageQuery;
+import org.ruoyi.common.mybatis.core.page.TableDataInfo;
+import org.ruoyi.common.trace.constant.TraceDisplayConstants;
+import org.ruoyi.common.trace.domain.TraceNode;
+import org.ruoyi.common.trace.domain.TraceRun;
+import org.ruoyi.common.trace.domain.bo.TraceRunBo;
+import org.ruoyi.common.trace.domain.vo.TraceDetailVo;
+import org.ruoyi.common.trace.domain.vo.TraceNodeVo;
+import org.ruoyi.common.trace.domain.vo.TraceRunVo;
+import org.ruoyi.common.trace.mapper.TraceNodeMapper;
+import org.ruoyi.common.trace.mapper.TraceRunMapper;
+import org.ruoyi.common.trace.service.TraceRecordService;
+import org.ruoyi.common.trace.util.TracePayloadUtils;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * 链路追踪记录服务实现。
+ *
+ * 在自动映射的基础上,对 VO 进行二次加工:补充展示标签、解析 payload 为结构化对象、计算统计信息。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class TraceRecordServiceImpl implements TraceRecordService {
+
+ private static final int TOP_SLOW_NODES_LIMIT = 5;
+
+ private final TraceRunMapper traceRunMapper;
+ private final TraceNodeMapper traceNodeMapper;
+
+ @Override
+ public void startRun(TraceRun run) {
+ try {
+ traceRunMapper.insert(run);
+ } catch (Exception e) {
+ log.warn("写入 trace run 失败,traceId={}", run == null ? null : run.getTraceId(), e);
+ }
+ }
+
+ @Override
+ public void finishRun(String traceId, String status, String errorMessage, Date endTime, long durationMs) {
+ try {
+ TraceRun update = new TraceRun();
+ update.setStatus(status);
+ update.setErrorMessage(errorMessage);
+ update.setEndTime(endTime);
+ update.setDurationMs(durationMs);
+ traceRunMapper.update(update, Wrappers.lambdaUpdate(TraceRun.class).eq(TraceRun::getTraceId, traceId));
+ } catch (Exception e) {
+ log.warn("更新 trace run 失败,traceId={}", traceId, e);
+ }
+ }
+
+ @Override
+ public void startNode(TraceNode node) {
+ try {
+ traceNodeMapper.insert(node);
+ } catch (Exception e) {
+ log.warn("写入 trace node 失败,traceId={}, nodeId={}",
+ node == null ? null : node.getTraceId(), node == null ? null : node.getNodeId(), e);
+ }
+ }
+
+ @Override
+ public void finishNode(String traceId, String nodeId, String status, String errorMessage, String outputPayload, Date endTime, long durationMs) {
+ try {
+ TraceNode update = new TraceNode();
+ update.setStatus(status);
+ update.setErrorMessage(errorMessage);
+ update.setOutputPayload(outputPayload);
+ update.setEndTime(endTime);
+ update.setDurationMs(durationMs);
+ traceNodeMapper.update(update, Wrappers.lambdaUpdate(TraceNode.class)
+ .eq(TraceNode::getTraceId, traceId)
+ .eq(TraceNode::getNodeId, nodeId));
+ } catch (Exception e) {
+ log.warn("更新 trace node 失败,traceId={}, nodeId={}", traceId, nodeId, e);
+ }
+ }
+
+ @Override
+ public TableDataInfo pageRuns(TraceRunBo bo, PageQuery pageQuery) {
+ LambdaQueryWrapper wrapper = buildRunWrapper(bo == null ? new TraceRunBo() : bo);
+ if (StringUtils.isBlank(pageQuery.getOrderByColumn())) {
+ wrapper.orderByDesc(TraceRun::getStartTime);
+ }
+ Page page = traceRunMapper.selectVoPage(pageQuery.build(), wrapper);
+ if (page.getRecords() != null) {
+ page.getRecords().forEach(this::enrichRunVo);
+ }
+ return TableDataInfo.build(page);
+ }
+
+ @Override
+ public TraceRunVo getRun(String traceId) {
+ TraceRunVo run = traceRunMapper.selectVoOne(Wrappers.lambdaQuery(TraceRun.class).eq(TraceRun::getTraceId, traceId));
+ if (run != null) {
+ enrichRunVo(run);
+ }
+ return run;
+ }
+
+ @Override
+ public List listNodes(String traceId) {
+ List nodes = traceNodeMapper.selectVoList(Wrappers.lambdaQuery(TraceNode.class)
+ .eq(TraceNode::getTraceId, traceId)
+ .orderByAsc(TraceNode::getStartTime)
+ .orderByAsc(TraceNode::getId));
+ if (nodes != null) {
+ nodes.forEach(this::enrichNodeVo);
+ }
+ return nodes;
+ }
+
+ @Override
+ public TraceDetailVo getDetail(String traceId) {
+ TraceDetailVo detail = new TraceDetailVo();
+ TraceRunVo run = getRun(traceId);
+ detail.setRun(run);
+
+ List nodes = listNodes(traceId);
+ if (nodes != null) {
+ nodes.forEach(this::enrichNodeVo);
+ }
+ // 返回扁平列表,前端自行按 parentNodeId 建树
+ detail.setNodes(nodes != null ? nodes : new ArrayList<>());
+
+ // 计算统计信息
+ if (nodes != null) {
+ detail.setStatistics(buildStatistics(nodes, run));
+ }
+ return detail;
+ }
+
+ // ======================== VO 加工 ========================
+
+ /**
+ * 为 TraceRunVo 补充展示标签和解析后的 metadata。
+ */
+ private void enrichRunVo(TraceRunVo vo) {
+ if (vo == null) {
+ return;
+ }
+ vo.setStatusLabel(TraceDisplayConstants.statusLabel(vo.getStatus()));
+ vo.setBusinessTypeLabel(TraceDisplayConstants.businessTypeLabel(vo.getBusinessType()));
+ vo.setParsedMetadata(TracePayloadUtils.parseJsonToMap(vo.getMetadata()));
+ }
+
+ /**
+ * 为 TraceNodeVo 补充展示标签和解析后的 payload。
+ */
+ private void enrichNodeVo(TraceNodeVo vo) {
+ if (vo == null) {
+ return;
+ }
+ vo.setNodeTypeLabel(TraceDisplayConstants.nodeTypeLabel(vo.getNodeType()));
+ vo.setStatusLabel(TraceDisplayConstants.statusLabel(vo.getStatus()));
+ vo.setNodeDisplayName(TraceDisplayConstants.prettifyNodeName(vo.getNodeName()));
+ vo.setParsedInput(TracePayloadUtils.parseJsonToMap(vo.getInputPayload()));
+ vo.setParsedOutput(TracePayloadUtils.parseJsonToMap(vo.getOutputPayload()));
+ vo.setParsedMetadata(TracePayloadUtils.parseJsonToMap(vo.getMetadata()));
+ }
+
+ // ======================== 统计计算 ========================
+ private TraceDetailVo.TraceStatistics buildStatistics(List nodes, TraceRunVo run) {
+ TraceDetailVo.TraceStatistics stats = new TraceDetailVo.TraceStatistics();
+
+ int total = nodes == null ? 0 : nodes.size();
+ int success = 0;
+ int failed = 0;
+ int running = 0;
+ int maxDepth = 0;
+ long totalDuration = 0;
+
+ for (TraceNodeVo node : nodes) {
+ if (node == null) {
+ continue;
+ }
+ if (TraceDisplayConstants.isSuccess(node.getStatus())) {
+ success++;
+ } else if (TraceDisplayConstants.isFailed(node.getStatus())) {
+ failed++;
+ } else if (TraceDisplayConstants.isRunning(node.getStatus())) {
+ running++;
+ }
+
+ int depth = node.getDepth() == null ? 0 : node.getDepth();
+ if (depth > maxDepth) {
+ maxDepth = depth;
+ }
+
+ long dur = node.getDurationMs() == null ? 0 : node.getDurationMs();
+ totalDuration += dur;
+ }
+
+ stats.setTotalNodes(total);
+ stats.setSuccessCount(success);
+ stats.setFailedCount(failed);
+ stats.setRunningCount(running);
+ stats.setMaxDepth(maxDepth);
+ stats.setAvgDurationMs(total > 0 ? totalDuration / total : 0);
+ stats.setTotalDurationMs(run != null && run.getDurationMs() != null ? run.getDurationMs() : totalDuration);
+
+ // 慢节点 Top N
+ long totalMs = stats.getTotalDurationMs();
+ List topSlow = nodes.stream()
+ .filter(n -> n != null && n.getDurationMs() != null && n.getDurationMs() > 0)
+ .sorted(Comparator.comparingLong(TraceNodeVo::getDurationMs).reversed())
+ .limit(TOP_SLOW_NODES_LIMIT)
+ .map(n -> {
+ TraceDetailVo.SlowNodeInfo info = new TraceDetailVo.SlowNodeInfo();
+ info.setNodeId(n.getNodeId());
+ info.setNodeDisplayName(n.getNodeDisplayName() != null ? n.getNodeDisplayName() : n.getNodeName());
+ info.setNodeTypeLabel(n.getNodeTypeLabel());
+ info.setDurationMs(n.getDurationMs());
+ info.setPercentOfTotal(totalMs > 0 ? Math.round(n.getDurationMs() * 1000.0 / totalMs) / 10.0 : 0);
+ return info;
+ })
+ .collect(Collectors.toList());
+ stats.setTopSlowNodes(topSlow);
+
+ return stats;
+ }
+
+ // ======================== 查询辅助 ========================
+
+ private LambdaQueryWrapper buildRunWrapper(TraceRunBo bo) {
+ Map params = bo.getParams();
+ return new LambdaQueryWrapper()
+ .eq(StringUtils.isNotBlank(bo.getTraceId()), TraceRun::getTraceId, bo.getTraceId())
+ .like(StringUtils.isNotBlank(bo.getTraceName()), TraceRun::getTraceName, bo.getTraceName())
+ .eq(StringUtils.isNotBlank(bo.getBusinessType()), TraceRun::getBusinessType, bo.getBusinessType())
+ .eq(StringUtils.isNotBlank(bo.getBusinessId()), TraceRun::getBusinessId, bo.getBusinessId())
+ .eq(StringUtils.isNotBlank(bo.getStatus()), TraceRun::getStatus, bo.getStatus())
+ .eq(bo.getUserId() != null, TraceRun::getUserId, bo.getUserId())
+ .eq(StringUtils.isNotBlank(bo.getTenantId()), TraceRun::getTenantId, bo.getTenantId())
+ .between(params.get("beginTime") != null && params.get("endTime") != null,
+ TraceRun::getStartTime, params.get("beginTime"), params.get("endTime"));
+ }
+
+}
diff --git a/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/util/TracePayloadUtils.java b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/util/TracePayloadUtils.java
new file mode 100644
index 00000000..84497bbe
--- /dev/null
+++ b/ruoyi-common/ruoyi-common-trace/src/main/java/org/ruoyi/common/trace/util/TracePayloadUtils.java
@@ -0,0 +1,100 @@
+package org.ruoyi.common.trace.util;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.module.SimpleModule;
+import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
+import org.ruoyi.common.json.handler.BigNumberSerializer;
+import org.ruoyi.common.trace.config.TraceProperties;
+
+import java.math.BigDecimal;
+import java.math.BigInteger;
+import java.util.Collections;
+import java.util.Map;
+
+/**
+ * Trace payload 序列化、截断与解析工具。
+ */
+public final class TracePayloadUtils {
+
+ private static final ObjectMapper OBJECT_MAPPER;
+
+ static {
+ OBJECT_MAPPER = new ObjectMapper();
+ SimpleModule jsSafeModule = new SimpleModule("js-safe-numbers");
+ jsSafeModule.addSerializer(Long.class, BigNumberSerializer.INSTANCE);
+ jsSafeModule.addSerializer(Long.TYPE, BigNumberSerializer.INSTANCE);
+ jsSafeModule.addSerializer(BigInteger.class, BigNumberSerializer.INSTANCE);
+ jsSafeModule.addSerializer(BigDecimal.class, ToStringSerializer.instance);
+ OBJECT_MAPPER.registerModule(jsSafeModule);
+ }
+
+ private TracePayloadUtils() {
+ }
+
+ public static String input(Object value, TraceProperties properties) {
+ if (value == null || properties == null || !properties.getPayload().isRecordDetail()) {
+ return null;
+ }
+ return truncate(asString(value), properties.getPayload().getMaxInputLength());
+ }
+
+ public static String output(Object value, TraceProperties properties) {
+ if (value == null || properties == null || !properties.getPayload().isRecordDetail()) {
+ return null;
+ }
+ return truncate(asString(value), properties.getPayload().getMaxOutputLength());
+ }
+
+ public static String error(Throwable throwable, TraceProperties properties) {
+ if (throwable == null) {
+ return null;
+ }
+ int maxLength = properties == null ? 1000 : properties.getPayload().getMaxErrorLength();
+ String message = throwable.getClass().getSimpleName() + ": " + (throwable.getMessage() == null ? "" : throwable.getMessage());
+ return truncate(message, maxLength);
+ }
+
+ public static String toJson(Object value) {
+ if (value == null) {
+ return null;
+ }
+ try {
+ return OBJECT_MAPPER.writeValueAsString(value);
+ } catch (JsonProcessingException e) {
+ return "{\"summary\":\"payload serialization failed\"}";
+ }
+ }
+
+ public static String truncate(String value, int maxLength) {
+ if (value == null) {
+ return null;
+ }
+ if (maxLength <= 0 || value.length() <= maxLength) {
+ return value;
+ }
+ return value.substring(0, maxLength);
+ }
+
+ /**
+ * 将 JSON 字符串解析为 Map,用于前端展示结构化数据。
+ * 解析失败时返回空 Map,不影响主流程。
+ */
+ @SuppressWarnings("unchecked")
+ public static Map parseJsonToMap(String json) {
+ if (json == null || json.isBlank()) {
+ return Collections.emptyMap();
+ }
+ try {
+ return OBJECT_MAPPER.readValue(json, new TypeReference