232 lines
7.7 KiB
Go
232 lines
7.7 KiB
Go
package middleware
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"io"
|
|
"kra/internal/biz/system"
|
|
httpx "kra/internal/server/httpx"
|
|
"log/slog"
|
|
"mime"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"kra/internal/config"
|
|
"kra/internal/routecatalog"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
const defaultRequestBodyLimit int64 = 8 << 20
|
|
|
|
// AccessLog is the single global request/response capture point, matching
|
|
// the reference middleware ordering and making every HTTP request observable.
|
|
func AccessLog(runtime *config.Store, logger *slog.Logger, version string) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
started := time.Now()
|
|
var requestBody []byte
|
|
multipart := strings.Contains(c.GetHeader("Content-Type"), "multipart/form-data")
|
|
mediaUpload := routecatalog.BodyPolicyFor(c.Request.Method, c.Request.URL.Path) == routecatalog.BodyPolicyUpload
|
|
var admin *config.Admin
|
|
if runtime != nil {
|
|
if snapshot := runtime.Snapshot(); snapshot != nil {
|
|
admin = snapshot.Admin
|
|
}
|
|
}
|
|
bodyLimit := defaultRequestBodyLimit
|
|
if mediaUpload {
|
|
bodyLimit = system.DefaultMaxMediaFileSize + (1 << 20)
|
|
if admin != nil && admin.Media != nil && admin.Media.MaxFileSize > 0 {
|
|
bodyLimit = admin.Media.MaxFileSize + (1 << 20)
|
|
}
|
|
}
|
|
bytesIn := c.Request.ContentLength
|
|
logLimit := 1024
|
|
if admin != nil && admin.Zap != nil && admin.Zap.AccessLogMaxBytes > 0 {
|
|
logLimit = admin.Zap.AccessLogMaxBytes
|
|
}
|
|
maxBytes := 1 << 20
|
|
if logLimit > maxBytes {
|
|
maxBytes = logLimit
|
|
}
|
|
writer := &captureWriter{ResponseWriter: c.Writer, maxBytes: maxBytes}
|
|
c.Writer = writer
|
|
c.Header("X-Kra-Version", version)
|
|
requestReadFailed := c.Request.ContentLength > bodyLimit
|
|
if requestReadFailed {
|
|
c.AbortWithStatusJSON(http.StatusRequestEntityTooLarge, gin.H{"code": httpx.CodeError, "msg": "请求体超过大小上限"})
|
|
} else if c.Request.Body != nil && !mediaUpload {
|
|
limited := http.MaxBytesReader(c.Writer, c.Request.Body, bodyLimit)
|
|
var err error
|
|
requestBody, err = io.ReadAll(limited)
|
|
if err != nil {
|
|
requestReadFailed = true
|
|
var tooLarge *http.MaxBytesError
|
|
if errors.As(err, &tooLarge) {
|
|
c.AbortWithStatusJSON(http.StatusRequestEntityTooLarge, gin.H{"code": httpx.CodeError, "msg": "请求体超过大小上限"})
|
|
} else {
|
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"code": httpx.CodeError, "msg": "请求体读取失败"})
|
|
}
|
|
} else {
|
|
c.Request.Body = io.NopCloser(bytes.NewReader(requestBody))
|
|
bytesIn = int64(len(requestBody))
|
|
}
|
|
} else if c.Request.Body != nil {
|
|
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, bodyLimit)
|
|
}
|
|
if bytesIn < 0 {
|
|
bytesIn = 0
|
|
}
|
|
bodyPolicy := routecatalog.BodyPolicyFor(c.Request.Method, c.Request.URL.Path)
|
|
paymentCallback := bodyPolicy == routecatalog.BodyPolicyPaymentCallback
|
|
paymentConfigWrite := bodyPolicy == routecatalog.BodyPolicyPaymentConfig
|
|
requestText := ""
|
|
if paymentCallback {
|
|
requestText = paymentCallbackSummary(requestBody, c.GetHeader("Content-Type"))
|
|
} else if paymentConfigWrite {
|
|
requestText = paymentConfigSummary(requestBody)
|
|
} else if multipart {
|
|
requestText = "[文件]"
|
|
} else {
|
|
requestText = redactJSON(requestBody, c.GetHeader("Content-Type"), logLimit)
|
|
}
|
|
c.Set(ctxReqBodyKey, requestText)
|
|
if paymentCallback {
|
|
// Callback acknowledgements and provider payloads must not flow into
|
|
// the generic response/error audit pipeline.
|
|
c.Set(ctxRespBufferKey, &bytes.Buffer{})
|
|
} else {
|
|
c.Set(ctxRespBufferKey, &writer.body)
|
|
}
|
|
if !requestReadFailed {
|
|
c.Next()
|
|
}
|
|
if logger == nil {
|
|
return
|
|
}
|
|
responseText := redactJSON(writer.body.Bytes(), c.Writer.Header().Get("Content-Type"), logLimit)
|
|
if paymentCallback {
|
|
responseText = "[支付回调响应已省略]"
|
|
}
|
|
userID, authorityID := uint(0), uint(0)
|
|
if claims := Claims(c); claims != nil {
|
|
userID, authorityID = claims.ID, claims.AuthorityID
|
|
}
|
|
route := c.FullPath()
|
|
if route == "" {
|
|
route = "unmatched"
|
|
}
|
|
bytesOut := int64(c.Writer.Size())
|
|
if bytesOut < 0 {
|
|
bytesOut = 0
|
|
}
|
|
privateErrors := strings.TrimRight(c.Errors.ByType(gin.ErrorTypePrivate).String(), "\n")
|
|
var attributes []any
|
|
if paymentCallback {
|
|
attributes = []any{
|
|
"mod", "payment-callback", "payment_provider", paymentCallbackProvider(c.Request.URL.Path),
|
|
"http_status", c.Writer.Status(), "latency_ms", time.Since(started).Milliseconds(),
|
|
"request_id", stringValueFromContext(c, "request_id"), "trace_id", stringValueFromContext(c, "trace_id"),
|
|
"bytes_in", bytesIn, "bytes_out", bytesOut,
|
|
"error", c.Writer.Status() >= http.StatusInternalServerError || privateErrors != "",
|
|
"payment_callback", true, "payment_callback_summary", requestText,
|
|
}
|
|
} else {
|
|
attributes = []any{
|
|
"mod", "http", "ip", c.ClientIP(), "method", c.Request.Method, "http_path", c.Request.URL.Path, "http_route", route,
|
|
"http_status", c.Writer.Status(), "latency_ms", time.Since(started).Milliseconds(),
|
|
"request_id", stringValueFromContext(c, "request_id"), "trace_id", stringValueFromContext(c, "trace_id"),
|
|
"bytes_in", bytesIn, "bytes_out", bytesOut, "user_id", userID, "authority_id", authorityID,
|
|
"error", c.Writer.Status() >= http.StatusInternalServerError || privateErrors != "", "ua", c.Request.UserAgent(),
|
|
"req_query", redactQuery(c.Request.URL.RawQuery),
|
|
}
|
|
}
|
|
// A payment callback is logged through its own attribute set: neither the
|
|
// configurable request/response capture nor the private error text may leak
|
|
// provider payloads into the generic access log.
|
|
if !paymentCallback {
|
|
var zap *config.Zap
|
|
if admin != nil {
|
|
zap = admin.Zap
|
|
}
|
|
if zap != nil {
|
|
if zap.AccessReqHeaders {
|
|
attributes = append(attributes, "req_headers", redactHeaders(c.Request.Header))
|
|
}
|
|
if zap.AccessReqBody {
|
|
attributes = append(attributes, "req_body", requestText)
|
|
}
|
|
if zap.AccessRespData {
|
|
attributes = append(attributes, "resp_data", responseText)
|
|
}
|
|
}
|
|
if privateErrors != "" {
|
|
attributes = append(attributes, "error_msg", privateErrors)
|
|
}
|
|
}
|
|
logger.InfoContext(c.Request.Context(), "请求完成", attributes...)
|
|
}
|
|
}
|
|
|
|
func paymentCallbackProvider(path string) string {
|
|
parts := strings.Split(strings.Trim(path, "/"), "/")
|
|
for index := 0; index+2 < len(parts); index++ {
|
|
if parts[index] == "payment" && parts[index+1] == "callback" {
|
|
return parts[index+2]
|
|
}
|
|
}
|
|
return "unknown"
|
|
}
|
|
|
|
func paymentCallbackSummary(body []byte, contentType string) string {
|
|
mediaType, _, err := mime.ParseMediaType(contentType)
|
|
if err != nil || mediaType == "" {
|
|
mediaType = strings.TrimSpace(strings.SplitN(contentType, ";", 2)[0])
|
|
}
|
|
if mediaType == "" {
|
|
mediaType = "unknown"
|
|
}
|
|
digest := sha256.Sum256(body)
|
|
return "[支付回调正文已省略 body_bytes=" + strconv.Itoa(len(body)) + " body_sha256=" + hex.EncodeToString(digest[:]) + " content_type=" + mediaType + "]"
|
|
}
|
|
|
|
func paymentConfigSummary(body []byte) string {
|
|
digest := sha256.Sum256(body)
|
|
return "[支付配置正文已省略 body_bytes=" + strconv.Itoa(len(body)) + " body_sha256=" + hex.EncodeToString(digest[:]) + "]"
|
|
}
|
|
|
|
func redactHeaders(headers map[string][]string) map[string]string {
|
|
out := make(map[string]string, len(headers))
|
|
for key, values := range headers {
|
|
if isSensitiveHeader(key) {
|
|
out[key] = redactedValue
|
|
} else {
|
|
out[key] = strings.Join(values, ",")
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func redactQuery(raw string) string {
|
|
if strings.TrimSpace(raw) == "" {
|
|
return ""
|
|
}
|
|
values, _ := url.ParseQuery(raw)
|
|
for key := range values {
|
|
if isSensitivePayloadKey(key) {
|
|
values[key] = []string{redactedValue}
|
|
}
|
|
}
|
|
return values.Encode()
|
|
}
|
|
|
|
func stringValueFromContext(c *gin.Context, key string) string {
|
|
value, _ := c.Get(key)
|
|
return stringValue(value)
|
|
}
|