Files
2026-07-21 16:35:46 +08:00

374 lines
9.4 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package forward
import (
"bytes"
"context"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"xk-hy-forward-go/internal/forward/testweb"
)
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()
if err := os.WriteFile(path, buf.Bytes(), 0o644); err != nil {
return err
}
rel := filepath.ToSlash(filepath.Join(hourDir, name))
if err := testweb.AppendPrescriptionFromLogContent(apiLogRoot(), rel, buf.Bytes()); err != nil {
appLogf("%s | prescription-index append error | %v", time.Now().Format(time.RFC3339), err)
}
return nil
}
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
}