Explorar el Código

feat:1.接口调用日志记录http调用外挂程序的接口

wangkun hace 1 semana
padre
commit
3b2e458b52

+ 144 - 0
internal/audit/span.go

@@ -0,0 +1,144 @@
+package audit
+
+import (
+	"context"
+	"database/sql"
+	"encoding/json"
+	"time"
+
+	"udeServer/internal/log"
+	udeModel "udeServer/internal/model"
+)
+
+// SpanRecorder records type=2 interface-log entries for the outbound remote
+// HTTP calls to the agent program (外挂程序): /path/lists (ListDirChildren),
+// /api/fuseMount and /api/fuseUnMount. Ported from sdf-serve's
+// SpanRecorder.DoSimple: the entry links to the inbound request (type=1,
+// produced by LogMiddleware) through parent_span_id, so one trace shows the
+// whole local-request → remote-call chain.
+type SpanRecorder struct {
+	LogModel    udeModel.TSystemLogModel
+	ConfigModel udeModel.TConfigModel
+	Service     string
+}
+
+// NewSpanRecorder builds the shared recorder; models and service name come
+// from ServiceContext at startup.
+func NewSpanRecorder(logModel udeModel.TSystemLogModel, configModel udeModel.TConfigModel, service string) *SpanRecorder {
+	return &SpanRecorder{
+		LogModel:    logModel,
+		ConfigModel: configModel,
+		Service:     service,
+	}
+}
+
+// DoSimple wraps a remote-call closure and records one type=2 entry:
+//   - ctx must carry the inbound trace/span (it does: the middleware injected
+//     it before the handler ran);
+//   - a fresh child span id is generated for this remote call;
+//   - reqBody is the outbound payload digest/uploaded before the call runs
+//     (for GET calls the caller passes a {method,url} description so the
+//     digest still identifies the outbound request);
+//   - remoteURL is stored in uri_path for display;
+//   - fn performs the call and returns (result, code, err): code is the
+//     remote HTTP status (0 when the call failed at transport level).
+func DoSimple[T any](sr *SpanRecorder, ctx context.Context, methodName, remoteURL string, reqBody []byte, fn func() (T, int64, error)) (T, error) {
+	// 1.获取上下文的 traceId、父 SpanId(即入站请求的 SpanId)
+	ctx, traceID, parentSpanID, _, _, appKey := EnsureTraceAndSpanID(ctx)
+
+	// 2.生成新的 SpanId 并写回上下文,保持调用链可继续嵌套
+	spanID := NewUUID()
+	nextCtx := context.WithValue(ctx, SpanKey, spanID)
+
+	// 3.请求体摘要与原文上传(MinIO 未就绪时降级为仅摘要)
+	client, logConfig := GetGlobalConfig(sr.ConfigModel)
+	reqDigestStr, reqUrl, reqErr := UploadAndDigest(reqBody, traceID, spanID, "[Device-Request]", client, "application/json")
+	if reqErr != nil {
+		log.RecordError(nextCtx, reqErr)
+	}
+
+	// 4.执行闭包并计时
+	start := time.Now()
+	result, code, callErr := fn()
+	duration := time.Since(start).Milliseconds()
+
+	// 5.响应体摘要与原文上传
+	var respBody []byte
+	if b, mErr := json.Marshal(result); mErr == nil {
+		respBody = b
+	}
+	respDigestStr, respUrl, respErr := UploadAndDigest(respBody, traceID, spanID, "[Device-Response]", client, "application/json")
+	if respErr != nil {
+		log.RecordError(nextCtx, respErr)
+	}
+
+	// 6.构造 type=2 日志对象(UriPath 存远程完整 URL,便于定位四个外挂接口调用)
+	if len(remoteURL) > 255 { // uri_path varchar(255),截断防插入失败
+		remoteURL = remoteURL[:255]
+	}
+	entry := &udeModel.TSystemLog{
+		Iden:                NewUUID(),
+		TraceId:             traceID,
+		SpanId:              spanID,
+		ParentSpanId:        sql.NullString{String: parentSpanID, Valid: parentSpanID != ""}, //顶级链路父SpanId为""时填写null
+		Type:                InterfaceLog,
+		ServiceName:         sr.Service,
+		UriPath:             sql.NullString{String: remoteURL, Valid: remoteURL != ""},
+		Method:              methodName,
+		Level:               ChooseLevelByInterfaceCode(code, callErr),
+		AppKey:              sql.NullString{String: appKey, Valid: true},
+		RequestBodyDigest:   reqDigestStr,
+		RequestBodyFileKey:  reqUrl,
+		ResponseCode:        code,
+		ResponseBodyDigest:  respDigestStr,
+		ResponseBodyFileKey: respUrl,
+		DurationMs:          duration,
+		AuditStatus:         UnAudited,
+	}
+
+	// 7.填充 ErrMsg
+	if callErr != nil {
+		entry.ErrorMessage = sql.NullString{String: callErr.Error(), Valid: true}
+	} else {
+		entry.ErrorMessage = sql.NullString{Valid: false}
+	}
+
+	// 8.填充 Hmac 值;密钥为空时用空串计算,保持列非空
+	macBuffer := make([]byte, 0, 32)
+	if mac, hErr := CalculateHMAC(entry, logConfig.HmacSecret, macBuffer); hErr == nil {
+		entry.HMac = mac
+	} else {
+		log.RecordError(nextCtx, hErr)
+	}
+	entry.CreatedAt = time.Now()
+
+	// 9.按配置库中 log_record_level 过滤后异步入库
+	if SaveToDBByLogLevel(logConfig.LogRecordLevel, entry.Level) {
+		go AsyncInsertLog(sr.LogModel, entry)
+	}
+
+	return result, callErr
+}
+
+// ChooseLevelByInterfaceCode maps a remote-call outcome to a log level:
+// call error or agent 5xx → ERROR; 2xx → INFO; other (4xx/3xx/0) → WARN.
+func ChooseLevelByInterfaceCode(code int64, callErr error) string {
+	switch {
+	case callErr != nil, code >= 500:
+		return "ERROR"
+	case code >= 200 && code < 300:
+		return "INFO"
+	default:
+		return "WARN"
+	}
+}
+
+// BuildRemoteGetRequest renders the outbound GET request as JSON so the
+// digest identifies the actual remote request (GET has no body).
+func BuildRemoteGetRequest(method, fullURL string) []byte {
+	b, err := json.Marshal(map[string]string{"method": method, "url": fullURL})
+	if err != nil {
+		return nil
+	}
+	return b
+}

