Files
ttstd-infinite-canvas/backend/internal/platform/api_call_payload.go
xiaomuclaw 29cc6b4716 fix(platform): API 调用日志截断对齐 UTF-8 字符边界 - 防止截断报文写库失败导致订单误标 uncertain (#695)
truncateAPICallPayload 原先按字节硬切超长报文,切点多字节字符时会产生
非法 UTF-8,写 PostgreSQL 报 invalid byte sequence,调用日志入库失败并
连锁导致订单被标为 uncertain。

- 截断点回退到最近的 UTF-8 字符边界
- 对源报文本身的非法字节做 strings.ToValidUTF8 兜底
- 补充回归测试:中文/emoji/中日韩混排/非法字节等场景

Co-authored-by: xiaomuclaw <282836183+xiaomuclaw@users.noreply.github.com>
2026-10-06 09:28:17 +08:00

166 lines
5.4 KiB
Go
Raw 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 platform
import (
"bytes"
"encoding/json"
"fmt"
"infinite-canvas/backend/internal/kernel"
"io"
"mime"
"mime/multipart"
"net/http"
"net/url"
"strings"
"unicode/utf8"
)
const maxAPICallPayloadBytes = 128 << 10
const maxAPICallPayloadSourceBytes = 1 << 20
// SanitizeAPICallPayload 保留排障所需报文,同时阻止密钥和大段内嵌媒体进入日志库。
func SanitizeAPICallPayload(data []byte, contentType string) string {
if len(data) == 0 {
return ""
}
mediaType, params, _ := mime.ParseMediaType(contentType)
if mediaType == "" {
mediaType, _, _ = mime.ParseMediaType(http.DetectContentType(data))
}
if mediaType == "multipart/form-data" && params["boundary"] != "" {
return sanitizeMultipartPayload(bytes.NewReader(data), params["boundary"])
}
if len(data) > maxAPICallPayloadSourceBytes {
return fmt.Sprintf("[报文过大,已省略,共 %d 字节]", len(data))
}
if json.Valid(data) {
var payload any
if json.Unmarshal(data, &payload) == nil {
formatted, err := json.MarshalIndent(sanitizeAPICallJSON(payload, ""), "", " ")
if err == nil {
return truncateAPICallPayload(string(formatted))
}
}
}
if strings.HasPrefix(mediaType, "image/") || strings.HasPrefix(mediaType, "video/") || strings.HasPrefix(mediaType, "audio/") || mediaType == "application/octet-stream" {
return fmt.Sprintf("[%s 二进制报文,共 %d 字节]", kernel.DefaultString(mediaType, "未知类型"), len(data))
}
return truncateAPICallPayload(string(data))
}
func RequestPayloadForLog(req *http.Request) string {
if req == nil || req.GetBody == nil {
return ""
}
body, err := req.GetBody()
if err != nil {
return ""
}
defer body.Close()
mediaType, params, _ := mime.ParseMediaType(req.Header.Get("Content-Type"))
if mediaType == "multipart/form-data" && params["boundary"] != "" {
return sanitizeMultipartPayload(body, params["boundary"])
}
data, err := io.ReadAll(io.LimitReader(body, maxAPICallPayloadSourceBytes+1))
if err != nil {
return ""
}
if len(data) > maxAPICallPayloadSourceBytes {
return fmt.Sprintf("[请求报文过大,已省略,超过 %d 字节]", maxAPICallPayloadSourceBytes)
}
return SanitizeAPICallPayload(data, req.Header.Get("Content-Type"))
}
func sanitizeMultipartPayload(source io.Reader, boundary string) string {
reader := multipart.NewReader(source, boundary)
fields := make(map[string]any)
for partIndex := 0; partIndex < 100; partIndex++ {
part, err := reader.NextPart()
if err == io.EOF {
break
}
if err != nil {
return "[multipart 请求报文无法解析]"
}
name := part.FormName()
if part.FileName() != "" {
size, _ := io.Copy(io.Discard, part)
fields[name] = map[string]any{"fileName": part.FileName(), "contentType": part.Header.Get("Content-Type"), "size": size}
} else {
content, _ := io.ReadAll(io.LimitReader(part, maxAPICallPayloadBytes+1))
fields[name] = sanitizeAPICallJSON(string(content), name)
}
part.Close()
}
formatted, _ := json.MarshalIndent(fields, "", " ")
return truncateAPICallPayload(string(formatted))
}
func sanitizeAPICallJSON(value any, key string) any {
normalizedKey := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(key, "_", ""), "-", ""))
for _, secretKey := range []string{"apikey", "accesstoken", "authorization", "password", "secret"} {
if strings.Contains(normalizedKey, secretKey) {
return "[REDACTED]"
}
}
switch typed := value.(type) {
case map[string]any:
result := make(map[string]any, len(typed))
for childKey, childValue := range typed {
result[childKey] = sanitizeAPICallJSON(childValue, childKey)
}
return result
case []any:
result := make([]any, len(typed))
for index, childValue := range typed {
result[index] = sanitizeAPICallJSON(childValue, key)
}
return result
case string:
if strings.HasPrefix(typed, "data:") {
mediaType := strings.TrimPrefix(strings.SplitN(typed, ";", 2)[0], "data:")
return fmt.Sprintf("[内嵌媒体 %s,共 %d 字符]", kernel.DefaultString(mediaType, "未知类型"), len(typed))
}
if strings.Contains(normalizedKey, "base64") || strings.Contains(normalizedKey, "b64") {
return fmt.Sprintf("[内嵌编码数据,共 %d 字符]", len(typed))
}
if sanitizedURL, ok := sanitizeAPICallURL(typed); ok {
return sanitizedURL
}
return typed
default:
return value
}
}
func sanitizeAPICallURL(value string) (string, bool) {
parsed, err := url.Parse(value)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
return "", false
}
query := parsed.Query()
for key := range query {
normalized := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(key, "_", ""), "-", ""))
if strings.Contains(normalized, "token") || strings.Contains(normalized, "signature") || strings.Contains(normalized, "apikey") || normalized == "key" {
query.Set(key, "[REDACTED]")
}
}
parsed.RawQuery = query.Encode()
return parsed.String(), true
}
func truncateAPICallPayload(value string) string {
if len(value) <= maxAPICallPayloadBytes {
return value
}
cut := maxAPICallPayloadBytes
for cut > 0 && !utf8.RuneStart(value[cut]) {
cut--
}
// 上游报文不保证是合法 UTF-8,直接入库会被 PostgreSQL 以 invalid byte sequence 拒收。
truncated := value[:cut]
if !utf8.ValidString(truncated) {
truncated = strings.ToValidUTF8(truncated, string(utf8.RuneError))
}
return truncated + fmt.Sprintf("\n[报文已截断,原始长度 %d 字节]", len(value))
}