365 lines
9.1 KiB
Go
365 lines
9.1 KiB
Go
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
|
||
}
|