diff --git a/internal/server/recorder.go b/internal/server/recorder.go new file mode 100644 index 0000000..1b22fb8 --- /dev/null +++ b/internal/server/recorder.go @@ -0,0 +1,174 @@ +package server + +import ( + "bytes" + "fmt" + "io" + "net/http" + "sync" + "time" +) + +const ( + maxRecentEntries = 10 + maxBodyCapture = 1 << 20 // 1 MB per body — full request/response capture for debugging +) + +// RecentEntry captures a single API request and its response for debugging. +type RecentEntry struct { + Timestamp time.Time `json:"timestamp"` + Method string `json:"method"` + Path string `json:"path"` + Status int `json:"status"` + DurationMs int64 `json:"duration_ms"` + RequestHeaders map[string]string `json:"request_headers"` + RequestBody string `json:"request_body"` + ResponseBody string `json:"response_body"` +} + +// RecentRecorder is an in-memory ring buffer that stores the most recent API +// requests. It is safe for concurrent use. +type RecentRecorder struct { + mu sync.Mutex + entries []RecentEntry +} + +func NewRecentRecorder() *RecentRecorder { + return &RecentRecorder{} +} + +// Record appends an entry, evicting the oldest when the buffer is full. +func (r *RecentRecorder) Record(e RecentEntry) { + r.mu.Lock() + defer r.mu.Unlock() + r.entries = append(r.entries, e) + if len(r.entries) > maxRecentEntries { + r.entries = r.entries[len(r.entries)-maxRecentEntries:] + } +} + +// Entries returns a copy of the buffer in newest-first order. +func (r *RecentRecorder) Entries() []RecentEntry { + r.mu.Lock() + defer r.mu.Unlock() + n := len(r.entries) + out := make([]RecentEntry, n) + for i, e := range r.entries { + out[n-1-i] = e // reverse: newest first + } + return out +} + +// recordingResponseWriter wraps http.ResponseWriter to capture the status code +// and a truncated copy of the response body. It implements http.Flusher so +// streaming handlers can flush through the wrapper. +type recordingResponseWriter struct { + http.ResponseWriter + statusCode int + body bytes.Buffer + totalBytes int +} + +func newRecordingResponseWriter(w http.ResponseWriter) *recordingResponseWriter { + return &recordingResponseWriter{ResponseWriter: w, statusCode: http.StatusOK} +} + +func (w *recordingResponseWriter) WriteHeader(code int) { + w.statusCode = code + w.ResponseWriter.WriteHeader(code) +} + +func (w *recordingResponseWriter) Write(b []byte) (int, error) { + w.totalBytes += len(b) + if w.body.Len() < maxBodyCapture { + remaining := maxBodyCapture - w.body.Len() + if len(b) <= remaining { + w.body.Write(b) + } else { + w.body.Write(b[:remaining]) + } + } + return w.ResponseWriter.Write(b) +} + +func (w *recordingResponseWriter) Flush() { + if f, ok := w.ResponseWriter.(http.Flusher); ok { + f.Flush() + } +} + +// captureRequestHeaders extracts selected request headers, masking the +// Authorization value to avoid leaking API keys. +func captureRequestHeaders(r *http.Request) map[string]string { + headers := map[string]string{} + for _, key := range []string{"Content-Type", "User-Agent", "Accept", "Authorization"} { + if v := r.Header.Get(key); v != "" { + if key == "Authorization" { + headers[key] = mask(v) + } else { + headers[key] = v + } + } + } + return headers +} + +// truncateBody returns the string form of b, truncated to maxBodyCapture +// bytes with a marker if the original was longer. +func truncateBody(b []byte) string { + if len(b) > maxBodyCapture { + return string(b[:maxBodyCapture]) + fmt.Sprintf("\n...(truncated, total %d bytes)", len(b)) + } + return string(b) +} + +// formatResponseBody returns the captured response body, with a truncation +// marker if the full response exceeded the capture limit. +func formatResponseBody(buf *bytes.Buffer, totalBytes int) string { + s := buf.String() + if totalBytes > maxBodyCapture { + s += fmt.Sprintf("\n...(truncated, total %d bytes)", totalBytes) + } + return s +} + +// withRecording wraps a handler so that each request's headers, body, +// response status, and response body (truncated) are captured into the +// server's RecentRecorder. It is applied to the /v1/ API routes so that +// both successful and error responses are recorded. +func (s *Server) withRecording(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + + // Read and restore the request body so the downstream handler + // still sees the full content. + var reqBody []byte + if r.Body != nil { + reqBody, _ = io.ReadAll(r.Body) + r.Body = io.NopCloser(bytes.NewReader(reqBody)) + } + + recW := newRecordingResponseWriter(w) + next(recW, r) + + s.recorder.Record(RecentEntry{ + Timestamp: start, + Method: r.Method, + Path: r.URL.Path, + Status: recW.statusCode, + DurationMs: time.Since(start).Milliseconds(), + RequestHeaders: captureRequestHeaders(r), + RequestBody: truncateBody(reqBody), + ResponseBody: formatResponseBody(&recW.body, recW.totalBytes), + }) + } +} + +// getRecent handles GET /api/recent — returns the most recent API requests +// as JSON, newest first. +func (s *Server) getRecent(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "requests": s.recorder.Entries(), + }) +} diff --git a/internal/server/recorder_test.go b/internal/server/recorder_test.go new file mode 100644 index 0000000..064ade2 --- /dev/null +++ b/internal/server/recorder_test.go @@ -0,0 +1,148 @@ +package server + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// TestRecentRecorderRingBuffer verifies that the recorder keeps at most +// maxRecentEntries and evicts the oldest when full. +func TestRecentRecorderRingBuffer(t *testing.T) { + rec := NewRecentRecorder() + for i := 0; i < maxRecentEntries+5; i++ { + rec.Record(RecentEntry{Method: "POST", Path: "/v1/test", Status: 200}) + } + entries := rec.Entries() + if len(entries) != maxRecentEntries { + t.Fatalf("got %d entries, want %d", len(entries), maxRecentEntries) + } +} + +// TestRecentRecorderNewestFirst verifies entries are returned newest-first. +func TestRecentRecorderNewestFirst(t *testing.T) { + rec := NewRecentRecorder() + rec.Record(RecentEntry{Path: "/first"}) + rec.Record(RecentEntry{Path: "/second"}) + rec.Record(RecentEntry{Path: "/third"}) + entries := rec.Entries() + if len(entries) != 3 { + t.Fatalf("got %d entries", len(entries)) + } + if entries[0].Path != "/third" { + t.Fatalf("first entry = %q, want /third", entries[0].Path) + } + if entries[2].Path != "/first" { + t.Fatalf("last entry = %q, want /first", entries[2].Path) + } +} + +// TestWithRecordingCapturesRequestResponse verifies the middleware captures +// request headers, request body, response status, and response body. +func TestWithRecordingCapturesRequestResponse(t *testing.T) { + rec := NewRecentRecorder() + s := &Server{recorder: rec} + + handler := s.withRecording(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"result":"ok"}`)) + }) + + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"GLM-4.7","messages":[]}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer sk-secret-key-12345") + req.Header.Set("User-Agent", "test-client/1.0") + w := httptest.NewRecorder() + handler(w, req) + + entries := rec.Entries() + if len(entries) != 1 { + t.Fatalf("got %d entries, want 1", len(entries)) + } + e := entries[0] + if e.Method != "POST" { + t.Fatalf("method = %q", e.Method) + } + if e.Path != "/v1/chat/completions" { + t.Fatalf("path = %q", e.Path) + } + if e.Status != 200 { + t.Fatalf("status = %d", e.Status) + } + if e.RequestBody != `{"model":"GLM-4.7","messages":[]}` { + t.Fatalf("request body = %q", e.RequestBody) + } + if e.ResponseBody != `{"result":"ok"}` { + t.Fatalf("response body = %q", e.ResponseBody) + } + // Authorization must be masked + auth, ok := e.RequestHeaders["Authorization"] + if !ok || !strings.Contains(auth, "****") { + t.Fatalf("authorization not masked: %q", auth) + } + if strings.Contains(auth, "sk-secret-key-12345") { + t.Fatal("authorization leaked raw key") + } + if e.RequestHeaders["Content-Type"] != "application/json" { + t.Fatalf("content-type = %v", e.RequestHeaders["Content-Type"]) + } + if e.RequestHeaders["User-Agent"] != "test-client/1.0" { + t.Fatalf("user-agent = %v", e.RequestHeaders["User-Agent"]) + } +} + +// TestWithRecordingCapturesErrors verifies error responses are recorded. +func TestWithRecordingCapturesErrors(t *testing.T) { + rec := NewRecentRecorder() + s := &Server{recorder: rec} + + handler := s.withRecording(func(w http.ResponseWriter, r *http.Request) { + writeOpenAIError(w, http.StatusUnauthorized, "invalid api key", "auth_error", "invalid_api_key") + }) + + req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"input":"hi"}`)) + req.Header.Set("Authorization", "Bearer wrong-key") + w := httptest.NewRecorder() + handler(w, req) + + entries := rec.Entries() + if len(entries) != 1 { + t.Fatalf("got %d entries, want 1", len(entries)) + } + e := entries[0] + if e.Status != 401 { + t.Fatalf("status = %d, want 401", e.Status) + } + if !strings.Contains(e.ResponseBody, "invalid api key") { + t.Fatalf("response body = %q", e.ResponseBody) + } +} + +// TestGetRecentAPI verifies GET /api/recent returns recorded entries as JSON. +func TestGetRecentAPI(t *testing.T) { + rec := NewRecentRecorder() + rec.Record(RecentEntry{Method: "POST", Path: "/v1/chat/completions", Status: 200}) + rec.Record(RecentEntry{Method: "POST", Path: "/v1/responses", Status: 500}) + s := &Server{recorder: rec} + + req := httptest.NewRequest(http.MethodGet, "/api/recent", nil) + w := httptest.NewRecorder() + s.getRecent(w, req) + + body, _ := io.ReadAll(w.Body) + if !strings.Contains(string(body), `"ok":true`) { + t.Fatalf("response missing ok:true: %s", string(body)) + } + if !strings.Contains(string(body), "/v1/responses") { + t.Fatalf("response missing /v1/responses: %s", string(body)) + } + // newest first + idxResponses := strings.Index(string(body), "/v1/responses") + idxChat := strings.Index(string(body), "/v1/chat/completions") + if idxResponses < 0 || idxChat < 0 || idxResponses > idxChat { + t.Fatalf("entries not newest-first: %s", string(body)) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index ba214b7..c1462f3 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -33,10 +33,11 @@ type Server struct { loginSession string st *store.Store statsEnabled bool + recorder *RecentRecorder } func New(cfg config.Config, st *store.Store) http.Handler { - s := &Server{cfg: cfg, mux: http.NewServeMux(), st: st, statsEnabled: !cfg.StatsDisabled} + s := &Server{cfg: cfg, mux: http.NewServeMux(), st: st, statsEnabled: !cfg.StatsDisabled, recorder: NewRecentRecorder()} if cfg.LoginPassword != "" { s.loginSession = randomSessionToken() } @@ -62,11 +63,12 @@ func (s *Server) routes() { s.mux.HandleFunc("POST /api/sso/exchange", s.withLoginSession(s.exchangeSSOCode)) s.mux.HandleFunc("GET /api/stats", s.withLoginSession(s.getStats)) s.mux.HandleFunc("POST /api/stats/reset", s.withLoginSession(s.resetStats)) + s.mux.HandleFunc("GET /api/recent", s.withLoginSession(s.getRecent)) s.mux.HandleFunc("GET /api/models", s.withLoginSession(s.getModels)) s.mux.HandleFunc("POST /api/models/test", s.withLoginSession(s.testModel)) - s.mux.HandleFunc("GET /v1/models", s.withAPIKey(s.models)) - s.mux.HandleFunc("POST /v1/chat/completions", s.withAPIKey(s.chatCompletions)) - s.mux.HandleFunc("POST /v1/responses", s.withAPIKey(s.responses)) + s.mux.HandleFunc("GET /v1/models", s.withRecording(s.withAPIKey(s.models))) + s.mux.HandleFunc("POST /v1/chat/completions", s.withRecording(s.withAPIKey(s.chatCompletions))) + s.mux.HandleFunc("POST /v1/responses", s.withRecording(s.withAPIKey(s.responses))) } func (s *Server) healthz(w http.ResponseWriter, r *http.Request) { diff --git a/internal/server/templates/admin.html b/internal/server/templates/admin.html index 551688f..1d81e35 100644 --- a/internal/server/templates/admin.html +++ b/internal/server/templates/admin.html @@ -101,6 +101,16 @@ @media(prefers-reduced-motion:reduce){.status[data-state="busy"]::before{animation:none}} .footer{margin-top:22px;padding-top:18px;border-top:1px solid var(--border);font-size:12px;color:var(--muted);line-height:1.7} .footer code{font-family:"SF Mono",ui-monospace,Consolas,monospace;font-size:12px;color:var(--body);word-break:break-all} + .recent-card{border:1px solid var(--border);border-radius:12px;padding:16px;margin-bottom:12px;background:var(--bg)} + .recent-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:4px} + .recent-time{font-size:12px;color:var(--muted);font-variant-numeric:tabular-nums} + .recent-method{font-size:11.5px;font-weight:700;color:var(--accent);background:var(--accent-soft);padding:2px 8px;border-radius:999px} + .recent-path{font-size:13px;color:var(--ink);font-family:"SF Mono",ui-monospace,Consolas,monospace;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} + .recent-duration{font-size:12px;color:var(--muted);font-variant-numeric:tabular-nums} + .recent-card details{margin-top:6px} + .recent-card summary{cursor:pointer;font-size:12.5px;font-weight:600;color:var(--body);padding:4px 0;user-select:none} + .recent-card summary:hover{color:var(--accent)} + .recent-card pre{background:#1e1e2e;color:#cdd6f4;padding:12px;border-radius:8px;font-size:12px;overflow-x:auto;max-height:400px;overflow-y:auto;font-family:"SF Mono",ui-monospace,Consolas,monospace;line-height:1.5;margin:8px 0 0;white-space:pre-wrap;word-break:break-all}
@@ -116,6 +126,7 @@记录最近 10 条 /v1/ 请求的请求头和返回结果(含报错)。
+ +输入手机号获取验证码,按插件默认的移动云登录接口换取凭据和模型 API Key。凭据保存到本地数据库,后续 OpenAI 兼容接口自动使用。
@@ -225,6 +247,7 @@ }); var hash = location.hash.replace('#', ''); if (hash === 'models') activate('models'); + else if (hash === 'recent') activate('recent'); else if (hash === 'login') activate('login'); var rows = document.querySelectorAll('#daily .bar'); @@ -521,6 +544,66 @@ if (modelsTab) modelsTab.addEventListener('click', loadIfActive); loadIfActive(); })(); + + (function () { + var recentList = document.getElementById('recent-list'); + if (!recentList) return; + var loaded = false; + + function escapeHtml(s) { + return String(s).replace(/[&<>"']/g, function (c) { + return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; + }); + } + function formatTime(ts) { + var d = new Date(ts); + return d.toLocaleString('zh-CN', { hour12: false }); + } + async function loadRecent() { + recentList.innerHTML = '加载中…
'; + try { + var res = await fetch('/api/recent'); + var data = await res.json(); + if (!data.ok || !data.requests || data.requests.length === 0) { + recentList.innerHTML = '暂无数据
'; + return; + } + var html = ''; + for (var i = 0; i < data.requests.length; i++) { + var r = data.requests[i]; + var statusClass = r.status >= 200 && r.status < 300 ? 'ok' : 'err'; + var headers = r.request_headers || {}; + var headerStr = Object.keys(headers).map(function (k) { + return k + ': ' + headers[k]; + }).join('\n'); + html += '' + escapeHtml(headerStr) + '
' + escapeHtml(r.request_body || '(空)') + '
' + escapeHtml(r.response_body || '(空)') + '
加载失败: ' + escapeHtml(e.message) + '
'; + } + } + var recentTab = document.querySelector('.tab[data-tab="recent"]'); + if (recentTab) recentTab.addEventListener('click', function () { + if (!loaded) { loaded = true; loadRecent(); } + }); + var refreshBtn = document.getElementById('recent-refresh'); + if (refreshBtn) refreshBtn.addEventListener('click', loadRecent); + // auto-load if the tab is active on page load + var panel = document.querySelector('.tabpanel[data-tab="recent"]'); + if (panel && panel.classList.contains('active')) { loaded = true; loadRecent(); } + })();