From 10f51cbdaef403445670448536e8260f9f457832 Mon Sep 17 00:00:00 2001 From: Sakurasan <26715255+Sakurasan@users.noreply.github.com> Date: Thu, 20 Aug 2026 00:35:53 +0800 Subject: [PATCH] =?UTF-8?q?=E8=AF=B7=E6=B1=82=E6=98=8E=E7=BB=86=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E6=9F=A5=E7=9C=8B=E5=8E=9F=E5=A7=8B=E8=AF=B7=E6=B1=82?= =?UTF-8?q?/=E5=93=8D=E5=BA=94=EF=BC=9AOT=5FPROXY=5FLOG=5FRAW=20=E5=BC=80?= =?UTF-8?q?=E5=85=B3=E6=8E=A7=E5=88=B6=EF=BC=8C=E4=BB=85=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=91=98=E8=AE=B0=E5=BD=95=E4=B8=8E=E5=8F=AF=E8=A7=81=EF=BC=8C?= =?UTF-8?q?=E6=B5=81=E5=BC=8F=E5=85=A8=E9=87=8F=E6=8D=95=E8=8E=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 配置: ProxyConfig.LogRaw (OT_PROXY_LOG_RAW, 默认 false) - 存储: usage_logs 新增 raw_request/raw_response 文本列 (AutoMigrate) - 网关: NewGateway 接收 logRaw 参数 - handlers: 三个协议入口按 开关+管理员 条件记录原始请求体 - passthrough: 非流式 copyAndCapture 捕获响应, 流式 streamCopy 累积全部原始 SSE 行, finishUsage 统一写入 - admin API: AdminUsage 返回 raw_request/raw_response (仅管理员) - 前端: 用量页新增查看入口, 弹窗 tab 切换请求/响应 - gitignore: 修正 server/web/ 忽略规则(尾随空格导致未生效) --- .env.example | 3 ++ .gitignore | 3 +- server/cmd/server/main.go | 2 +- server/internal/api/admin_stats.go | 1 + server/internal/config/config.go | 3 ++ server/internal/proxy/gateway.go | 4 ++- server/internal/proxy/handlers.go | 12 +++++++ server/internal/proxy/passthrough.go | 29 +++++++++++++++ server/internal/store/models.go | 2 ++ web/src/types.ts | 2 ++ web/src/views/admin/UsageView.vue | 54 +++++++++++++++++++++++++++- 11 files changed, 111 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index c5f205d..7e046d4 100644 --- a/.env.example +++ b/.env.example @@ -33,6 +33,9 @@ OT_PROXY_TIMEOUT=120s OT_PROXY_HEALTH_INTERVAL=60s OT_PROXY_HEALTH_FAIL_THRESHOLD=2 +# 调试:记录管理员的原始请求与响应到请求明细(默认关闭;流式记录全部事件) +OT_PROXY_LOG_RAW=false + # 限流(内存计数,Redis 后置):用户级每秒请求数上限(0=不限制) OT_RATELIMIT_USER_RPS=20 diff --git a/.gitignore b/.gitignore index a581ee1..87fbc0d 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,8 @@ dist/ # Go server/bin/ server/data/ -server/web/ # 本地静态托管软链(指向 ../web/dist),不入库 +# 本地静态托管软链(指向 ../web/dist),不入库 +server/web/ scripts/mockupstream/bin/ # TypeScript 增量构建产物 diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index c51f350..e92cffa 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -31,7 +31,7 @@ func main() { } defer a.Shutdown(context.Background()) - gw := proxy.NewGateway(a.DB, a.Enc, a.Usage, a.Limit, cfg.RateLimit.UserRPS) + gw := proxy.NewGateway(a.DB, a.Enc, a.Usage, a.Limit, cfg.RateLimit.UserRPS, cfg.Proxy.LogRaw) router := api.NewRouter(a, gw) srv := &http.Server{ diff --git a/server/internal/api/admin_stats.go b/server/internal/api/admin_stats.go index f1d663a..9602253 100644 --- a/server/internal/api/admin_stats.go +++ b/server/internal/api/admin_stats.go @@ -95,6 +95,7 @@ func (h *Handler) AdminUsage(c *gin.Context) { "input_tokens": l.InputTokens, "output_tokens": l.OutputTokens, "cache_read_tokens": l.CacheReadTokens, "cost": l.Cost, "latency_ms": l.LatencyMS, "status": l.Status, "error_code": l.ErrorCode, + "raw_request": l.RawRequest, "raw_response": l.RawResponse, "created_at": l.CreatedAt, }) } diff --git a/server/internal/config/config.go b/server/internal/config/config.go index d52dc78..7df1ad5 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -66,6 +66,7 @@ type ProxyConfig struct { Timeout time.Duration HealthInterval time.Duration // 渠道健康检查周期 HealthFailThreshold int // 连续失败 N 次进 cooldown + LogRaw bool // 记录管理员原始请求体+响应到 usage_logs(调试用,默认关) } // loadDotEnv 读取 .env 并把 KEY=VALUE 注入环境变量(AutomaticEnv 自动映射 OT_ 前缀)。 @@ -129,6 +130,7 @@ func Load() (*Config, error) { v.SetDefault("proxy.timeout", "120s") v.SetDefault("proxy.health_interval", "60s") v.SetDefault("proxy.health_fail_threshold", 2) + v.SetDefault("proxy.log_raw", false) v.SetDefault("ratelimit.user_rps", 20) @@ -168,6 +170,7 @@ func Load() (*Config, error) { Timeout: v.GetDuration("proxy.timeout"), HealthInterval: v.GetDuration("proxy.health_interval"), HealthFailThreshold: v.GetInt("proxy.health_fail_threshold"), + LogRaw: v.GetBool("proxy.log_raw"), }, RateLimit: RateLimitConfig{ UserRPS: v.GetInt("ratelimit.user_rps"), diff --git a/server/internal/proxy/gateway.go b/server/internal/proxy/gateway.go index a931196..16efcef 100644 --- a/server/internal/proxy/gateway.go +++ b/server/internal/proxy/gateway.go @@ -35,6 +35,7 @@ type Gateway struct { enc *crypto.Encryptor lim *ratelimit.Limiter userRPS int + logRaw bool hc *http.Client policyMu sync.Mutex @@ -108,7 +109,7 @@ func contains(list []string, s string) bool { return false } -func NewGateway(db *gorm.DB, enc *crypto.Encryptor, rec *usage.Recorder, lim *ratelimit.Limiter, userRPS int) *Gateway { +func NewGateway(db *gorm.DB, enc *crypto.Encryptor, rec *usage.Recorder, lim *ratelimit.Limiter, userRPS int, logRaw bool) *Gateway { return &Gateway{ db: db, ch: channel.NewService(db, enc), @@ -116,6 +117,7 @@ func NewGateway(db *gorm.DB, enc *crypto.Encryptor, rec *usage.Recorder, lim *ra enc: enc, lim: lim, userRPS: userRPS, + logRaw: logRaw, hc: &http.Client{Timeout: 120 * time.Second}, } } diff --git a/server/internal/proxy/handlers.go b/server/internal/proxy/handlers.go index 55cb2ab..8e6cd64 100644 --- a/server/internal/proxy/handlers.go +++ b/server/internal/proxy/handlers.go @@ -5,6 +5,7 @@ import ( "github.com/gin-gonic/gin" "github.com/openteam/server/internal/proxy/convert" + "github.com/openteam/server/internal/store" ) // chatCompletions POST /v1/chat/completions @@ -23,6 +24,7 @@ func (g *Gateway) chatCompletions(c *gin.Context) { } c.Set("protocol", convert.ProtoChat) c.Set("model_name", br.Model) + g.recordRawRequest(c, u, body) if !g.checkModelAllowed(u, br.Model) { apiError(c, http.StatusForbidden, "model_not_allowed", "模型未对你开放,请联系管理员") return @@ -55,6 +57,7 @@ func (g *Gateway) responses(c *gin.Context) { } c.Set("protocol", convert.ProtoResponses) c.Set("model_name", br.Model) + g.recordRawRequest(c, u, body) if !g.checkModelAllowed(u, br.Model) { apiError(c, http.StatusForbidden, "model_not_allowed", "模型未对你开放,请联系管理员") return @@ -87,6 +90,7 @@ func (g *Gateway) messages(c *gin.Context) { } c.Set("protocol", convert.ProtoMessages) c.Set("model_name", br.Model) + g.recordRawRequest(c, u, body) if !g.checkModelAllowed(u, br.Model) { apiError(c, http.StatusForbidden, "model_not_allowed", "模型未对你开放,请联系管理员") return @@ -108,6 +112,14 @@ type sinkHolder struct { sink *usageSink } +// recordRawRequest 记录管理员原始请求体到 context(供 finishUsage 落库)。 +// 仅当开关开启且用户为管理员时记录;响应侧以 c.Get("raw_request") 是否非空判断是否需要捕获响应。 +func (g *Gateway) recordRawRequest(c *gin.Context, u *store.User, body []byte) { + if g.logRaw && u.Role == store.RoleAdmin { + c.Set("raw_request", string(body)) + } +} + // apiError 按客户端协议返回错误体(PLANNING §5.1.4)。 func apiError(c *gin.Context, status int, code, message string) { if p, _ := c.Get("protocol"); p == convert.ProtoMessages { diff --git a/server/internal/proxy/passthrough.go b/server/internal/proxy/passthrough.go index 38fdd87..f711cc8 100644 --- a/server/internal/proxy/passthrough.go +++ b/server/internal/proxy/passthrough.go @@ -272,6 +272,9 @@ func (g *Gateway) copyAndCapture(c *gin.Context, ch *store.Channel, r io.Reader, } } _, _ = c.Writer.Write(out) + if _, ok := c.Get("raw_request"); ok { + c.Set("raw_response", string(data)) // 上游原始响应(未转换) + } g.finishUsage(c, ch, start, store.UsageStatusSuccess, "") } @@ -283,10 +286,24 @@ func (g *Gateway) streamCopy(c *gin.Context, ch *store.Channel, r io.Reader, sta flusher = nopFlusher{} } + // 原始响应捕获:仅管理员且开关开启(raw_request 已 set)时累积上游原始行 + _, capture := c.Get("raw_request") + var rawResp strings.Builder + + // commitRaw 在记账前把已累积的原始响应写入 context + commitRaw := func() { + if capture { + c.Set("raw_response", rawResp.String()) + } + } + scanner := newSSEScanner(r) for { line, err := scanner.Next() if line != nil { + if capture { + rawResp.Write(line) + } out := line if lineConv != nil { out = lineConv(line) @@ -294,6 +311,7 @@ func (g *Gateway) streamCopy(c *gin.Context, ch *store.Channel, r io.Reader, sta if out != nil { if _, werr := w.Write(out); werr != nil { // 客户端意外断开:按已生成部分收费(canceled) + commitRaw() g.finishUsage(c, ch, start, store.UsageStatusCanceled, "client_disconnect") return } @@ -307,6 +325,7 @@ func (g *Gateway) streamCopy(c *gin.Context, ch *store.Channel, r io.Reader, sta } } if err != nil { + commitRaw() if err == io.EOF { g.finishUsage(c, ch, start, store.UsageStatusSuccess, "") } else if c.Request.Context().Err() != nil { @@ -578,6 +597,14 @@ func (g *Gateway) finishUsage(c *gin.Context, ch *store.Channel, start time.Time chID = ch.ID } + var rawReq, rawResp string + if v, ok := c.Get("raw_request"); ok { + rawReq, _ = v.(string) + } + if v, ok := c.Get("raw_response"); ok { + rawResp, _ = v.(string) + } + // 密钥今日 token 用量累计(配额检查用) if g.lim != nil && kidVal > 0 { g.lim.AddTokens(kidVal, in+out) @@ -603,6 +630,8 @@ func (g *Gateway) finishUsage(c *gin.Context, ch *store.Channel, start time.Time LatencyMS: latency, Status: status, ErrorCode: errCodePtr, + RawRequest: rawReq, + RawResponse: rawResp, CreatedAt: time.Now().UTC(), }) } diff --git a/server/internal/store/models.go b/server/internal/store/models.go index c7fb4af..9921d3e 100644 --- a/server/internal/store/models.go +++ b/server/internal/store/models.go @@ -188,6 +188,8 @@ type UsageLog struct { LatencyMS int `json:"latency_ms"` Status string `gorm:"size:16;not null" json:"status"` ErrorCode *string `json:"error_code,omitempty"` + RawRequest string `gorm:"type:text" json:"raw_request"` // 客户端原始请求体(未转换) + RawResponse string `gorm:"type:text" json:"raw_response"` // 上游原始响应(未转换;流式为全部 SSE 事件) CreatedAt time.Time `gorm:"index" json:"created_at"` } diff --git a/web/src/types.ts b/web/src/types.ts index c772520..656c3d1 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -90,6 +90,8 @@ export interface UsageLog { latency_ms: number status: string error_code: string | null + raw_request?: string + raw_response?: string created_at: string user?: string } diff --git a/web/src/views/admin/UsageView.vue b/web/src/views/admin/UsageView.vue index 658cd69..bcb5aa2 100644 --- a/web/src/views/admin/UsageView.vue +++ b/web/src/views/admin/UsageView.vue @@ -6,6 +6,7 @@ import { protocolShort } from '@/lib/protocol' import { fmtCost, fmtTime } from '@/lib/format' import Badge from '@/components/ui/Badge.vue' import Button from '@/components/ui/Button.vue' +import Modal from '@/components/ui/Modal.vue' import type { UsageLog } from '@/types' const toast = useToastStore() @@ -15,6 +16,14 @@ const page = ref(1) const modelFilter = ref('') const pageSize = 15 +const viewing = ref(null) +const viewTab = ref<'request' | 'response'>('request') + +function openRaw(l: UsageLog) { + viewing.value = l + viewTab.value = 'request' +} + async function load() { try { const { data } = await http.get( @@ -77,6 +86,7 @@ onMounted(load) {{ l.input_tokens }}/{{ l.output_tokens }} tok {{ l.latency_ms }}ms {{ fmtTime(l.created_at) }} +

暂无请求记录

@@ -95,6 +105,7 @@ onMounted(load) 耗时 状态 时间 + 原始 @@ -109,9 +120,13 @@ onMounted(load) {{ l.status }} {{ fmtTime(l.created_at) }} + + + - + - 暂无请求记录 + 暂无请求记录 @@ -124,5 +139,42 @@ onMounted(load) + + +
+
+
+ {{ viewing.model }} + · + {{ viewing.user || '-' }} + · + {{ fmtTime(viewing.created_at) }} +
+
+ + +
+
+
{{ viewTab === 'request' ? viewing.raw_request : viewing.raw_response }}
+

+ 该请求未记录{{ viewTab === 'request' ? '原始请求' : '原始响应' }} +

+
+