package gateway import ( "bytes" "io" "strings" ) // outputRedactReadCloser 对上游模型响应应用输出侧脱敏(隐私信息拦截替换): // - 非流式(application/json):首次 Read 前缓冲整个响应,处理后再输出; // - 流式(text/event-stream):逐 data 行处理,不改变事件边界。 type outputRedactReadCloser struct { io.ReadCloser engine interface { OutputRedact([]byte) ([]byte, bool) } sse bool buffered []byte // 已处理待输出的字节 done bool // 非流式已完成缓冲与处理 pending []byte // 流式:未完成的行 } func newOutputRedactReadCloser(body io.ReadCloser, contentType string, engine interface { OutputRedact([]byte) ([]byte, bool) }) io.ReadCloser { if engine == nil { return body } return &outputRedactReadCloser{ ReadCloser: body, engine: engine, sse: strings.Contains(strings.ToLower(contentType), "text/event-stream"), } } func (r *outputRedactReadCloser) Read(buffer []byte) (int, error) { if !r.sse { // 非流式:首次 Read 时一次性缓冲+处理。 if !r.done { r.done = true raw, err := io.ReadAll(r.ReadCloser) _ = err if replaced, changed := r.engine.OutputRedact(raw); changed { r.buffered = replaced } else { r.buffered = raw } } if len(r.buffered) == 0 { return 0, io.EOF } n := copy(buffer, r.buffered) r.buffered = r.buffered[n:] if len(r.buffered) == 0 { return n, io.EOF } return n, nil } // 流式:先输出已处理的行,再读上游。 if len(r.buffered) > 0 { n := copy(buffer, r.buffered) r.buffered = r.buffered[n:] return n, nil } chunk := make([]byte, 32<<10) n, err := r.ReadCloser.Read(chunk) if n > 0 { r.pending = append(r.pending, chunk[:n]...) r.processLines() } if err == io.EOF && len(r.pending) > 0 { // 流结束:剩余不完整行原样输出(不处理,避免破坏事件边界)。 r.buffered = append(r.buffered, r.pending...) r.pending = nil } if len(r.buffered) > 0 { n2 := copy(buffer, r.buffered) r.buffered = r.buffered[n2:] return n2, err } return n, err } // processLines 把 pending 中的完整行处理进 buffered。 func (r *outputRedactReadCloser) processLines() { for { index := bytes.IndexByte(r.pending, '\n') if index < 0 { return } line := r.pending[:index] r.pending = r.pending[index+1:] trimmed := strings.TrimSpace(string(line)) if !strings.HasPrefix(trimmed, "data:") { r.buffered = append(r.buffered, line...) r.buffered = append(r.buffered, '\n') continue } payload := strings.TrimSpace(strings.TrimPrefix(trimmed, "data:")) if payload == "" || payload == "[DONE]" { r.buffered = append(r.buffered, line...) r.buffered = append(r.buffered, '\n') continue } if replaced, changed := r.engine.OutputRedact([]byte(payload)); changed { r.buffered = append(r.buffered, []byte("data: "+string(replaced)+"\n")...) } else { r.buffered = append(r.buffered, line...) r.buffered = append(r.buffered, '\n') } } }