From 0c268fb2c53e491629622bec5049e0df4523c349 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E7=90=A6?= Date: Thu, 28 May 2026 17:04:29 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 2 + .gitignore | 1 + README.md | 44 ++- internal/forward/apilog.go | 364 ++++++++++++++++++ internal/forward/apilog_test.go | 219 +++++++++++ internal/forward/proxy.go | 87 ++++- internal/forward/run.go | 14 +- internal/forward/testweb/apilog_list.go | 98 +++++ internal/forward/testweb/apilog_parse.go | 318 +++++++++++++++ internal/forward/testweb/apilog_parse_test.go | 141 +++++++ internal/forward/testweb/embed.go | 6 + internal/forward/testweb/export_postman.go | 117 ++++++ internal/forward/testweb/govtest.go | 151 ++++++++ internal/forward/testweb/server.go | 259 +++++++++++++ internal/forward/testweb/web/index.html | 84 ++++ internal/forward/testweb/web/t.css | 224 +++++++++++ internal/forward/testweb/web/t.js | 250 ++++++++++++ 17 files changed, 2362 insertions(+), 17 deletions(-) create mode 100644 internal/forward/apilog.go create mode 100644 internal/forward/apilog_test.go create mode 100644 internal/forward/testweb/apilog_list.go create mode 100644 internal/forward/testweb/apilog_parse.go create mode 100644 internal/forward/testweb/apilog_parse_test.go create mode 100644 internal/forward/testweb/embed.go create mode 100644 internal/forward/testweb/export_postman.go create mode 100644 internal/forward/testweb/govtest.go create mode 100644 internal/forward/testweb/server.go create mode 100644 internal/forward/testweb/web/index.html create mode 100644 internal/forward/testweb/web/t.css create mode 100644 internal/forward/testweb/web/t.js diff --git a/.env.example b/.env.example index c2d2a81..2d3fc75 100644 --- a/.env.example +++ b/.env.example @@ -21,3 +21,5 @@ ALLOW_IPS=192.168.1.20 # 应用日志根目录(默认 {程序目录}/../log/forward/) # LOG_DIR=D:\worker\code\log + +# API 请求明细(无需配置):{程序目录}/api-logs/{YYYY-mm-dd HH}/ 每请求一个 .log,含完整请求/响应(不脱敏) diff --git a/.gitignore b/.gitignore index edede67..b0546bb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ *.exe .env ../log/ +api-logs/ diff --git a/README.md b/README.md index 05f13e7..df55f07 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ | `POST /province/supervise/data` | `SUPERVISE_TARGET_URL`(28212) | 加密后的监管业务 JSON | | `POST /mng/file/auth/upload` | `FILE_TARGET_URL`(28211) | 处方 PDF multipart 上传 | | `GET /health` | 本地 | 健康检查 | +| `GET /t` | 本地 | 政务云测试页(读 file api-logs、连通测试、回放、导出 ApiPost) | 转发规则: @@ -94,6 +95,19 @@ Windows 也可双击或执行项目根目录下的 [`run.bat`](d:/worker/code/xk IDE 调试:Working Directory 设为项目根,运行文件选 `main.go` 即可。 +## 政务云测试页 `/t` + +启动后浏览器访问:`http://:16001/t`(随 forward 进程开放,**仅建议内网使用**)。 + +功能: + +- **连通测试**:对当前 `.env` 中 28212 / 28211 目标 URL 发起探测。 +- **file 日志**:左侧列出 `api-logs/**/_file_*.log`,点击解析 **入站(块1)** / **出站(块2)** 的 Headers 与 Body(multipart 为 `body_base64`)。 +- **发送**:按出站头/体向政务云回放 POST(不含 `X-Forward-Token`)。 +- **导出 ApiPost**:下载 Postman Collection v2.1 JSON,在 ApiPost 中选择 **导入 → Postman 集合**。 + +日志含 `X-Authorization` 等敏感信息,请勿将导出的集合或 `/t` 暴露到公网。 + 本地联调 mock 上游时,在 `.env` 中设置: ```env @@ -112,4 +126,32 @@ FILE_TARGET_URL=http://127.0.0.1:18001/api/u 2026-05-19T22:00:02+08:00 | 192.168.1.20 | supervise | dry_run | target=https://... | body_len=1024 | 3ms ``` -不记录完整 body 与 uploadToken。 +摘要行不含完整 body;明细见下方 **API 请求日志**。 + +## API 请求日志(api-logs) + +每次 HTTP 请求(含 `/health`、监管、文件上传、403 本地拒绝、dry-run)在**程序同级目录**写入独立文件: + +```text +{程序目录}/api-logs/2026-05-27 14/20260527143015_supervise_a1b2c3d4.log +``` + +单文件固定 **三段**(完整原文,不脱敏): + +```text +=== META === +Time / ClientIP / Path / Kind(supervise|file) / DurationMs / Status + +=== 1. 入站请求(transit → forward)=== +URL、原始 Headers(含 X-Forward-Token)、Body(multipart/PDF 为 body_len + body_base64) + +=== 2. 出站转发(forward → 政务云)=== +TargetURL、白名单过滤后 Headers、Body(与入站相同字节) + +=== 3. 回复(政务云 → forward → transit)=== +UpstreamStatus、UpstreamHeaders、Body;JSON 响应额外输出 BodyJSON(缩进格式化) +``` + +排查超时:若 **块1+块2** 完整、**块3** 为空且 `DurationMs` 很大,说明已发往政务云但上游无响应(常见为 28211 网络不通)。 + +**注意**:日志含 `X-Authorization`、`X-Forward-Token`、业务密文及 PDF,已加入 `.gitignore`,请勿提交仓库或暴露到外网。 diff --git a/internal/forward/apilog.go b/internal/forward/apilog.go new file mode 100644 index 0000000..f5dff72 --- /dev/null +++ b/internal/forward/apilog.go @@ -0,0 +1,364 @@ +package forward + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +const apiLogsDirName = "api-logs" + +var ( + apiLogMu sync.Mutex + apiLogRootOverride string // 测试用,非空时覆盖 programDir()/api-logs +) + +type apiLogCtxKey struct{} + +// apiLogCollector 单次 HTTP 请求的三段日志采集器(入站 / 出站 / 上游回复)。 +type apiLogCollector struct { + mu sync.Mutex + + Kind string + Target string + InboundBody []byte + + outboundURL string + outboundHeaders http.Header + outboundSet bool + + upstreamStatus int + upstreamHeaders http.Header + upstreamBody []byte + upstreamSet bool +} + +func newAPILogCollector(inboundBody []byte) *apiLogCollector { + return &apiLogCollector{InboundBody: inboundBody} +} + +func withAPILogCollector(r *http.Request, c *apiLogCollector) *http.Request { + return r.WithContext(context.WithValue(r.Context(), apiLogCtxKey{}, c)) +} + +func apiLogCollectorFrom(ctx context.Context) *apiLogCollector { + c, _ := ctx.Value(apiLogCtxKey{}).(*apiLogCollector) + return c +} + +func (c *apiLogCollector) SetMeta(kind, target string) { + if c == nil { + return + } + c.mu.Lock() + defer c.mu.Unlock() + c.Kind = kind + c.Target = target +} + +func (c *apiLogCollector) SetOutbound(targetURL string, headers http.Header) { + if c == nil { + return + } + c.mu.Lock() + defer c.mu.Unlock() + c.outboundURL = targetURL + c.outboundHeaders = cloneHeader(headers) + c.outboundSet = true +} + +func (c *apiLogCollector) SetUpstream(status int, headers http.Header, body []byte) { + if c == nil { + return + } + c.mu.Lock() + defer c.mu.Unlock() + c.upstreamStatus = status + c.upstreamHeaders = cloneHeader(headers) + c.upstreamBody = append([]byte(nil), body...) + c.upstreamSet = true +} + +func (c *apiLogCollector) snapshot() (kind, target, outboundURL string, outboundHdr, upstreamHdr http.Header, inboundBody, upstreamBody []byte, outboundSet, upstreamSet bool, upstreamStatus int) { + if c == nil { + return + } + c.mu.Lock() + defer c.mu.Unlock() + return c.Kind, c.Target, c.outboundURL, c.outboundHeaders, c.upstreamHeaders, c.InboundBody, c.upstreamBody, c.outboundSet, c.upstreamSet, c.upstreamStatus +} + +func apiLogRoot() string { + if apiLogRootOverride != "" { + return apiLogRootOverride + } + return filepath.Join(programDir(), apiLogsDirName) +} + +// APILogRoot 返回 api-logs 根目录(供 testweb 等子包通过 Deps 注入使用)。 +func APILogRoot() string { + return apiLogRoot() +} + +// initAPILog 创建程序同级 api-logs 根目录。 +func initAPILog() error { + return os.MkdirAll(apiLogRoot(), 0o755) +} + +// logHTTP 记录每次 HTTP 请求的三段 api-log(入站 / 出站 / 回复),按小时子目录、每请求单文件。 +func logHTTP(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + reqBody, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "read request body failed", http.StatusInternalServerError) + return + } + r.Body = io.NopCloser(bytes.NewReader(reqBody)) + + collector := newAPILogCollector(reqBody) + r = withAPILogCollector(r, collector) + + cw := &captureResponseWriter{ResponseWriter: w, status: http.StatusOK} + next.ServeHTTP(cw, r) + + if err := writeAPILogFile(r, collector, cw, time.Since(start)); err != nil { + appLogf("%s | api-log write error | %v", time.Now().Format(time.RFC3339), err) + } + }) +} + +type captureResponseWriter struct { + http.ResponseWriter + status int + wroteHeader bool + body bytes.Buffer + capturedHdr http.Header +} + +func (c *captureResponseWriter) WriteHeader(statusCode int) { + if !c.wroteHeader { + c.status = statusCode + c.wroteHeader = true + c.capturedHdr = cloneHeader(c.ResponseWriter.Header()) + } + c.ResponseWriter.WriteHeader(statusCode) +} + +func (c *captureResponseWriter) Write(b []byte) (int, error) { + if !c.wroteHeader { + c.WriteHeader(http.StatusOK) + } + _, _ = c.body.Write(b) + return c.ResponseWriter.Write(b) +} + +func cloneHeader(h http.Header) http.Header { + out := make(http.Header, len(h)) + for k, vs := range h { + cp := make([]string, len(vs)) + copy(cp, vs) + out[k] = cp + } + return out +} + +func writeAPILogFile(r *http.Request, col *apiLogCollector, cw *captureResponseWriter, elapsed time.Duration) error { + now := time.Now() + hourDir := now.Format("2006-01-02 15") + dir := filepath.Join(apiLogRoot(), hourDir) + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + + name := fmt.Sprintf("%s_%s_%s.log", + now.Format("20060102150405"), + pathSlug(r.URL.Path), + shortID(), + ) + path := filepath.Join(dir, name) + + kind, _, outboundURL, outboundHdr, upstreamHdr, inboundBody, upstreamBody, outboundSet, upstreamSet, upstreamStatus := col.snapshot() + if kind == "" { + kind = pathSlug(r.URL.Path) + } + + var buf bytes.Buffer + fmt.Fprintf(&buf, "=== META ===\n") + fmt.Fprintf(&buf, "Time: %s\n", now.Format(time.RFC3339Nano)) + fmt.Fprintf(&buf, "ClientIP: %s\n", clientIP(r)) + fmt.Fprintf(&buf, "Method: %s\n", r.Method) + fmt.Fprintf(&buf, "Path: %s\n", r.URL.Path) + fmt.Fprintf(&buf, "Kind: %s\n", kind) + if r.URL.RawQuery != "" { + fmt.Fprintf(&buf, "Query: %s\n", r.URL.RawQuery) + } + fmt.Fprintf(&buf, "DurationMs: %d\n", elapsed.Milliseconds()) + fmt.Fprintf(&buf, "Status: %d\n\n", cw.status) + + writeInboundBlock(&buf, r, kind, inboundBody) + + buf.WriteString("\n=== 2. 出站转发(forward → 政务云)===\n") + if outboundSet { + fmt.Fprintf(&buf, "TargetURL: %s\n", outboundURL) + fmt.Fprintf(&buf, "Method: %s\n\n", http.MethodPost) + buf.WriteString("Headers:\n") + writeHeaders(&buf, outboundHdr) + buf.WriteString("\nBody:\n") + writeLogBody(&buf, kind, outboundHdr, inboundBody) + } else { + buf.WriteString("(未发起出站转发)\n") + } + + buf.WriteString("\n=== 3. 回复(政务云 → forward → transit)===\n") + if upstreamSet { + writeResponseBlock(&buf, upstreamStatus, upstreamHdr, upstreamBody) + } else { + buf.WriteString("UpstreamStatus: (无上游响应)\n\n") + buf.WriteString("ClientResponseHeaders:\n") + if cw.capturedHdr != nil { + writeHeaders(&buf, cw.capturedHdr) + } + buf.WriteString("\nBody:\n") + buf.Write(cw.body.Bytes()) + if cw.capturedHdr != nil && isJSONContentType(cw.capturedHdr) { + writeBodyJSON(&buf, cw.body.Bytes()) + } + } + buf.WriteByte('\n') + + apiLogMu.Lock() + defer apiLogMu.Unlock() + return os.WriteFile(path, buf.Bytes(), 0o644) +} + +func writeInboundBlock(buf *bytes.Buffer, r *http.Request, kind string, body []byte) { + buf.WriteString("=== 1. 入站请求(transit → forward)===\n") + inboundURL := r.URL.String() + if r.URL.Scheme == "" { + inboundURL = fmt.Sprintf("%s %s%s", r.Method, r.Host, r.URL.RequestURI()) + } else { + inboundURL = fmt.Sprintf("%s %s", r.Method, inboundURL) + } + fmt.Fprintf(buf, "URL: %s\n\n", inboundURL) + buf.WriteString("Headers:\n") + writeHeaders(buf, r.Header) + buf.WriteString("\nBody:\n") + writeLogBody(buf, kind, r.Header, body) +} + +func writeLogBody(buf *bytes.Buffer, kind string, headers http.Header, body []byte) { + if len(body) == 0 { + buf.WriteString("(empty)\n") + return + } + hdrMap := headerToMap(headers) + if plainBodyForLog(kind, hdrMap, body) { + buf.Write(body) + buf.WriteByte('\n') + return + } + fmt.Fprintf(buf, "body_len=%d\nbody_base64=%s\n", len(body), base64.StdEncoding.EncodeToString(body)) +} + +func plainBodyForLog(kind string, headers map[string][]string, body []byte) bool { + return plainBodyForDryRun(kind, headers, body) +} + +func headerToMap(h http.Header) map[string][]string { + out := make(map[string][]string, len(h)) + for k, vs := range h { + cp := make([]string, len(vs)) + copy(cp, vs) + out[k] = cp + } + return out +} + +func writeResponseBlock(buf *bytes.Buffer, status int, headers http.Header, body []byte) { + fmt.Fprintf(buf, "UpstreamStatus: %d\n\n", status) + buf.WriteString("UpstreamHeaders:\n") + writeHeaders(buf, headers) + buf.WriteString("\nBody:\n") + if len(body) == 0 { + buf.WriteString("(empty)\n") + } else { + buf.Write(body) + buf.WriteByte('\n') + } + if isJSONContentType(headers) { + writeBodyJSON(buf, body) + } +} + +func writeBodyJSON(buf *bytes.Buffer, body []byte) { + var v any + if err := json.Unmarshal(body, &v); err != nil { + return + } + indented, err := json.MarshalIndent(v, "", " ") + if err != nil { + return + } + buf.WriteString("\nBodyJSON:\n") + buf.Write(indented) + buf.WriteByte('\n') +} + +func isJSONContentType(h http.Header) bool { + ct := strings.ToLower(h.Get("Content-Type")) + return strings.Contains(ct, "json") +} + +func writeHeaders(w *bytes.Buffer, h http.Header) { + for k, vs := range h { + for _, v := range vs { + fmt.Fprintf(w, "%s: %s\n", k, v) + } + } +} + +func pathSlug(path string) string { + switch path { + case "/province/supervise/data": + return "supervise" + case "/mng/file/auth/upload": + return "file" + case "/health": + return "health" + default: + s := strings.Trim(path, "/") + if s == "" { + return "root" + } + s = strings.ReplaceAll(s, "/", "_") + if len(s) > 32 { + s = s[:32] + } + return s + } +} + +func shortID() string { + var b [4]byte + if _, err := rand.Read(b[:]); err != nil { + return "0000" + } + return hex.EncodeToString(b[:]) +} + +// setAPILogRootForTest 仅供测试注入日志根目录。 +func setAPILogRootForTest(root string) { + apiLogRootOverride = root +} diff --git a/internal/forward/apilog_test.go b/internal/forward/apilog_test.go new file mode 100644 index 0000000..721dfe2 --- /dev/null +++ b/internal/forward/apilog_test.go @@ -0,0 +1,219 @@ +package forward + +import ( + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestLogHTTP_writesThreeBlockLog(t *testing.T) { + tmp := t.TempDir() + setAPILogRootForTest(tmp) + t.Cleanup(func() { setAPILogRootForTest("") }) + + const ( + reqBody = `{"hello":"world"}` + respBody = `{"code":200,"msg":"ok"}` + ) + + mux := http.NewServeMux() + mux.HandleFunc("/province/supervise/data", func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("X-Test-Token"); got != "secret-value-12345" { + t.Errorf("header=%q", got) + } + b, _ := io.ReadAll(r.Body) + if string(b) != reqBody { + t.Errorf("body=%q", b) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(respBody)) + }) + + srv := httptest.NewServer(logHTTP(mux)) + defer srv.Close() + + req, err := http.NewRequest(http.MethodPost, srv.URL+"/province/supervise/data", strings.NewReader(reqBody)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("X-Test-Token", "secret-value-12345") + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status=%d", resp.StatusCode) + } + + content := readOnlyAPILogFile(t, tmp) + + checks := []string{ + "=== META ===", + "Kind: supervise", + "=== 1. 入站请求(transit → forward)===", + "X-Test-Token: secret-value-12345", + reqBody, + "=== 2. 出站转发(forward → 政务云)===", + "(未发起出站转发)", + "=== 3. 回复(政务云 → forward → transit)===", + respBody, + `"code": 200`, + "BodyJSON:", + } + for _, c := range checks { + if !strings.Contains(content, c) { + t.Errorf("log missing %q\n---\n%s", c, content) + } + } +} + +func TestLogHTTP_forwardProxyThreeBlocks(t *testing.T) { + tmp := t.TempDir() + setAPILogRootForTest(tmp) + t.Cleanup(func() { setAPILogRootForTest("") }) + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/mng/file/auth/upload" { + t.Errorf("path=%s", r.URL.Path) + } + if r.Header.Get("X-Authorization") != "tok" { + t.Errorf("X-Authorization=%q", r.Header.Get("X-Authorization")) + } + if r.Header.Get("X-Forward-Token") != "" { + t.Error("X-Forward-Token must not reach upstream") + } + b, _ := io.ReadAll(r.Body) + if string(b) != "pdf-bytes" { + t.Errorf("body=%q", b) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"success":true,"record":{"fileId":"abc123"}}`)) + })) + defer upstream.Close() + + fileTarget := strings.TrimRight(upstream.URL, "/") + "/mng/file/auth/upload" + proxy, err := newReverseProxy(fileTarget, "file") + if err != nil { + t.Fatal(err) + } + + mux := http.NewServeMux() + mux.HandleFunc("/mng/file/auth/upload", func(w http.ResponseWriter, r *http.Request) { + handleForward(w, r, "file", proxy, fileTarget, "", "", true) + }) + + srv := httptest.NewServer(logHTTP(mux)) + defer srv.Close() + + req, err := http.NewRequest(http.MethodPost, srv.URL+"/mng/file/auth/upload", strings.NewReader("pdf-bytes")) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "multipart/form-data; boundary=abc") + req.Header.Set("X-Authorization", "tok") + req.Header.Set("X-Forward-Token", "should-strip") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status=%d", resp.StatusCode) + } + + content := readOnlyAPILogFile(t, tmp) + checks := []string{ + "Kind: file", + "=== 1. 入站请求(transit → forward)===", + "X-Forward-Token: should-strip", + "body_len=9", + "=== 2. 出站转发(forward → 政务云)===", + "TargetURL: " + fileTarget, + "X-Authorization: tok", + "=== 3. 回复(政务云 → forward → transit)===", + "UpstreamStatus: 200", + `"fileId": "abc123"`, + "BodyJSON:", + } + for _, c := range checks { + if !strings.Contains(content, c) { + t.Errorf("log missing %q\n---\n%s", c, content) + } + } + if strings.Contains(content, "X-Forward-Token:") && strings.Contains(content, "=== 2. 出站转发") { + // X-Forward-Token must not appear in outbound headers block + outboundSection := content[strings.Index(content, "=== 2. 出站转发"):strings.Index(content, "=== 3. 回复")] + if strings.Contains(outboundSection, "X-Forward-Token") { + t.Errorf("outbound block leaked X-Forward-Token:\n%s", outboundSection) + } + } +} + +func TestLogHTTP_forbiddenHasOutboundAndClientResponse(t *testing.T) { + tmp := t.TempDir() + setAPILogRootForTest(tmp) + t.Cleanup(func() { setAPILogRootForTest("") }) + + mux := http.NewServeMux() + mux.HandleFunc("/province/supervise/data", func(w http.ResponseWriter, r *http.Request) { + handleForward(w, r, "supervise", nil, "http://example.com/up", "10.0.0.1", "secret", true) + }) + + srv := httptest.NewServer(logHTTP(mux)) + defer srv.Close() + + req, err := http.NewRequest(http.MethodPost, srv.URL+"/province/supervise/data", strings.NewReader(`{}`)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Forward-Token", "wrong") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("status=%d", resp.StatusCode) + } + + content := readOnlyAPILogFile(t, tmp) + checks := []string{ + "=== 2. 出站转发(forward → 政务云)===", + "TargetURL: http://example.com/up", + "=== 3. 回复(政务云 → forward → transit)===", + "forbidden", + } + for _, c := range checks { + if !strings.Contains(content, c) { + t.Errorf("log missing %q\n---\n%s", c, content) + } + } +} + +func readOnlyAPILogFile(t *testing.T, root string) string { + t.Helper() + hourDirs, err := os.ReadDir(root) + if err != nil || len(hourDirs) == 0 { + t.Fatalf("no hour dir under %s: %v", root, err) + } + files, err := os.ReadDir(filepath.Join(root, hourDirs[0].Name())) + if err != nil || len(files) != 1 { + t.Fatalf("want 1 log file, got %d err=%v", len(files), err) + } + raw, err := os.ReadFile(filepath.Join(root, hourDirs[0].Name(), files[0].Name())) + if err != nil { + t.Fatal(err) + } + return string(raw) +} diff --git a/internal/forward/proxy.go b/internal/forward/proxy.go index 018a79a..e797048 100644 --- a/internal/forward/proxy.go +++ b/internal/forward/proxy.go @@ -1,6 +1,8 @@ package forward import ( + "bytes" + "context" "crypto/tls" "encoding/base64" "encoding/json" @@ -16,6 +18,11 @@ import ( "unicode/utf8" ) +const ( + outboundDialTimeout = 10 * time.Second + outboundResponseHeaderTimeout = 60 * time.Second +) + // dryRunResponse 为 ENABLE_FORWARD=false 时返回给 transit-go 的 JSON 结构(回显将要转发的出站内容)。 type dryRunResponse struct { DryRun bool `json:"dry_run"` @@ -35,13 +42,30 @@ func newReverseProxy(target, kind string) (*httputil.ReverseProxy, error) { return nil, err } proxy := httputil.NewSingleHostReverseProxy(targetURL) - // 步骤 2:政务网自签证书,TLS 跳过校验(与现网一致) + // 步骤 2:政务网自签证书,TLS 跳过校验;出站连接/读头超时便于快速失败 proxy.Transport = &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // 政务网自签证书 + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // 政务网自签证书 + DialContext: (&net.Dialer{Timeout: outboundDialTimeout}).DialContext, + ResponseHeaderTimeout: outboundResponseHeaderTimeout, } - // 步骤 3:每次转发前改写出站 URL 与 Header(Body 由 ReverseProxy 原样流式透传) + // 步骤 3:每次转发前改写出站 URL 与 Header,并记录出站快照供 api-log 第 2 块 proxy.Director = func(req *http.Request) { applyOutboundTarget(req, targetURL, kind) + if col := apiLogCollectorFrom(req.Context()); col != nil { + col.SetOutbound(req.URL.String(), req.Header) + } + } + // 步骤 4:捕获上游响应供 api-log 第 3 块,Body 回填后继续透传 + proxy.ModifyResponse = func(resp *http.Response) error { + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + resp.Body = io.NopCloser(bytes.NewReader(body)) + if col := apiLogCollectorFrom(resp.Request.Context()); col != nil { + col.SetUpstream(resp.StatusCode, resp.Header, body) + } + return nil } return proxy, nil } @@ -59,32 +83,62 @@ func applyOutboundTarget(req *http.Request, targetURL *url.URL, kind string) { req.Header = filtered } -// buildOutboundSnapshot 读取入站 Body,按与真实转发相同规则组装出站 URL/Header/Body,供回显模式使用。 -func buildOutboundSnapshot(r *http.Request, target, kind string) (targetURL string, headers map[string][]string, body []byte, err error) { +// buildOutboundFromInbound 按 Director 相同规则组装出站 URL/Header(不读 Body)。 +func buildOutboundFromInbound(headers http.Header, target, kind string) (targetURL string, hdr map[string][]string, err error) { parsed, err := url.Parse(target) if err != nil { - return "", nil, nil, err + return "", nil, err } + filtered := make(http.Header) + copyAllowedHeaders(filtered, headers, kind) + hdr = make(map[string][]string, len(filtered)) + for k, vs := range filtered { + cp := make([]string, len(vs)) + copy(cp, vs) + hdr[k] = cp + } + return parsed.String(), hdr, nil +} + +// recordOutboundSnapshot 将出站快照写入 api-log 采集器(403/dry-run 等未走 Director 时使用)。 +func recordOutboundSnapshot(ctx context.Context, headers http.Header, target, kind string) { + col := apiLogCollectorFrom(ctx) + if col == nil { + return + } + targetURL, hdrMap, err := buildOutboundFromInbound(headers, target, kind) + if err != nil { + return + } + filtered := make(http.Header) + for k, vs := range hdrMap { + for _, v := range vs { + filtered.Add(k, v) + } + } + col.SetOutbound(targetURL, filtered) +} + +// buildOutboundSnapshot 读取入站 Body,按与真实转发相同规则组装出站 URL/Header/Body,供回显模式使用。 +func buildOutboundSnapshot(r *http.Request, target, kind string) (targetURL string, headers map[string][]string, body []byte, err error) { body, err = io.ReadAll(r.Body) if err != nil { return "", nil, nil, err } _ = r.Body.Close() - filtered := make(http.Header) - copyAllowedHeaders(filtered, r.Header, kind) - - hdr := make(map[string][]string, len(filtered)) - for k, vs := range filtered { - cp := make([]string, len(vs)) - copy(cp, vs) - hdr[k] = cp + targetURL, headers, err = buildOutboundFromInbound(r.Header, target, kind) + if err != nil { + return "", nil, nil, err } - return parsed.String(), hdr, body, nil + return targetURL, headers, body, nil } // handleForward 处理监管/文件两条转发路由:校验 → 回显或真实转发 → 记日志。 func handleForward(w http.ResponseWriter, r *http.Request, kind string, proxy *httputil.ReverseProxy, target, allowIPs, forwardSecret string, enableForward bool) { + if col := apiLogCollectorFrom(r.Context()); col != nil { + col.SetMeta(kind, target) + } // 步骤 1:仅允许 POST if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) @@ -93,12 +147,14 @@ func handleForward(w http.ResponseWriter, r *http.Request, kind string, proxy *h ip := clientIP(r) // 步骤 2:来源 IP 白名单(ALLOW_IPS 非空时生效) if allowIPs != "" && !ipAllowed(ip, allowIPs) { + recordOutboundSnapshot(r.Context(), r.Header, target, kind) appLogf("%s | %s | %s | forbidden | not in ALLOW_IPS", time.Now().Format(time.RFC3339), ip, kind) http.Error(w, "forbidden", http.StatusForbidden) return } // 步骤 3:内网共享密钥(FORWARD_SHARED_SECRET 非空时校验 X-Forward-Token) if forwardSecret != "" && r.Header.Get("X-Forward-Token") != forwardSecret { + recordOutboundSnapshot(r.Context(), r.Header, target, kind) appLogf("%s | %s | %s | forbidden | bad forward token", time.Now().Format(time.RFC3339), ip, kind) http.Error(w, "forbidden", http.StatusForbidden) return @@ -128,6 +184,7 @@ func handleDryRun(w http.ResponseWriter, r *http.Request, kind, target, ip strin http.Error(w, "internal error", http.StatusInternalServerError) return } + recordOutboundSnapshot(r.Context(), r.Header, target, kind) // 步骤 2:构造 JSON(body 明文仅 UTF-8 且非 multipart 时填充;body_base64 始终提供) resp := dryRunResponse{ DryRun: true, diff --git a/internal/forward/run.go b/internal/forward/run.go index 186d88f..39916e8 100644 --- a/internal/forward/run.go +++ b/internal/forward/run.go @@ -5,6 +5,8 @@ import ( "fmt" "log" "net/http" + + "xk-hy-forward-go/internal/forward/testweb" ) // Run 启动 forward 全部 HTTP 服务:/health、/province/supervise/data、/mng/file/auth/upload。 @@ -15,6 +17,9 @@ func Run() { if err := initAppLog(); err != nil { log.Printf("应用日志初始化失败: %v", err) } + if err := initAPILog(); err != nil { + log.Printf("api-logs 目录初始化失败: %v", err) + } // 步骤 3:读取监听地址、访问控制、是否真实转发 listen := env("LISTEN_ADDR", ":16001") @@ -57,12 +62,19 @@ func Run() { handleForward(w, r, "file", fileProxy, fileTarget, allowIPs, forwardSecret, enableForward) }) + // 步骤 8b:政务云测试页 /t(ApiPost 风格,读 file api-logs、连通测试、导出 Postman) + testweb.Register(mux, testweb.Deps{ + SuperviseTarget: superviseTarget, + FileTarget: fileTarget, + APILogRoot: APILogRoot(), + }) + // 步骤 9:打印启动配置并监听 msg := fmt.Sprintf("xk-hy-forward-go 监听 %s | forward_enabled=%v | supervise=%s | file=%s", listen, enableForward, superviseTarget, fileTarget) appLogf(msg) log.Print(msg) - if err := http.ListenAndServe(listen, mux); err != nil { + if err := http.ListenAndServe(listen, logHTTP(mux)); err != nil { log.Fatal(err) } } diff --git a/internal/forward/testweb/apilog_list.go b/internal/forward/testweb/apilog_list.go new file mode 100644 index 0000000..961f043 --- /dev/null +++ b/internal/forward/testweb/apilog_list.go @@ -0,0 +1,98 @@ +package testweb + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "time" +) + +var logRelRE = regexp.MustCompile(`^\d{4}-\d{2}-\d{2} \d{2}/\d{8,14}_file_[a-f0-9]+\.log$`) + +// FileLogEntry api-logs 中的 file 通道日志条目。 +type FileLogEntry struct { + Rel string `json:"rel"` + Name string `json:"name"` + HourDir string `json:"hourDir"` + Modified string `json:"modified"` + Size int64 `json:"size"` +} + +// ListFileLogs 列出 api-logs 下所有 *_file_*.log(按修改时间倒序)。 +func ListFileLogs(root string) ([]FileLogEntry, error) { + root, err := filepath.Abs(root) + if err != nil { + return nil, err + } + var entries []FileLogEntry + err = filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if d.IsDir() { + return nil + } + name := d.Name() + if !strings.Contains(name, "_file_") || !strings.HasSuffix(name, ".log") { + return nil + } + rel, err := filepath.Rel(root, path) + if err != nil { + return err + } + rel = filepath.ToSlash(rel) + if !logRelRE.MatchString(rel) { + return nil + } + info, err := d.Info() + if err != nil { + return err + } + hourDir := filepath.ToSlash(filepath.Dir(rel)) + entries = append(entries, FileLogEntry{ + Rel: rel, + Name: name, + HourDir: hourDir, + Modified: info.ModTime().Format(time.RFC3339), + Size: info.Size(), + }) + return nil + }) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + sort.Slice(entries, func(i, j int) bool { + return entries[i].Modified > entries[j].Modified + }) + return entries, nil +} + +// resolveLogRelPath 校验 rel 并返回绝对路径。 +func resolveLogRelPath(root, rel string) (string, error) { + rel = filepath.ToSlash(strings.TrimSpace(rel)) + if rel == "" || strings.Contains(rel, "..") { + return "", fmt.Errorf("invalid rel path") + } + if !logRelRE.MatchString(rel) { + return "", fmt.Errorf("rel path not allowed") + } + root, err := filepath.Abs(root) + if err != nil { + return "", err + } + full := filepath.Join(root, filepath.FromSlash(rel)) + full, err = filepath.Abs(full) + if err != nil { + return "", err + } + if !strings.HasPrefix(full, root+string(os.PathSeparator)) && full != root { + return "", fmt.Errorf("path escape") + } + return full, nil +} diff --git a/internal/forward/testweb/apilog_parse.go b/internal/forward/testweb/apilog_parse.go new file mode 100644 index 0000000..24179e4 --- /dev/null +++ b/internal/forward/testweb/apilog_parse.go @@ -0,0 +1,318 @@ +package testweb + +import ( + "encoding/base64" + "fmt" + "os" + "regexp" + "strconv" + "strings" +) + +var ( + blockInboundRE = regexp.MustCompile(`(?m)^=== 1\. 入站请求`) + blockOutboundRE = regexp.MustCompile(`(?m)^=== 2\. 出站转发`) + blockResponseRE = regexp.MustCompile(`(?m)^=== 3\. 回复`) +) + +// ParsedAPILog 解析后的 api-log 三段结构。 +type ParsedAPILog struct { + Rel string `json:"rel"` + Meta map[string]string `json:"meta"` + Inbound RequestSection `json:"inbound"` + Outbound RequestSection `json:"outbound"` + Response ResponseSection `json:"response"` + ParseWarnings []string `json:"parseWarnings,omitempty"` + OutboundAbsent bool `json:"outboundAbsent"` +} + +// RequestSection 请求段(入站或出站)。 +type RequestSection struct { + URL string `json:"url,omitempty"` + Method string `json:"method,omitempty"` + Headers map[string]string `json:"headers"` + BodyRaw string `json:"bodyRaw,omitempty"` + BodyLen int `json:"bodyLen"` + BodyBase64 string `json:"bodyBase64,omitempty"` + BodyBytes []byte `json:"-"` + BodyPlain bool `json:"bodyPlain"` +} + +// ResponseSection 上游/客户端回复段。 +type ResponseSection struct { + Status int `json:"status,omitempty"` + StatusText string `json:"statusText,omitempty"` + Headers map[string]string `json:"headers"` + Body string `json:"body,omitempty"` + BodyJSON string `json:"bodyJSON,omitempty"` + UpstreamSet bool `json:"upstreamSet"` +} + +// ParseAPILogBytes 解析 api-log 文件内容。 +func ParseAPILogBytes(rel string, raw []byte) (*ParsedAPILog, error) { + content := string(raw) + p := &ParsedAPILog{Rel: rel} + p.Meta = parseMeta(content) + + inboundStart := blockInboundRE.FindStringIndex(content) + outboundStart := blockOutboundRE.FindStringIndex(content) + responseStart := blockResponseRE.FindStringIndex(content) + + if inboundStart == nil { + return nil, fmt.Errorf("missing inbound block") + } + + var inboundText, outboundText, responseText string + if outboundStart != nil { + inboundText = content[inboundStart[0]:outboundStart[0]] + } else if responseStart != nil { + inboundText = content[inboundStart[0]:responseStart[0]] + } else { + inboundText = content[inboundStart[0]:] + } + if outboundStart != nil { + if responseStart != nil { + outboundText = content[outboundStart[0]:responseStart[0]] + } else { + outboundText = content[outboundStart[0]:] + } + } + if responseStart != nil { + responseText = content[responseStart[0]:] + } + + var warns []string + in, w := parseRequestSection(inboundText, true) + p.Inbound = in + warns = append(warns, w...) + + if outboundText != "" { + if strings.Contains(outboundText, "(未发起出站转发)") { + p.OutboundAbsent = true + } else { + out, w2 := parseRequestSection(outboundText, false) + p.Outbound = out + warns = append(warns, w2...) + } + } else { + p.OutboundAbsent = true + } + + if responseText != "" { + p.Response = parseResponseSection(responseText) + } + p.ParseWarnings = warns + return p, nil +} + +// ParseAPILogFile 读取并解析 api-log 文件。 +func ParseAPILogFile(root, rel string) (*ParsedAPILog, error) { + path, err := resolveLogRelPath(root, rel) + if err != nil { + return nil, err + } + raw, err := os.ReadFile(path) + if err != nil { + return nil, err + } + return ParseAPILogBytes(rel, raw) +} + +func parseMeta(content string) map[string]string { + meta := make(map[string]string) + idx := strings.Index(content, "=== META ===") + if idx < 0 { + return meta + } + end := strings.Index(content[idx:], "\n\n") + section := content[idx:] + if end > 0 { + section = section[:end] + } + for _, line := range strings.Split(section, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "===") { + continue + } + if k, v, ok := strings.Cut(line, ": "); ok { + meta[k] = v + } + } + return meta +} + +func parseRequestSection(section string, inbound bool) (RequestSection, []string) { + var rs RequestSection + rs.Headers = make(map[string]string) + var warns []string + + lines := strings.Split(section, "\n") + i := 0 + for i < len(lines) && strings.TrimSpace(lines[i]) == "" { + i++ + } + for i < len(lines) { + line := lines[i] + if strings.HasPrefix(line, "URL: ") { + rs.URL = strings.TrimSpace(strings.TrimPrefix(line, "URL: ")) + if inbound && strings.HasPrefix(rs.URL, "POST ") { + rs.URL = strings.TrimSpace(strings.TrimPrefix(rs.URL, "POST ")) + } + i++ + continue + } + if strings.HasPrefix(line, "TargetURL: ") { + rs.URL = strings.TrimSpace(strings.TrimPrefix(line, "TargetURL: ")) + i++ + continue + } + if strings.HasPrefix(line, "Method: ") { + rs.Method = strings.TrimSpace(strings.TrimPrefix(line, "Method: ")) + i++ + continue + } + if strings.TrimSpace(line) == "Headers:" { + i++ + for i < len(lines) { + hl := lines[i] + if hl == "" || strings.HasPrefix(hl, "Body:") || strings.HasPrefix(hl, "===") { + break + } + if k, v, ok := strings.Cut(hl, ": "); ok { + rs.Headers[k] = v + } + i++ + } + continue + } + if strings.TrimSpace(line) == "Body:" { + i++ + bodyLines, consumed, w := readBodyLines(lines[i:]) + warns = append(warns, w...) + i += consumed + applyBodyToSection(&rs, bodyLines) + continue + } + i++ + } + return rs, warns +} + +func readBodyLines(lines []string) (bodyLines []string, consumed int, warns []string) { + for consumed < len(lines) { + line := lines[consumed] + if strings.HasPrefix(line, "===") { + break + } + bodyLines = append(bodyLines, line) + consumed++ + } + return bodyLines, consumed, warns +} + +func applyBodyToSection(rs *RequestSection, bodyLines []string) { + text := strings.Join(bodyLines, "\n") + text = strings.TrimSuffix(text, "\n") + if text == "" || text == "(empty)" { + return + } + if strings.HasPrefix(text, "body_len=") { + for _, line := range bodyLines { + if strings.HasPrefix(line, "body_len=") { + if n, err := strconv.Atoi(strings.TrimPrefix(line, "body_len=")); err == nil { + rs.BodyLen = n + } + } + if strings.HasPrefix(line, "body_base64=") { + b64 := strings.TrimPrefix(line, "body_base64=") + rs.BodyBase64 = b64 + dec, err := base64.StdEncoding.DecodeString(b64) + if err != nil { + return + } + rs.BodyBytes = dec + rs.BodyLen = len(dec) + } + } + return + } + rs.BodyRaw = text + rs.BodyPlain = true + rs.BodyBytes = []byte(text) + rs.BodyLen = len(rs.BodyBytes) +} + +func parseResponseSection(section string) ResponseSection { + var rs ResponseSection + rs.Headers = make(map[string]string) + + lines := strings.Split(section, "\n") + i := 0 + for i < len(lines) && strings.TrimSpace(lines[i]) == "" { + i++ + } + for i < len(lines) { + line := lines[i] + if strings.HasPrefix(line, "UpstreamStatus: ") { + st := strings.TrimSpace(strings.TrimPrefix(line, "UpstreamStatus: ")) + if st == "(无上游响应)" { + rs.UpstreamSet = false + } else { + rs.UpstreamSet = true + if n, err := strconv.Atoi(st); err == nil { + rs.Status = n + } + } + i++ + continue + } + if strings.TrimSpace(line) == "UpstreamHeaders:" || strings.TrimSpace(line) == "ClientResponseHeaders:" { + i++ + for i < len(lines) { + hl := lines[i] + if hl == "" || strings.HasPrefix(hl, "Body:") || strings.HasPrefix(hl, "BodyJSON:") || strings.HasPrefix(hl, "===") { + break + } + if k, v, ok := strings.Cut(hl, ": "); ok { + rs.Headers[k] = v + } + i++ + } + continue + } + if strings.TrimSpace(line) == "Body:" { + i++ + var bodyLines []string + for i < len(lines) { + if strings.TrimSpace(lines[i]) == "BodyJSON:" { + break + } + if strings.HasPrefix(lines[i], "===") { + break + } + bodyLines = append(bodyLines, lines[i]) + i++ + } + rs.Body = strings.TrimSuffix(strings.Join(bodyLines, "\n"), "\n") + continue + } + if strings.TrimSpace(line) == "BodyJSON:" { + i++ + var jsonLines []string + for i < len(lines) { + if strings.HasPrefix(lines[i], "===") { + break + } + jsonLines = append(jsonLines, lines[i]) + i++ + } + rs.BodyJSON = strings.TrimSuffix(strings.Join(jsonLines, "\n"), "\n") + continue + } + i++ + } + if rs.Status > 0 || rs.Body != "" { + rs.UpstreamSet = true + } + return rs +} diff --git a/internal/forward/testweb/apilog_parse_test.go b/internal/forward/testweb/apilog_parse_test.go new file mode 100644 index 0000000..0508d2f --- /dev/null +++ b/internal/forward/testweb/apilog_parse_test.go @@ -0,0 +1,141 @@ +package testweb + +import ( + "encoding/base64" + "os" + "path/filepath" + "strings" + "testing" +) + +const sampleLog = `=== META === +Time: 2026-05-28T08:00:00+08:00 +ClientIP: 127.0.0.1 +Method: POST +Path: /mng/file/auth/upload +Kind: file +DurationMs: 100 +Status: 200 + +=== 1. 入站请求(transit → forward)=== +URL: POST 127.0.0.1:16001/mng/file/auth/upload + +Headers: +Content-Type: multipart/form-data; boundary=abc +X-Authorization: tok-in +X-Forward-Token: secret + +Body: +body_len=3 +body_base64=Zm9v + +=== 2. 出站转发(forward → 政务云)=== +TargetURL: https://59.202.52.129:28211/mng/file/auth/upload +Method: POST + +Headers: +Content-Type: multipart/form-data; boundary=abc +X-Authorization: tok-out + +Body: +body_len=3 +body_base64=Zm9v + +=== 3. 回复(政务云 → forward → transit)=== +UpstreamStatus: 200 + +UpstreamHeaders: +Content-Type: application/json + +Body: +{"success":true,"record":{"fileId":"id1"}} + +BodyJSON: +{ + "success": true, + "record": { + "fileId": "id1" + } +} +` + +func TestParseAPILogBytes_outboundBody(t *testing.T) { + p, err := ParseAPILogBytes("2026-05-28 08/20260528080000_file_abcd.log", []byte(sampleLog)) + if err != nil { + t.Fatal(err) + } + if p.Outbound.URL != "https://59.202.52.129:28211/mng/file/auth/upload" { + t.Fatalf("outbound url=%q", p.Outbound.URL) + } + if p.Outbound.Headers["X-Authorization"] != "tok-out" { + t.Fatalf("auth=%q", p.Outbound.Headers["X-Authorization"]) + } + if string(p.Outbound.BodyBytes) != "foo" { + t.Fatalf("body=%q", p.Outbound.BodyBytes) + } + if p.Response.Status != 200 || !strings.Contains(p.Response.Body, "fileId") { + t.Fatalf("response=%+v", p.Response) + } +} + +func TestListFileLogs_and_resolve(t *testing.T) { + tmp := t.TempDir() + hour := filepath.Join(tmp, "2026-05-28 08") + if err := os.MkdirAll(hour, 0o755); err != nil { + t.Fatal(err) + } + name := "20260528080000_file_abcd.log" + if err := os.WriteFile(filepath.Join(hour, name), []byte(sampleLog), 0o644); err != nil { + t.Fatal(err) + } + list, err := ListFileLogs(tmp) + if err != nil || len(list) != 1 { + t.Fatalf("list=%v err=%v", list, err) + } + rel := list[0].Rel + p, err := ParseAPILogFile(tmp, rel) + if err != nil { + t.Fatal(err) + } + if p.Meta["Kind"] != "file" { + t.Fatalf("kind=%v", p.Meta) + } + if _, err := resolveLogRelPath(tmp, "../etc/passwd"); err == nil { + t.Fatal("expected reject ..") + } + if _, err := resolveLogRelPath(tmp, "bad/name.log"); err == nil { + t.Fatal("expected reject pattern") + } +} + +func TestToPostmanCollection_multipart(t *testing.T) { + p, err := ParseAPILogBytes("x.log", []byte(sampleLog)) + if err != nil { + t.Fatal(err) + } + data, err := ToPostmanCollection(p, false) + if err != nil { + t.Fatal(err) + } + s := string(data) + if !strings.Contains(s, "postman.com/json/collection/v2.1.0") { + t.Fatal("missing schema") + } + if !strings.Contains(s, "formdata") { + t.Fatal("expected formdata") + } +} + +func Test_applyBody_plain(t *testing.T) { + var rs RequestSection + rs.Headers = map[string]string{"Content-Type": "application/json"} + applyBodyToSection(&rs, []string{`{"a":1}`}) + if !rs.BodyPlain || string(rs.BodyBytes) != `{"a":1}` { + t.Fatalf("%+v", rs) + } + b64 := base64.StdEncoding.EncodeToString([]byte("bin")) + applyBodyToSection(&rs, []string{"body_len=3", "body_base64=" + b64}) + if string(rs.BodyBytes) != "bin" { + t.Fatalf("got %q", rs.BodyBytes) + } +} diff --git a/internal/forward/testweb/embed.go b/internal/forward/testweb/embed.go new file mode 100644 index 0000000..01002b1 --- /dev/null +++ b/internal/forward/testweb/embed.go @@ -0,0 +1,6 @@ +package testweb + +import "embed" + +//go:embed web/* +var webFS embed.FS diff --git a/internal/forward/testweb/export_postman.go b/internal/forward/testweb/export_postman.go new file mode 100644 index 0000000..dd72577 --- /dev/null +++ b/internal/forward/testweb/export_postman.go @@ -0,0 +1,117 @@ +package testweb + +import ( + "encoding/json" + "fmt" + "strings" + "time" +) + +// ToPostmanCollection 将解析后的日志转为 Postman Collection v2.1(ApiPost 可导入)。 +func ToPostmanCollection(log *ParsedAPILog, useInbound bool) ([]byte, error) { + if log == nil { + return nil, fmt.Errorf("nil log") + } + sec := log.Outbound + name := "file upload outbound" + if useInbound { + sec = log.Inbound + name = "file upload inbound" + } + if sec.URL == "" && log.Outbound.URL != "" { + sec = log.Outbound + } + + headers := make([]map[string]any, 0, len(sec.Headers)) + for k, v := range sec.Headers { + headers = append(headers, map[string]any{ + "key": k, + "value": v, + "type": "text", + }) + } + + _, bodyObj := postmanBody(sec) + url := sec.URL + if url == "" { + url = "{{fileTarget}}" + } + + item := map[string]any{ + "name": name, + "request": map[string]any{ + "method": "POST", + "header": headers, + "body": bodyObj, + "url": url, + "description": "从 forward-go api-log 导出;multipart 请在 ApiPost 中重新选择 PDF 文件", + }, + } + + if log.Response.Body != "" || log.Response.BodyJSON != "" { + body := log.Response.Body + if log.Response.BodyJSON != "" { + body = log.Response.BodyJSON + } + item["response"] = []map[string]any{{ + "name": "example", + "status": "OK", + "code": log.Response.Status, + "header": responseHeaders(log.Response.Headers), + "body": body, + "_postman_previewlanguage": "json", + }} + } + + col := map[string]any{ + "info": map[string]any{ + "name": fmt.Sprintf("forward-file-%s", time.Now().Format("20060102150405")), + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + }, + "item": []map[string]any{item}, + } + return json.MarshalIndent(col, "", " ") +} + +func postmanBody(sec RequestSection) (mode string, body map[string]any) { + ct := strings.ToLower(sec.Headers["Content-Type"]) + if strings.Contains(ct, "multipart/form-data") { + formdata := []map[string]any{ + { + "key": "file", + "type": "file", + "src": "", + "description": "请在 ApiPost 中选择 PDF;日志中为 body_base64 落盘", + }, + { + "key": "_note", + "type": "text", + "value": fmt.Sprintf("body_len=%d,从 forward api-log 导出", sec.BodyLen), + "disabled": true, + }, + } + return "formdata", map[string]any{"mode": "formdata", "formdata": formdata} + } + raw := sec.BodyRaw + if len(sec.BodyBytes) > 0 && !sec.BodyPlain { + raw = string(sec.BodyBytes) + } + if raw == "" && sec.BodyBase64 != "" { + raw = "[base64 body, len=" + fmt.Sprint(sec.BodyLen) + "]" + } + return "raw", map[string]any{ + "mode": "raw", + "raw": raw, + "options": map[string]any{ + "raw": map[string]any{"language": "json"}, + }, + } +} + +func responseHeaders(h map[string]string) []map[string]string { + out := make([]map[string]string, 0, len(h)) + for k, v := range h { + out = append(out, map[string]string{"key": k, "value": v}) + } + return out +} diff --git a/internal/forward/testweb/govtest.go b/internal/forward/testweb/govtest.go new file mode 100644 index 0000000..6c3e214 --- /dev/null +++ b/internal/forward/testweb/govtest.go @@ -0,0 +1,151 @@ +package testweb + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "io" + "net" + "net/http" + "strings" + "time" +) + +const ( + connectDialTimeout = 5 * time.Second + connectReadTimeout = 15 * time.Second + replayTimeout = 120 * time.Second +) + +func govHTTPClient(timeout time.Duration) *http.Client { + return &http.Client{ + Timeout: timeout, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // 政务网自签证书 + DialContext: (&net.Dialer{Timeout: connectDialTimeout}).DialContext, + ResponseHeaderTimeout: connectReadTimeout, + }, + } +} + +// ConnectResult 政务云连通性探测结果。 +type ConnectResult struct { + URL string `json:"url"` + Channel string `json:"channel"` + ElapsedMs int64 `json:"elapsedMs"` + HTTPStatus int `json:"httpStatus,omitempty"` + OK bool `json:"ok"` + Error string `json:"error,omitempty"` + Phase string `json:"phase,omitempty"` +} + +// TestConnectivity 对 target URL 发起 POST 探测。 +func TestConnectivity(channel, target string) ConnectResult { + res := ConnectResult{URL: target, Channel: channel} + start := time.Now() + defer func() { res.ElapsedMs = time.Since(start).Milliseconds() }() + + ctx, cancel := context.WithTimeout(context.Background(), connectReadTimeout+connectDialTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, target, strings.NewReader("{}")) + if err != nil { + res.Error = err.Error() + res.Phase = "build_request" + return res + } + req.Header.Set("Content-Type", "application/json") + + resp, err := govHTTPClient(connectReadTimeout + connectDialTimeout).Do(req) + if err != nil { + res.Error = err.Error() + if strings.Contains(err.Error(), "timeout") || strings.Contains(err.Error(), "deadline") { + res.Phase = "timeout" + } else if strings.Contains(err.Error(), "connection refused") { + res.Phase = "tcp" + } else if strings.Contains(err.Error(), "tls") || strings.Contains(err.Error(), "certificate") { + res.Phase = "tls" + } else { + res.Phase = "network" + } + return res + } + defer resp.Body.Close() + _, _ = io.Copy(io.Discard, resp.Body) + res.HTTPStatus = resp.StatusCode + res.OK = true + res.Phase = "http" + return res +} + +// ReplayResult 回放请求结果。 +type ReplayResult struct { + URL string `json:"url"` + ElapsedMs int64 `json:"elapsedMs"` + HTTPStatus int `json:"httpStatus"` + Headers map[string]string `json:"headers"` + Body string `json:"body"` + BodyJSON string `json:"bodyJSON,omitempty"` + Error string `json:"error,omitempty"` +} + +// ReplayRequest 向政务云回放 POST。 +func ReplayRequest(url string, headers map[string]string, body []byte) ReplayResult { + res := ReplayResult{URL: url, Headers: make(map[string]string)} + start := time.Now() + defer func() { res.ElapsedMs = time.Since(start).Milliseconds() }() + + var bodyReader io.Reader + if len(body) > 0 { + bodyReader = bytes.NewReader(body) + } else { + bodyReader = strings.NewReader("") + } + req, err := http.NewRequest(http.MethodPost, url, bodyReader) + if err != nil { + res.Error = err.Error() + return res + } + for k, v := range headers { + if strings.EqualFold(k, "X-Forward-Token") { + continue + } + req.Header.Set(k, v) + } + + resp, err := govHTTPClient(replayTimeout).Do(req) + if err != nil { + res.Error = err.Error() + return res + } + defer resp.Body.Close() + res.HTTPStatus = resp.StatusCode + for k, vs := range resp.Header { + if len(vs) > 0 { + res.Headers[k] = vs[0] + } + } + raw, _ := io.ReadAll(resp.Body) + res.Body = string(raw) + if strings.Contains(strings.ToLower(resp.Header.Get("Content-Type")), "json") { + res.BodyJSON = formatJSON(raw) + } + return res +} + +func formatJSON(raw []byte) string { + s := strings.TrimSpace(string(raw)) + if s == "" { + return "" + } + var v any + if json.Unmarshal(raw, &v) != nil { + return s + } + b, err := json.MarshalIndent(v, "", " ") + if err != nil { + return s + } + return string(b) +} diff --git a/internal/forward/testweb/server.go b/internal/forward/testweb/server.go new file mode 100644 index 0000000..0d67892 --- /dev/null +++ b/internal/forward/testweb/server.go @@ -0,0 +1,259 @@ +package testweb + +import ( + "encoding/base64" + "encoding/json" + "io" + "io/fs" + "net/http" + "strings" +) + +// Deps testweb 依赖。 +type Deps struct { + SuperviseTarget string + FileTarget string + APILogRoot string +} + +// Register 注册 /t 页面与 API。 +func Register(mux *http.ServeMux, deps Deps) { + s := &server{deps: deps} + sub, _ := fs.Sub(webFS, "web") + static := http.FileServer(http.FS(sub)) + + mux.HandleFunc("/t", s.handlePage) + mux.HandleFunc("/t/", func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/t/static/") { + http.StripPrefix("/t/static/", static).ServeHTTP(w, r) + return + } + if r.URL.Path == "/t" || r.URL.Path == "/t/" { + s.handlePage(w, r) + return + } + http.NotFound(w, r) + }) + + mux.HandleFunc("/t/api/meta", s.handleMeta) + mux.HandleFunc("/t/api/logs", s.handleLogs) + mux.HandleFunc("/t/api/logs/detail", s.handleLogDetail) + mux.HandleFunc("/t/api/logs/export", s.handleLogExport) + mux.HandleFunc("/t/api/test/connect", s.handleTestConnect) + mux.HandleFunc("/t/api/test/send", s.handleTestSend) +} + +type server struct { + deps Deps +} + +func (s *server) handlePage(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/t" && r.URL.Path != "/t/" { + http.NotFound(w, r) + return + } + b, err := webFS.ReadFile("web/index.html") + if err != nil { + http.Error(w, "page not found", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write(b) +} + +func (s *server) handleMeta(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, map[string]any{ + "superviseTarget": s.deps.SuperviseTarget, + "fileTarget": s.deps.FileTarget, + "apiLogRoot": s.deps.APILogRoot, + }) +} + +func (s *server) handleLogs(w http.ResponseWriter, _ *http.Request) { + list, err := ListFileLogs(s.deps.APILogRoot) + if err != nil { + writeErr(w, err.Error(), http.StatusInternalServerError) + return + } + if list == nil { + list = []FileLogEntry{} + } + writeJSON(w, list) +} + +func (s *server) handleLogDetail(w http.ResponseWriter, r *http.Request) { + rel := r.URL.Query().Get("rel") + parsed, err := ParseAPILogFile(s.deps.APILogRoot, rel) + if err != nil { + writeErr(w, err.Error(), http.StatusBadRequest) + return + } + out := toDetailDTO(parsed) + writeJSON(w, out) +} + +type logDetailJSON struct { + Rel string `json:"rel"` + Meta map[string]string `json:"meta"` + Inbound sectionDTO `json:"inbound"` + Outbound sectionDTO `json:"outbound"` + Response ResponseSection `json:"response"` + ParseWarnings []string `json:"parseWarnings,omitempty"` + OutboundAbsent bool `json:"outboundAbsent"` +} + +type sectionDTO struct { + URL string `json:"url,omitempty"` + Method string `json:"method,omitempty"` + Headers map[string]string `json:"headers"` + BodyRaw string `json:"bodyRaw,omitempty"` + BodyLen int `json:"bodyLen"` + BodyBase64 string `json:"bodyBase64,omitempty"` + BodyPlain bool `json:"bodyPlain"` +} + +func toDetailDTO(p *ParsedAPILog) logDetailJSON { + return logDetailJSON{ + Rel: p.Rel, + Meta: p.Meta, + Inbound: sectionDTOFrom(p.Inbound), + Outbound: sectionDTOFrom(p.Outbound), + Response: p.Response, + ParseWarnings: p.ParseWarnings, + OutboundAbsent: p.OutboundAbsent, + } +} + +func sectionDTOFrom(sec RequestSection) sectionDTO { + d := sectionDTO{ + URL: sec.URL, + Method: sec.Method, + Headers: sec.Headers, + BodyRaw: sec.BodyRaw, + BodyLen: sec.BodyLen, + BodyBase64: sec.BodyBase64, + BodyPlain: sec.BodyPlain, + } + if len(sec.BodyBytes) > 0 && !sec.BodyPlain { + d.BodyBase64 = base64.StdEncoding.EncodeToString(sec.BodyBytes) + } + return d +} + +func (s *server) handleLogExport(w http.ResponseWriter, r *http.Request) { + rel := r.URL.Query().Get("rel") + useInbound := r.URL.Query().Get("source") == "inbound" + parsed, err := ParseAPILogFile(s.deps.APILogRoot, rel) + if err != nil { + writeErr(w, err.Error(), http.StatusBadRequest) + return + } + data, err := ToPostmanCollection(parsed, useInbound) + if err != nil { + writeErr(w, err.Error(), http.StatusInternalServerError) + return + } + name := "forward-file-" + strings.ReplaceAll(rel, "/", "_") + ".postman_collection.json" + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Content-Disposition", "attachment; filename=\""+name+"\"") + _, _ = w.Write(data) +} + +func (s *server) handleTestConnect(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var req struct { + Channel string `json:"channel"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, "invalid json", http.StatusBadRequest) + return + } + ch := strings.ToLower(strings.TrimSpace(req.Channel)) + var results []ConnectResult + switch ch { + case "supervise", "28212": + results = append(results, TestConnectivity("supervise", s.deps.SuperviseTarget)) + case "file", "28211": + results = append(results, TestConnectivity("file", s.deps.FileTarget)) + default: + results = append(results, + TestConnectivity("supervise", s.deps.SuperviseTarget), + TestConnectivity("file", s.deps.FileTarget), + ) + } + writeJSON(w, map[string]any{"results": results}) +} + +func (s *server) handleTestSend(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + body, _ := io.ReadAll(r.Body) + var req struct { + Rel string `json:"rel"` + UseOutbound bool `json:"useOutbound"` + URL string `json:"url"` + Headers map[string]string `json:"headers"` + BodyBase64 string `json:"bodyBase64"` + } + if err := json.Unmarshal(body, &req); err != nil { + writeErr(w, "invalid json", http.StatusBadRequest) + return + } + + url := strings.TrimSpace(req.URL) + headers := req.Headers + var payload []byte + + if req.Rel != "" { + parsed, err := ParseAPILogFile(s.deps.APILogRoot, req.Rel) + if err != nil { + writeErr(w, err.Error(), http.StatusBadRequest) + return + } + sec := parsed.Outbound + if !req.UseOutbound { + sec = parsed.Inbound + } + if url == "" { + url = sec.URL + } + if len(headers) == 0 { + headers = sec.Headers + } + payload = sec.BodyBytes + } + if req.BodyBase64 != "" { + dec, err := base64.StdEncoding.DecodeString(req.BodyBase64) + if err != nil { + writeErr(w, "invalid bodyBase64", http.StatusBadRequest) + return + } + payload = dec + } + if url == "" { + writeErr(w, "url required", http.StatusBadRequest) + return + } + if headers == nil { + headers = map[string]string{} + } + + result := ReplayRequest(url, headers, payload) + writeJSON(w, result) +} + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(v) +} + +func writeErr(w http.ResponseWriter, msg string, code int) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(map[string]string{"error": msg}) +} diff --git a/internal/forward/testweb/web/index.html b/internal/forward/testweb/web/index.html new file mode 100644 index 0000000..f921cb4 --- /dev/null +++ b/internal/forward/testweb/web/index.html @@ -0,0 +1,84 @@ + + + + + + 政务云连接测试 · forward-go + + + +
+
+
+ + forward 政务云测试 +
+
+ 连通测试 + + + +
+
+
+ +
+ + +
+
+ + + + + +
+ +
+ + + +
+ +
+ + + + +
+ +
+
+ + + +
KeyValue
+ +
+
+
+ + +
+
+
(发送后或从日志块3 加载)
+
+
+