+ 18 - 11
internal/logic/createservicelogic.go

@@ -13,6 +13,7 @@ import (
 	"path"
 	"strings"
 	"time"
+	"udeServer/internal/audit"
 
 	"udeServer/internal/model"
 	"udeServer/internal/svc"
@@ -278,18 +279,24 @@ func (l *CreateServiceLogic) notifyFuseMount(svc *model.TService, dirs []string)
 			return fmt.Errorf("序列化挂载请求失败: %w", err)
 		}
 
-		resp, err := doFuseRequest(fuseMountClient, svc.Iden.String, http.MethodPost, baseURL, bytes.NewReader(body), "application/json")
-		if err != nil {
-			return fmt.Errorf("请求 %s 失败(外挂程序未启动?): %w", baseURL, err)
+		// 发起 POST(DoSimple 记录 type=2 接口日志,父 Span 指向本次入站请求)
+		if _, callErr := audit.DoSimple(l.svcCtx.SpanRecorder, l.ctx, "FuseMount", baseURL, body,
+			func() (map[string]interface{}, int64, error) {
+				resp, rerr := fuseMountClient.Post(baseURL, "application/json", bytes.NewReader(body))
+				if rerr != nil {
+					return nil, 0, fmt.Errorf("请求 %s 失败(外挂程序未启动?): %w", baseURL, rerr)
+				}
+				// 读到 EOF 保证底层 TCP 连接池可以复用 Keep-Alive
+				raw, _ := io.ReadAll(resp.Body)
+				resp.Body.Close()
+				status := int64(resp.StatusCode)
+				if status >= http.StatusBadRequest {
+					return nil, status, fmt.Errorf("挂载目录 %s 失败,外挂程序返回 HTTP %d: %s", mount, status, string(raw))
+				}
+				return map[string]interface{}{"status": status, "body": string(raw)}, status, nil
+			}); callErr != nil {
+			return callErr
 		}
-		if resp.StatusCode >= http.StatusBadRequest {
-			snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
-			resp.Body.Close()
-			return fmt.Errorf("挂载目录 %s 失败,外挂程序返回 HTTP %d: %s", mount, resp.StatusCode, string(snippet))
-		}
-		// 2. 成功处理分支:必须将剩余的 body 数据读入黑洞 (io.Discard),以保证底层 TCP 连接池可以复用 Keep-Alive
-		_, _ = io.Copy(io.Discard, resp.Body)
-		resp.Body.Close()
 		l.Infof("fuse mount ok: %s -> %s", mount, mount+"-cipher")
 	}
 	return nil

