This commit is contained in:
李琦
2026-05-28 17:04:29 +08:00
parent ba0779877f
commit 0c268fb2c5
17 changed files with 2362 additions and 17 deletions

View File

@@ -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含完整请求/响应(不脱敏)

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
*.exe
.env
../log/
api-logs/

View File

@@ -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://<forward-host>:16001/t`(随 forward 进程开放,**仅建议内网使用**)。
功能:
- **连通测试**:对当前 `.env` 中 28212 / 28211 目标 URL 发起探测。
- **file 日志**:左侧列出 `api-logs/**/_file_*.log`,点击解析 **入站(块1)** / **出站(块2)** 的 Headers 与 Bodymultipart 为 `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、Bodymultipart/PDF 为 body_len + body_base64
=== 2. 出站转发forward → 政务云)===
TargetURL、白名单过滤后 Headers、Body与入站相同字节
=== 3. 回复(政务云 → forward → transit===
UpstreamStatus、UpstreamHeaders、BodyJSON 响应额外输出 BodyJSON缩进格式化
```
排查超时:若 **块1+块2** 完整、**块3** 为空且 `DurationMs` 很大,说明已发往政务云但上游无响应(常见为 28211 网络不通)。
**注意**:日志含 `X-Authorization``X-Forward-Token`、业务密文及 PDF已加入 `.gitignore`,请勿提交仓库或暴露到外网。

364
internal/forward/apilog.go Normal file
View File

@@ -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
}

View File

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

View File

@@ -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 与 HeaderBody 由 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构造 JSONbody 明文仅 UTF-8 且非 multipart 时填充body_base64 始终提供)
resp := dryRunResponse{
DryRun: true,

View File

@@ -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政务云测试页 /tApiPost 风格,读 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)
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

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

View File

@@ -0,0 +1,6 @@
package testweb
import "embed"
//go:embed web/*
var webFS embed.FS

View File

@@ -0,0 +1,117 @@
package testweb
import (
"encoding/json"
"fmt"
"strings"
"time"
)
// ToPostmanCollection 将解析后的日志转为 Postman Collection v2.1ApiPost 可导入)。
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
}

View File

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

View File

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

View File

@@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>政务云连接测试 · forward-go</title>
<link rel="stylesheet" href="/t/static/t.css">
</head>
<body>
<div class="app">
<header class="topbar">
<div class="brand">
<span class="logo">T</span>
<span class="title">forward 政务云测试</span>
</div>
<div class="connect-bar">
<span class="label">连通测试</span>
<button type="button" class="btn" data-connect="supervise">28212 监管</button>
<button type="button" class="btn" data-connect="file">28211 文件</button>
<button type="button" class="btn primary" data-connect="both">全部</button>
<div id="connect-results" class="connect-results"></div>
</div>
</header>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-head">
<h2>file 日志</h2>
<button type="button" class="btn sm" id="btn-refresh-logs">刷新</button>
</div>
<div class="meta-hint" id="meta-hint"></div>
<ul class="log-list" id="log-list"></ul>
</aside>
<main class="workspace">
<div class="toolbar">
<button type="button" class="btn" id="btn-load-outbound">加载出站(块2)</button>
<button type="button" class="btn" id="btn-load-inbound">加载入站(块1)</button>
<button type="button" class="btn" id="btn-export-outbound">导出 ApiPost(出站)</button>
<button type="button" class="btn" id="btn-export-inbound">导出 ApiPost(入站)</button>
<button type="button" class="btn" id="btn-curl">复制 cURL</button>
</div>
<div class="url-bar">
<select id="method" disabled>
<option>POST</option>
</select>
<input type="text" id="url" class="url-input" placeholder="TargetURL">
<button type="button" class="btn send primary" id="btn-send">发送</button>
</div>
<div class="tabs">
<button type="button" class="tab active" data-tab="headers">Headers</button>
<button type="button" class="tab" data-tab="body">Body</button>
<button type="button" class="tab" data-tab="response">响应</button>
<button type="button" class="tab" data-tab="logmeta">日志 Meta</button>
</div>
<div class="tab-panels">
<div class="panel active" id="panel-headers">
<table class="kv-table" id="headers-table">
<thead><tr><th>Key</th><th>Value</th><th></th></tr></thead>
<tbody></tbody>
</table>
<button type="button" class="btn sm" id="btn-add-header">+ Header</button>
</div>
<div class="panel" id="panel-body">
<div class="body-info" id="body-info"></div>
<textarea id="body-raw" spellcheck="false" placeholder="请求体(明文或 base64 解码后预览)"></textarea>
<input type="hidden" id="body-base64">
</div>
<div class="panel" id="panel-response">
<pre id="response-view" class="code-block">发送后或从日志块3 加载)</pre>
</div>
<div class="panel" id="panel-logmeta">
<pre id="logmeta-view" class="code-block"></pre>
</div>
</div>
</main>
</div>
</div>
<script src="/t/static/t.js"></script>
</body>
</html>

View File

@@ -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;
}

View File

@@ -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: <code>' + esc(state.meta.superviseTarget) + '</code><br>' +
'28211: <code>' + esc(state.meta.fileTarget) + '</code>';
}
async function loadLogs() {
state.logs = await api('/t/api/logs');
const ul = $('log-list');
ul.innerHTML = '';
if (!state.logs.length) {
ul.innerHTML = '<li style="cursor:default;color:var(--muted)">暂无 file 日志</li>';
return;
}
state.logs.forEach((e) => {
const li = document.createElement('li');
li.dataset.rel = e.rel;
li.innerHTML =
'<div class="name">' + esc(e.name) + '</div>' +
'<div class="sub">' + esc(e.hourDir) + ' · ' + esc(e.modified) + '</div>';
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 =
'<td><input class="hk" value="' + esc(k).replace(/"/g, '&quot;') + '"></td>' +
'<td><input class="hv" value="' + esc(v).replace(/"/g, '&quot;') + '"></td>' +
'<td><button type="button" class="btn sm del">删</button></td>';
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 = '<span class="badge">测试中…</span>';
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 '<span class="badge ' + cls + '" title="' + esc(r.url) + '">' +
esc(r.channel) + ' ' + esc(detail) + '</span>';
}).join('');
} catch (e) {
box.innerHTML = '<span class="badge err">' + esc(e.message) + '</span>';
}
}
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('已复制 cURLmultipart 二进制请用导出 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;
});
})();