+        
+
+
+
+
+ + + diff --git a/internal/forward/testweb/web/t.css b/internal/forward/testweb/web/t.css new file mode 100644 index 0000000..3ccf8ed --- /dev/null +++ b/internal/forward/testweb/web/t.css @@ -0,0 +1,224 @@ +:root { + --bg: #1e1e2e; + --sidebar: #181825; + --surface: #252536; + --surface2: #2d2d44; + --border: #3d3d5c; + --text: #e8e8f0; + --muted: #9393b0; + --accent: #7c6cf0; + --accent-hover: #9588f5; + --send: #f59e0b; + --send-hover: #fbbf24; + --ok: #4ade80; + --err: #f87171; + --warn: #fbbf24; +} +* { box-sizing: border-box; } +body { + margin: 0; + font-family: "Segoe UI", "PingFang SC", system-ui, sans-serif; + background: var(--bg); + color: var(--text); + font-size: 13px; + height: 100vh; + overflow: hidden; +} +.app { display: flex; flex-direction: column; height: 100vh; } +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem 1rem; + background: var(--sidebar); + border-bottom: 1px solid var(--border); + flex-wrap: wrap; + gap: 0.5rem; +} +.brand { display: flex; align-items: center; gap: 0.5rem; } +.logo { + width: 28px; height: 28px; + background: linear-gradient(135deg, var(--accent), #ec4899); + border-radius: 6px; + display: flex; align-items: center; justify-content: center; + font-weight: 700; font-size: 14px; +} +.title { font-weight: 600; font-size: 14px; } +.connect-bar { display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; } +.connect-bar .label { color: var(--muted); margin-right: 0.25rem; } +.connect-results { display: flex; gap: 0.35rem; flex-wrap: wrap; margin-left: 0.5rem; } +.badge { + padding: 0.15rem 0.45rem; + border-radius: 4px; + font-size: 11px; + border: 1px solid var(--border); + background: var(--surface); +} +.badge.ok { border-color: var(--ok); color: var(--ok); } +.badge.err { border-color: var(--err); color: var(--err); } +.layout { display: flex; flex: 1; min-height: 0; } +.sidebar { + width: 280px; + background: var(--sidebar); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + min-height: 0; +} +.sidebar-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.65rem 0.75rem; + border-bottom: 1px solid var(--border); +} +.sidebar-head h2 { margin: 0; font-size: 13px; font-weight: 600; } +.meta-hint { + padding: 0.4rem 0.75rem; + font-size: 11px; + color: var(--muted); + border-bottom: 1px solid var(--border); + word-break: break-all; +} +.log-list { + list-style: none; + margin: 0; + padding: 0; + overflow-y: auto; + flex: 1; +} +.log-list li { + padding: 0.5rem 0.75rem; + border-bottom: 1px solid var(--border); + cursor: pointer; + transition: background 0.1s; +} +.log-list li:hover { background: var(--surface); } +.log-list li.active { background: var(--surface2); border-left: 3px solid var(--accent); } +.log-list .name { font-weight: 500; font-size: 12px; } +.log-list .sub { color: var(--muted); font-size: 11px; margin-top: 0.15rem; } +.workspace { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; +} +.toolbar { + display: flex; + gap: 0.35rem; + padding: 0.5rem 0.75rem; + border-bottom: 1px solid var(--border); + flex-wrap: wrap; +} +.url-bar { + display: flex; + gap: 0.35rem; + padding: 0.5rem 0.75rem; + background: var(--surface); + border-bottom: 1px solid var(--border); +} +.url-bar select { + width: 72px; + background: var(--surface2); + border: 1px solid var(--border); + color: var(--text); + border-radius: 4px; + padding: 0.35rem; +} +.url-input { + flex: 1; + background: var(--bg); + border: 1px solid var(--border); + color: var(--text); + border-radius: 4px; + padding: 0.4rem 0.6rem; + font-family: ui-monospace, monospace; + font-size: 12px; +} +.tabs { + display: flex; + gap: 0; + padding: 0 0.75rem; + background: var(--surface); + border-bottom: 1px solid var(--border); +} +.tab { + background: none; + border: none; + color: var(--muted); + padding: 0.55rem 1rem; + cursor: pointer; + border-bottom: 2px solid transparent; + font-size: 13px; +} +.tab:hover { color: var(--text); } +.tab.active { + color: var(--accent); + border-bottom-color: var(--accent); +} +.tab-panels { flex: 1; min-height: 0; overflow: hidden; position: relative; } +.panel { + display: none; + height: 100%; + overflow: auto; + padding: 0.75rem; +} +.panel.active { display: flex; flex-direction: column; } +.btn { + background: var(--surface2); + border: 1px solid var(--border); + color: var(--text); + border-radius: 4px; + padding: 0.35rem 0.65rem; + cursor: pointer; + font-size: 12px; +} +.btn:hover { border-color: var(--accent); } +.btn.primary { background: var(--accent); border-color: var(--accent); } +.btn.primary:hover { background: var(--accent-hover); } +.btn.send { background: var(--send); border-color: var(--send); color: #1a1a1a; font-weight: 600; } +.btn.send:hover { background: var(--send-hover); } +.btn.sm { padding: 0.2rem 0.45rem; font-size: 11px; } +.kv-table { width: 100%; border-collapse: collapse; } +.kv-table th, .kv-table td { + border: 1px solid var(--border); + padding: 0.35rem 0.5rem; + text-align: left; +} +.kv-table th { background: var(--surface2); color: var(--muted); font-weight: 500; } +.kv-table input { + width: 100%; + background: var(--bg); + border: 1px solid transparent; + color: var(--text); + padding: 0.25rem; + font-size: 12px; +} +.kv-table input:focus { border-color: var(--accent); outline: none; } +#body-raw { + flex: 1; + min-height: 200px; + background: var(--bg); + border: 1px solid var(--border); + color: var(--text); + border-radius: 4px; + padding: 0.6rem; + font-family: ui-monospace, monospace; + font-size: 12px; + resize: vertical; +} +.body-info { color: var(--muted); font-size: 11px; margin-bottom: 0.35rem; } +.code-block { + flex: 1; + margin: 0; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 4px; + padding: 0.6rem; + overflow: auto; + font-family: ui-monospace, monospace; + font-size: 12px; + white-space: pre-wrap; + word-break: break-all; +} diff --git a/internal/forward/testweb/web/t.js b/internal/forward/testweb/web/t.js new file mode 100644 index 0000000..f4271e8 --- /dev/null +++ b/internal/forward/testweb/web/t.js @@ -0,0 +1,250 @@ +(function () { + const state = { + meta: null, + logs: [], + currentRel: null, + currentDetail: null, + loadSource: 'outbound', + bodyBase64: '', + }; + + function $(id) { return document.getElementById(id); } + + async function api(path, opts) { + const r = await fetch(path, opts); + const t = await r.text(); + let j; + try { j = JSON.parse(t); } catch { j = { error: t }; } + if (!r.ok) throw new Error(j.error || r.statusText); + return j; + } + + function esc(s) { + const d = document.createElement('div'); + d.textContent = s == null ? '' : String(s); + return d.innerHTML; + } + + async function loadMeta() { + state.meta = await api('/t/api/meta'); + $('meta-hint').innerHTML = + '28212: ' + esc(state.meta.superviseTarget) + '
' + + '28211: ' + esc(state.meta.fileTarget) + ''; + } + + async function loadLogs() { + state.logs = await api('/t/api/logs'); + const ul = $('log-list'); + ul.innerHTML = ''; + if (!state.logs.length) { + ul.innerHTML = '
  • 暂无 file 日志
  • '; + return; + } + state.logs.forEach((e) => { + const li = document.createElement('li'); + li.dataset.rel = e.rel; + li.innerHTML = + '
    ' + esc(e.name) + '
    ' + + '
    ' + esc(e.hourDir) + ' · ' + esc(e.modified) + '
    '; + li.onclick = () => selectLog(e.rel, li); + ul.appendChild(li); + }); + } + + async function selectLog(rel, liEl) { + state.currentRel = rel; + document.querySelectorAll('.log-list li').forEach((el) => el.classList.remove('active')); + if (liEl) liEl.classList.add('active'); + state.currentDetail = await api('/t/api/logs/detail?rel=' + encodeURIComponent(rel)); + loadSection(state.loadSource === 'inbound' ? state.currentDetail.inbound : state.currentDetail.outbound); + showLogResponse(); + $('logmeta-view').textContent = JSON.stringify({ + meta: state.currentDetail.meta, + parseWarnings: state.currentDetail.parseWarnings, + outboundAbsent: state.currentDetail.outboundAbsent, + }, null, 2); + } + + function showLogResponse() { + const r = state.currentDetail.response; + let text = ''; + if (r.bodyJSON) text = r.bodyJSON; + else if (r.body) text = r.body; + else text = '(日志块3 无响应体)'; + if (r.status) text = 'HTTP ' + r.status + '\n\n' + text; + $('response-view').textContent = text; + } + + function loadSection(sec) { + if (!sec) return; + $('url').value = sec.url || ''; + renderHeaders(sec.headers || {}); + const info = []; + if (sec.bodyLen) info.push('body_len=' + sec.bodyLen); + if (sec.bodyPlain) info.push('明文'); + else if (sec.bodyBase64) info.push('base64'); + $('body-info').textContent = info.join(' · ') || ''; + state.bodyBase64 = sec.bodyBase64 || ''; + if (sec.bodyPlain && sec.bodyRaw) { + $('body-raw').value = sec.bodyRaw; + } else if (sec.bodyBase64) { + try { + $('body-raw').value = '[binary ' + sec.bodyLen + ' bytes — 发送时将使用 bodyBase64 回放]'; + } catch (e) { + $('body-raw').value = ''; + } + } else { + $('body-raw').value = sec.bodyRaw || ''; + } + $('body-base64').value = state.bodyBase64; + } + + function renderHeaders(hdrs) { + const tbody = $('headers-table').querySelector('tbody'); + tbody.innerHTML = ''; + Object.entries(hdrs).forEach(([k, v]) => addHeaderRow(k, v)); + if (!tbody.children.length) addHeaderRow('', ''); + } + + function addHeaderRow(k, v) { + const tbody = $('headers-table').querySelector('tbody'); + const tr = document.createElement('tr'); + tr.innerHTML = + '' + + '' + + ''; + tr.querySelector('.del').onclick = () => tr.remove(); + tbody.appendChild(tr); + } + + function collectHeaders() { + const h = {}; + $('headers-table').querySelectorAll('tbody tr').forEach((tr) => { + const k = tr.querySelector('.hk').value.trim(); + const v = tr.querySelector('.hv').value.trim(); + if (k) h[k] = v; + }); + return h; + } + + async function runConnect(channel) { + const box = $('connect-results'); + box.innerHTML = '测试中…'; + try { + const res = await api('/t/api/test/connect', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ channel }), + }); + box.innerHTML = res.results.map((r) => { + const cls = r.ok ? 'ok' : 'err'; + const detail = r.ok + ? 'http=' + r.httpStatus + ' ' + r.elapsedMs + 'ms' + : (r.phase || '') + ' ' + (r.error || ''); + return '' + + esc(r.channel) + ' ' + esc(detail) + ''; + }).join(''); + } catch (e) { + box.innerHTML = '' + esc(e.message) + ''; + } + } + + async function sendRequest() { + const payload = { + url: $('url').value.trim(), + headers: collectHeaders(), + useOutbound: state.loadSource === 'outbound', + }; + if (state.currentRel) payload.rel = state.currentRel; + const b64 = $('body-base64').value.trim(); + if (b64 && !$('body-raw').value.startsWith('[binary')) { + payload.bodyBase64 = b64; + } else if (b64) { + payload.bodyBase64 = b64; + } else { + const raw = $('body-raw').value; + if (raw && !raw.startsWith('[binary')) { + payload.bodyBase64 = btoa(unescape(encodeURIComponent(raw))); + } + } + $('response-view').textContent = '请求中…'; + try { + const res = await api('/t/api/test/send', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + let text = 'HTTP ' + res.httpStatus + ' (' + res.elapsedMs + 'ms)\n\n'; + if (res.error) text += 'Error: ' + res.error; + else if (res.bodyJSON) text += res.bodyJSON; + else text += res.body || ''; + $('response-view').textContent = text; + } catch (e) { + $('response-view').textContent = '失败: ' + e.message; + } + } + + function exportLog(source) { + if (!state.currentRel) { + alert('请先选择日志'); + return; + } + window.location.href = + '/t/api/logs/export?rel=' + encodeURIComponent(state.currentRel) + + '&source=' + (source === 'inbound' ? 'inbound' : 'outbound'); + } + + function copyCurl() { + const url = $('url').value.trim(); + const hdrs = collectHeaders(); + let parts = ['curl -X POST "' + url + '"']; + Object.entries(hdrs).forEach(([k, v]) => { + parts.push('-H "' + k + ': ' + v.replace(/"/g, '\\"') + '"'); + }); + const b64 = $('body-base64').value.trim(); + if (b64) { + parts.push('--data-binary @<(echo ' + b64 + ' | base64 -d)'); + } else { + const raw = $('body-raw').value; + if (raw && !raw.startsWith('[binary')) { + parts.push("-d '" + raw.replace(/'/g, "'\\''") + "'"); + } + } + navigator.clipboard.writeText(parts.join(' \\\n ')).then( + () => alert('已复制 cURL(multipart 二进制请用导出 ApiPost)'), + () => alert('复制失败') + ); + } + + document.querySelectorAll('.tab').forEach((tab) => { + tab.onclick = () => { + document.querySelectorAll('.tab').forEach((t) => t.classList.remove('active')); + document.querySelectorAll('.panel').forEach((p) => p.classList.remove('active')); + tab.classList.add('active'); + $('panel-' + tab.dataset.tab).classList.add('active'); + }; + }); + + document.querySelectorAll('[data-connect]').forEach((btn) => { + btn.onclick = () => runConnect(btn.dataset.connect); + }); + + $('btn-refresh-logs').onclick = loadLogs; + $('btn-load-outbound').onclick = () => { + state.loadSource = 'outbound'; + if (state.currentDetail) loadSection(state.currentDetail.outbound); + }; + $('btn-load-inbound').onclick = () => { + state.loadSource = 'inbound'; + if (state.currentDetail) loadSection(state.currentDetail.inbound); + }; + $('btn-export-outbound').onclick = () => exportLog('outbound'); + $('btn-export-inbound').onclick = () => exportLog('inbound'); + $('btn-curl').onclick = copyCurl; + $('btn-send').onclick = sendRequest; + $('btn-add-header').onclick = () => addHeaderRow('', ''); + + loadMeta().then(loadLogs).catch((e) => { + $('meta-hint').textContent = '加载失败: ' + e.message; + }); +})();