+ 18 - 12
internal/logic/deleteservicelogic.go

@@ -12,6 +12,7 @@ import (
 	"path"
 	"strings"
 
+	"udeServer/internal/audit"
 	"udeServer/internal/model"
 	"udeServer/internal/svc"
 	"udeServer/internal/types"
@@ -110,20 +111,25 @@ func (l *DeleteServiceLogic) notifyFuseUnmount(svc *model.TService) string {
 		q.Set("mount", cleanMount)
 		reqURL.RawQuery = q.Encode()
 		l.Infof("fuse unmount forward: GET %s", reqURL.String()) // 打出转发 URL 便于排查
-		// X-TOKEN = 该服务的接入标识 iden(外挂侧与自己环境变量 TOKEN 配的值比对)
-		resp, rerr := doFuseRequest(fuseMountClient, svc.Iden.String, http.MethodGet, reqURL.String(), nil, "")
-		if rerr != nil {
-			msgs = append(msgs, fmt.Sprintf("卸载目录 %s 失败(外挂程序未启动?): %v", cleanMount, rerr))
+		// DoSimple 记录 type=2 接口日志,父 Span 指向本次入站请求(GET 用 url 描述请求原文)
+		if _, callErr := audit.DoSimple(l.svcCtx.SpanRecorder, l.ctx, "FuseUnMount", reqURL.String(),
+			audit.BuildRemoteGetRequest(http.MethodGet, reqURL.String()),
+			func() (map[string]interface{}, int64, error) {
+				resp, rerr := fuseMountClient.Get(reqURL.String()) // 复用挂载逻辑里的 HTTP 客户端(同包变量)
+				if rerr != nil {
+					return nil, 0, fmt.Errorf("卸载目录 %s 失败(外挂程序未启动?): %w", cleanMount, rerr)
+				}
+				raw, _ := io.ReadAll(resp.Body) // 读到 EOF 保证 TCP 连接复用 Keep-Alive
+				resp.Body.Close()
+				status := int64(resp.StatusCode)
+				if status >= http.StatusBadRequest {
+					return nil, status, fmt.Errorf("卸载目录 %s 失败,外挂程序返回 HTTP %d: %s", cleanMount, status, string(raw))
+				}
+				return map[string]interface{}{"status": status, "body": string(raw)}, status, nil
+			}); callErr != nil {
+			msgs = append(msgs, callErr.Error())
 			continue
 		}
-		if resp.StatusCode >= http.StatusBadRequest {
-			snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 256)) // 带出外挂错误内容(前 256 字节)
-			resp.Body.Close()
-			msgs = append(msgs, fmt.Sprintf("卸载目录 %s 失败,外挂程序返回 HTTP %d: %s", cleanMount, resp.StatusCode, string(snippet)))
-			continue
-		}
-		_, _ = io.Copy(io.Discard, resp.Body) // 读入"黑洞"保证 TCP 连接复用 Keep-Alive
-		resp.Body.Close()
 		l.Infof("fuse unmount ok: %s", cleanMount)
 	}
 	if len(msgs) == 0 {

+ 29 - 20
internal/logic/listdirchildrenlogic.go

@@ -20,6 +20,7 @@ import (
 	"strings"
 
 	"udeServer/internal/apierr"
+	"udeServer/internal/audit"
 	"udeServer/internal/svc"
 	"udeServer/internal/types"
 
@@ -80,7 +81,7 @@ func (l *ListDirChildrenLogic) ListDirChildren(req *types.ListDirChildrenReq) (*
 	// path.Clean 而非 filepath.Clean: 该路径发给远程 Linux 外挂程序,
 	// 必须保持 / 分隔符;filepath 在 Windows 上跑 udeServer 时会把 / 转成 \
 	dir := path.Clean(p)
-	return l.notifyListDirChildren(ip, port, dir, req.Iden)
+	return l.notifyListDirChildren(ip, port, dir)
 }
 
 // notifyFuseMount 落库成功后回调外挂程序(svc.Ip:svc.Port)的 /api/fuseMount 接口,
@@ -90,7 +91,7 @@ func (l *ListDirChildrenLogic) ListDirChildren(req *types.ListDirChildrenReq) (*
 //	{ "backing": "<保护目录>-cipher", "mount": "<保护目录>", "ikey": "<密钥内容>" }
 //
 // ikey 取自 t_key.content(按服务配置的密钥标识 keyIden 查询)。
-func (l *ListDirChildrenLogic) notifyListDirChildren(ip, port, dirs, iden string) (*types.DirChildrenResp, error) {
+func (l *ListDirChildrenLogic) notifyListDirChildren(ip, port, dirs string) (*types.DirChildrenResp, error) {
 	if ip == "" || port == "" {
 		return nil, fmt.Errorf("外挂服务 IP/端口为空,无法下发挂载")
 	}
@@ -109,26 +110,34 @@ func (l *ListDirChildrenLogic) notifyListDirChildren(ip, port, dirs, iden string
 	q := reqURL.Query()
 	q.Set("path", cleanPath)
 	reqURL.RawQuery = q.Encode() // 自动处理 URL 编码,生成如 ?path=%2Fdata%2Ftest
-	// 发起 GET 请求
+	// 发起 GET 请求(DoSimple 记录 type=2 接口日志,父 Span 指向本次入站请求)
 	finalURL := reqURL.String()
 	l.Infof("URL,方便排查参数/编码问题list dirs forward: GET %s", finalURL) // 打出转发 URL,方便排查参数/编码问题
-	resp, err := doFuseRequest(fuseMountClient, iden, http.MethodGet, finalURL, nil, "")
-	if err != nil {
-		return nil, fmt.Errorf("请求 %s 失败: %w", baseURL, err)
-	}
-	defer resp.Body.Close() // 确保无论如何都能释放连接
-	// 错误处理
-	// 5. 检查 HTTP 状态码
-	if resp.StatusCode != http.StatusOK {
-		// 把外挂程序返回的错误内容带出来(前 256 字节),便于定位 400 的具体原因
-		snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
-		return nil, fmt.Errorf("获取目录失败, HTTP 状态码: %d, 外挂程序返回: %s", resp.StatusCode, string(snippet))
-	}
-	// 6. 解析 JSON 响应
-	var result *types.DirChildrenResp
-	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
-		return nil, fmt.Errorf("解析JSON响应失败: %w", err)
+	reqDesc := audit.BuildRemoteGetRequest(http.MethodGet, finalURL)
+	result, callErr := audit.DoSimple(l.svcCtx.SpanRecorder, l.ctx, "ListDirChildren", finalURL, reqDesc,
+		func() (*types.DirChildrenResp, int64, error) {
+			resp, rerr := fuseMountClient.Get(finalURL)
+			if rerr != nil {
+				return nil, 0, fmt.Errorf("请求 %s 失败: %w", baseURL, rerr)
+			}
+			defer resp.Body.Close() // 确保无论如何都能释放连接
+			status := int64(resp.StatusCode)
+			// 检查 HTTP 状态码
+			if resp.StatusCode != http.StatusOK {
+				// 把外挂程序返回的错误内容带出来(前 256 字节),便于定位 400 的具体原因
+				snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
+				return nil, status, fmt.Errorf("获取目录失败, HTTP 状态码: %d, 外挂程序返回: %s", resp.StatusCode, string(snippet))
+			}
+			// 解析 JSON 响应
+			var parsed *types.DirChildrenResp
+			if perr := json.NewDecoder(resp.Body).Decode(&parsed); perr != nil {
+				return nil, status, fmt.Errorf("解析JSON响应失败: %w", perr)
+			}
+			return parsed, status, nil
+		})
+	if callErr != nil {
+		return nil, callErr
 	}
 	l.Infof("fuse 请求成功, path: %s", cleanPath)
-	return result, err
+	return result, nil
 }

+ 39 - 26
internal/logic/updateservicelogic.go

@@ -15,6 +15,7 @@ import (
 	"strings"
 
 	"udeServer/internal/apierr"
+	"udeServer/internal/audit"
 	"udeServer/internal/model"
 	"udeServer/internal/svc"
 	"udeServer/internal/types"
@@ -164,8 +165,7 @@ func (l *UpdateServiceLogic) applyMountChanges(svc *model.TService, oldDirs, new
 			msgs = append(msgs, "原外挂 IP/端口为空,无法联动卸载,旧目录可能仍处于挂载状态")
 		} else {
 			for _, dir := range toUnmount {
-				// X-TOKEN 用【旧】iden:卸载发往旧机器,旧机器 TOKEN 认的还是改前的接入标识
-				if w := l.fuseUnmountOne(oldIp, oldPort, dir, svc.Iden.String); w != "" {
+				if w := l.fuseUnmountOne(oldIp, oldPort, dir); w != "" {
 					msgs = append(msgs, w)
 				}
 			}
@@ -182,7 +182,7 @@ func (l *UpdateServiceLogic) applyMountChanges(svc *model.TService, oldDirs, new
 				msgs = append(msgs, fmt.Sprintf("联动挂载失败: %v", err))
 			} else {
 				for _, dir := range newDirs {
-					if w := l.fuseMountOne(req.Ip, req.Port, dir, ikey, req.Iden); w != "" {
+					if w := l.fuseMountOne(req.Ip, req.Port, dir, ikey); w != "" {
 						msgs = append(msgs, w)
 					}
 				}
@@ -209,7 +209,7 @@ func (l *UpdateServiceLogic) applyMountChanges(svc *model.TService, oldDirs, new
 
 // fuseMountOne 通知外挂程序挂载单个目录。成功返回空串,失败返回警告文案。
 // 请求体与新增服务完全同款:{backing: 目录+"-cipher", mount: 目录, ikey: 密钥内容}
-func (l *UpdateServiceLogic) fuseMountOne(ip string, port int64, mount, ikey, iden string) string {
+func (l *UpdateServiceLogic) fuseMountOne(ip string, port int64, mount, ikey string) string {
 	// path.Clean(非 filepath!):路径要发给远程 Linux 外挂程序,必须保持 / 分隔符
 	dirLinuxPath := path.Clean(mount)
 	payload := map[string]interface{}{
@@ -222,24 +222,30 @@ func (l *UpdateServiceLogic) fuseMountOne(ip string, port int64, mount, ikey, id
 		return fmt.Sprintf("序列化挂载请求失败: %v", err)
 	}
 	baseURL := fmt.Sprintf("http://%s:%d/api/fuseMount", ip, port) // %d:port 是 int64
-	resp, err := doFuseRequest(fuseMountClient, iden, http.MethodPost, baseURL, bytes.NewReader(body), "application/json")
-	if err != nil {
-		return fmt.Sprintf("挂载目录 %s 失败(外挂程序未启动?): %v", dirLinuxPath, err)
-	}
-	if resp.StatusCode >= http.StatusBadRequest {
-		snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 256)) // 带出外挂错误内容(前 256 字节)
-		resp.Body.Close()
-		return fmt.Sprintf("挂载目录 %s 失败,外挂程序返回 HTTP %d: %s", dirLinuxPath, resp.StatusCode, string(snippet))
+	// DoSimple 记录 type=2 接口日志,父 Span 指向本次入站请求
+	if _, callErr := audit.DoSimple(l.svcCtx.SpanRecorder, l.ctx, "FuseMount", baseURL, body,
+		func() (map[string]interface{}, int64, error) {
+			resp, rerr := fuseMountClient.Post(baseURL, "application/json", bytes.NewReader(body))
+			if rerr != nil {
+				return nil, 0, fmt.Errorf("挂载目录 %s 失败(外挂程序未启动?): %w", dirLinuxPath, rerr)
+			}
+			raw, _ := io.ReadAll(resp.Body) // 读到 EOF 保证 TCP 连接复用 Keep-Alive
+			resp.Body.Close()
+			status := int64(resp.StatusCode)
+			if status >= http.StatusBadRequest {
+				return nil, status, fmt.Errorf("挂载目录 %s 失败,外挂程序返回 HTTP %d: %s", dirLinuxPath, status, string(raw))
+			}
+			return map[string]interface{}{"status": status, "body": string(raw)}, status, nil
+		}); callErr != nil {
+		return callErr.Error()
 	}
-	_, _ = io.Copy(io.Discard, resp.Body) // 读入"黑洞"保证 TCP 连接复用 Keep-Alive
-	resp.Body.Close()
 	l.Infof("fuse mount ok: %s -> %s", dirLinuxPath, dirLinuxPath+"-cipher")
 	return ""
 }
 
 // fuseUnmountOne 通知外挂程序卸载单个挂载点。成功返回空串,失败返回警告文案。
 // 卸载接口幂等:挂载点不在外挂注册表中也返回成功,所以重存/多卸都零成本。
-func (l *UpdateServiceLogic) fuseUnmountOne(ip string, port int64, mount, iden string) string {
+func (l *UpdateServiceLogic) fuseUnmountOne(ip string, port int64, mount string) string {
 	cleanMount := path.Clean(mount)                                  // 同上,发给 Linux 必须保持 / 分隔符
 	baseURL := fmt.Sprintf("http://%s:%d/api/fuseUnMount", ip, port) // %d:port 是 int64
 	reqURL, uerr := url.Parse(baseURL)
@@ -248,18 +254,25 @@ func (l *UpdateServiceLogic) fuseUnmountOne(ip string, port int64, mount, iden s
 	}
 	q := reqURL.Query()
 	q.Set("mount", cleanMount) // q.Encode() 自动做 URL 编码(/ → %2F),外挂侧 Query().Get 自动解码还原
-	reqURL.RawQuery = q.Encode()
-	resp, err := doFuseRequest(fuseMountClient, iden, http.MethodGet, reqURL.String(), nil, "") // 复用挂载逻辑的 HTTP 客户端(同包包级变量)
-	if err != nil {
-		return fmt.Sprintf("卸载目录 %s 失败(外挂程序未启动?): %v", cleanMount, err)
-	}
-	if resp.StatusCode >= http.StatusBadRequest {
-		snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
-		resp.Body.Close()
-		return fmt.Sprintf("卸载目录 %s 失败,外挂程序返回 HTTP %d: %s", cleanMount, resp.StatusCode, string(snippet))
+	finalURL := reqURL.String()
+	// DoSimple 记录 type=2 接口日志,父 Span 指向本次入站请求(GET 请求用 url 描述请求原文)
+	if _, callErr := audit.DoSimple(l.svcCtx.SpanRecorder, l.ctx, "FuseUnMount", finalURL,
+		audit.BuildRemoteGetRequest(http.MethodGet, finalURL),
+		func() (map[string]interface{}, int64, error) {
+			resp, rerr := fuseMountClient.Get(finalURL)
+			if rerr != nil {
+				return nil, 0, fmt.Errorf("卸载目录 %s 失败(外挂程序未启动?): %w", cleanMount, rerr)
+			}
+			raw, _ := io.ReadAll(resp.Body) // 读到 EOF 保证 TCP 连接复用 Keep-Alive
+			resp.Body.Close()
+			status := int64(resp.StatusCode)
+			if status >= http.StatusBadRequest {
+				return nil, status, fmt.Errorf("卸载目录 %s 失败,外挂程序返回 HTTP %d: %s", cleanMount, status, string(raw))
+			}
+			return map[string]interface{}{"status": status, "body": string(raw)}, status, nil
+		}); callErr != nil {
+		return callErr.Error()
 	}
-	_, _ = io.Copy(io.Discard, resp.Body)
-	resp.Body.Close()
 	l.Infof("fuse unmount ok: %s", cleanMount)
 	return ""
 }

+ 4 - 0
internal/svc/servicecontext.go

@@ -28,6 +28,7 @@ type ServiceContext struct {
 	MinioClient           *audit.MinioClient
 	LogConfigManager      *audit.LogConfigManager
 	LogMiddleware         rest.Middleware
+	SpanRecorder          *audit.SpanRecorder
 }
 
 func NewServiceContext(c config.Config) *ServiceContext {
@@ -41,6 +42,8 @@ func NewServiceContext(c config.Config) *ServiceContext {
 		minioClient != nil, logConfig.LogRecordLevel, logConfig.HmacSecret != "")
 
 	logMiddleware := audit.NewLogMiddleware().Handle(systemLogModel, configModel, c.Name)
+	// type=2 接口日志:四个远程外挂接口调用的 Recorder(见 internal/audit/span.go)
+	spanRecorder := audit.NewSpanRecorder(systemLogModel, configModel, c.Name)
 
 	return &ServiceContext{
 		Config:         c,
@@ -56,5 +59,6 @@ func NewServiceContext(c config.Config) *ServiceContext {
 		MinioClient:           minioClient,
 		LogConfigManager:      logConfig,
 		LogMiddleware:         logMiddleware,
+		SpanRecorder:          spanRecorder,
 	}
 }