Files
LLMGuardX Dev e31cc54b8e 0.11.2: 旗舰版第三轮完善(通用聊天/企微钉钉飞书扫码登录/个人安全策略)
- 门户通用聊天:选择已批准模型直接对话,审批通过后自动开通用户级运行时
  API Key(加密落库,限额取批准值),聊天经受管网关统一认证/限流/配额/审计;
  会话哈希链完整性 + busy 租约防并发,失败不落库。
- 扫码登录:identity_providers 扩展 wecom/dingtalk/feishu,管理端配置
  (AppID/AppSecret/AgentID/回调/自动开户/默认部门),登录页自动展示;
  one-time state 防 CSRF,provider_uid 全局唯一防多账号绑定,平台端点
  固定公网 URL 复用 public-only 拨号。
- 个人安全策略:账号安全页(登录设备管理/吊销非当前会话/登录提醒开关/
  扫码绑定解绑),登录成功发布 security.login_detected 事件按偏好落站内信
  (新增 security 类别),会话索引只存令牌摘要并惰性清理。
- 迁移 000038-000041;修复 social update 参数越界/凭据回读/路由挂载缺失;
  全量测试 25 包通过,前端 admin/portal 构建通过,端到端验证完成。
2026-08-13 12:53:38 +08:00

112 lines
2.9 KiB
Go

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')
}
}
}