package trace import ( "bytes" "encoding/json" "fmt" "io" "mime" "mime/multipart" "net/http" "os" "path/filepath" "sort" "strings" "sync/atomic" "time" "douyu_go/internal/constants" "douyu_go/internal/logger" "douyu_go/internal/types" "douyu_go/internal/utils" ) // Recorder HTTP 请求追踪记录器。 type Recorder struct { Enabled bool MaxBodyBytes int writer *logger.AsyncLogWriter filePath string seq uint64 } type entry struct { Seq uint64 Time string Label string Method string URL string Path string Status int Cost time.Duration ReqHeader map[string][]string RespHeader map[string][]string ReqBody string RespBody string APIError int APIMessage string ErrorText string MaxBodyBytes int } // InitTrace 初始化追踪记录器。 func InitTrace(logDir string, cfg types.TraceConfig) (*Recorder, func()) { if !cfg.Enabled { return &Recorder{Enabled: false}, func() {} } if logDir == "" { logDir = constants.DefaultLogDir } if err := os.MkdirAll(logDir, 0755); err != nil { logger.LogEvent("Trace", "error", "trace 目录创建失败,已关闭 trace: %v", err) return &Recorder{Enabled: false}, func() {} } path := filepath.Join(logDir, "douyu_trace_"+time.Now().In(constants.CST).Format("20060102_150405")+".log") file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) if err != nil { logger.LogEvent("Trace", "error", "trace 文件创建失败,已关闭 trace: %v", err) return &Recorder{Enabled: false}, func() {} } recorder := &Recorder{ Enabled: true, MaxBodyBytes: cfg.MaxBodyBytes, writer: logger.NewAsyncLogWriter(file, 2048), filePath: path, } if cfg.MaxBodyBytes > 0 { logger.LogEvent("Trace", "warn", "trace 已启用: %s,单段正文最多保留 %d 字节;请求头不脱敏,响应 Set-Cookie 隐藏值", path, cfg.MaxBodyBytes) } else { logger.LogEvent("Trace", "warn", "trace 已启用: %s,全量保留请求和响应;请求头不脱敏,响应 Set-Cookie 隐藏值", path) } return recorder, func() { _ = recorder.Close() _ = file.Close() } } // Close 关闭记录器。 func (t *Recorder) Close() error { if t == nil || t.writer == nil { return nil } return t.writer.Close() } // Record 记录一次请求。 func (t *Recorder) Record(label string, req *http.Request, status int, cost time.Duration, reqBody, respBody []byte, errText string, apiError int, apiMsg string) { t.RecordResponse(label, req, nil, status, cost, reqBody, respBody, errText, apiError, apiMsg) } // RecordResponse 记录一次请求和响应。 func (t *Recorder) RecordResponse(label string, req *http.Request, resp *http.Response, status int, cost time.Duration, reqBody, respBody []byte, errText string, apiError int, apiMsg string) { if t == nil || !t.Enabled || req == nil || req.URL == nil { return } if label == "服务时间" && status == http.StatusOK && errText == "" && apiError == 0 { return } e := entry{ Seq: atomic.AddUint64(&t.seq, 1), Time: time.Now().In(constants.CST).Format("2006-01-02 15:04:05.000"), Label: label, Method: req.Method, URL: req.URL.Redacted(), Path: req.URL.Path, Status: status, Cost: cost, ReqHeader: sanitizeHeader(req.Header), RespHeader: sanitizeResponseHeader(resp), ReqBody: sanitizeBodyForTrace(reqBody, req.Header.Get("Content-Type"), t.MaxBodyBytes), RespBody: sanitizeResponseBody(respBody, t.MaxBodyBytes), APIError: apiError, APIMessage: apiMsg, ErrorText: errText, MaxBodyBytes: t.MaxBodyBytes, } _, _ = t.writer.Write([]byte(formatEntry(e))) } // RequestBody 读取请求体用于追踪。 func (t *Recorder) RequestBody(req *http.Request) []byte { if t == nil || !t.Enabled || req == nil || req.GetBody == nil { return nil } body, err := req.GetBody() if err != nil { return nil } defer body.Close() if t.MaxBodyBytes > 0 { data, _ := io.ReadAll(io.LimitReader(body, int64(t.MaxBodyBytes)+1)) return data } data, _ := io.ReadAll(body) return data } func sanitizeHeader(header http.Header) map[string][]string { out := make(map[string][]string, len(header)) for key, values := range header { canonical := http.CanonicalHeaderKey(key) copied := make([]string, len(values)) copy(copied, values) out[canonical] = copied } return out } func sanitizeResponseHeader(resp *http.Response) map[string][]string { if resp == nil { return nil } out := sanitizeHeader(resp.Header) if len(out) == 0 { return out } if _, ok := out["Set-Cookie"]; ok { out["Set-Cookie"] = summarizeSetCookies(resp) } return out } func summarizeSetCookies(resp *http.Response) []string { if resp == nil { return nil } cookies := resp.Cookies() if len(cookies) == 0 { values := resp.Header.Values("Set-Cookie") summary := make([]string, 0, len(values)) for range values { summary = append(summary, "") } return summary } summary := make([]string, 0, len(cookies)) for _, ck := range cookies { if ck == nil || ck.Name == "" { continue } parts := []string{ck.Name + "="} if ck.Path != "" { parts = append(parts, "Path="+ck.Path) } if ck.Domain != "" { parts = append(parts, "Domain="+ck.Domain) } if ck.MaxAge != 0 { parts = append(parts, fmt.Sprintf("Max-Age=%d", ck.MaxAge)) } if !ck.Expires.IsZero() { parts = append(parts, "Expires="+ck.Expires.In(constants.CST).Format(time.RFC3339)) } if ck.HttpOnly { parts = append(parts, "HttpOnly") } if ck.Secure { parts = append(parts, "Secure") } summary = append(summary, strings.Join(parts, "; ")) } if len(summary) == 0 { return []string{""} } sort.Strings(summary) return summary } func sanitizeBodyForTrace(body []byte, contentType string, max int) string { if len(body) == 0 { return "" } mediaType, params, _ := mime.ParseMediaType(contentType) if strings.HasPrefix(mediaType, "multipart/") { boundary := params["boundary"] if boundary != "" { return sanitizeMultipartBody(body, boundary, max) } } return sanitizeTextBody(body, max) } func sanitizeResponseBody(body []byte, max int) string { return sanitizeTextBody(body, max) } func sanitizeTextBody(body []byte, max int) string { raw := string(body) if pretty, ok := prettyJSON(raw); ok { return truncateText(pretty, max) } return truncateText(raw, max) } func sanitizeMultipartBody(body []byte, boundary string, max int) string { reader := multipart.NewReader(bytes.NewReader(body), boundary) fields := make(map[string][]string) for { part, err := reader.NextPart() if err == io.EOF { break } if err != nil { return truncateText(string(body), max) } name := part.FormName() if name == "" { continue } var valueBytes []byte if max > 0 { valueBytes, _ = io.ReadAll(io.LimitReader(part, int64(max)+1)) } else { valueBytes, _ = io.ReadAll(part) } value := truncateText(string(valueBytes), max) fields[name] = append(fields[name], value) } encoded, err := json.Marshal(fields) if err != nil { return "" } return string(encoded) } func truncateText(s string, max int) string { if max <= 0 || len(s) <= max { return s } return s[:max] + fmt.Sprintf("...", len(s)-max) } func formatEntry(e entry) string { var b strings.Builder statusText := "成功" if e.Status < 200 || e.Status >= 300 || e.APIError != 0 || e.ErrorText != "" { statusText = "异常" } b.WriteString(strings.Repeat("=", 96)) b.WriteByte('\n') fmt.Fprintf(&b, "#%06d %s [%s] %s %s\n", e.Seq, e.Time, statusText, e.Method, e.URL) fmt.Fprintf(&b, "接口: %s\n", utils.EmptyDefault(e.Label, "-")) fmt.Fprintf(&b, "路径: %s\n", utils.EmptyDefault(e.Path, "-")) fmt.Fprintf(&b, "结果: HTTP %d | 耗时 %s | apiError=%d", e.Status, utils.RoundDuration(e.Cost), e.APIError) if e.APIMessage != "" { fmt.Fprintf(&b, " | msg=%s", e.APIMessage) } b.WriteByte('\n') if e.ErrorText != "" { fmt.Fprintf(&b, "错误: %s\n", e.ErrorText) } b.WriteString("\n[响应头]\n") b.WriteString(formatHeaders(e.RespHeader)) b.WriteString("\n[请求头]\n") b.WriteString(formatHeaders(e.ReqHeader)) b.WriteString("\n[请求体]\n") b.WriteString(formatBody(e.ReqBody)) b.WriteString("\n[响应体]\n") b.WriteString(formatBody(e.RespBody)) if e.MaxBodyBytes > 0 { fmt.Fprintf(&b, "\n提示: 单段正文最多保留 %d 字节;请求头不脱敏,响应 Set-Cookie 隐藏值。\n", e.MaxBodyBytes) } else { b.WriteString("\n提示: trace 全量保留请求和响应;请求头不脱敏,响应 Set-Cookie 隐藏值。\n") } b.WriteByte('\n') return b.String() } func formatHeaders(headers map[string][]string) string { if len(headers) == 0 { return "\n" } keys := make([]string, 0, len(headers)) for key := range headers { keys = append(keys, key) } sort.Strings(keys) var b strings.Builder for _, key := range keys { fmt.Fprintf(&b, "%s: %s\n", key, strings.Join(headers[key], ", ")) } return b.String() } func formatBody(body string) string { body = strings.TrimSpace(body) if body == "" { return "\n" } if pretty, ok := prettyJSON(body); ok { return pretty + "\n" } return body + "\n" } func prettyJSON(s string) (string, bool) { var value any if err := json.Unmarshal([]byte(s), &value); err != nil { return "", false } out, err := json.MarshalIndent(value, "", " ") if err != nil { return "", false } return string(out), true }