Files
easyai-ai-gateway/apps/api/internal/store/json_storage_guard.go
T
wangbo 4f163ea6d7 perf(storage): 拦截任务二进制并本地暂存结果
原因:任务标准结果中的 Base64、Data URI 和 Buffer 会进入 PostgreSQL JSON,导致 TOAST 与备份体积快速增长。

影响:新增统一 JSON 持久化关口;upload_none 将二进制原子写入本地结果目录,数据库仅保存带 SHA-256 的有界占位符;任务详情、同步响应、异步查询和兼容协议按需校验恢复。补充 24 小时清理、容量上限、历史小批量治理命令及管理端说明。

风险:本地结果超过 TTL、丢失或损坏时分别返回明确的 410/500;空间不足时返回 503 且不重试上游。未自动执行历史治理。

验证:三种真实图片模型同步/异步与幂等重放通过;Go vet/全量测试、前端 111 测试、lint/typecheck/build、OpenAPI、迁移安全、govulncheck、依赖审计、手工发布测试及 Linux amd64 构建通过。
2026-07-24 21:13:09 +08:00

345 lines
8.8 KiB
Go

package store
import (
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
)
const (
storageBinaryPrefixChars = 16
storageGenericBase64MinLength = 4096
storageInvalidBinaryMinLength = 512
storageJSONSanitizerMaxDepth = 64
storagePlaceholderPrefix = "[GatewayBinary:v1;"
storageBufferObjectType = "buffer"
storageDataURLPrefix = "data:"
storageDataURLBase64Marker = ";base64"
storageDataURLMaxContentType = 64
)
var (
ErrTaskRequestBinaryNotMaterialized = errors.New("task request binary payload was not materialized")
ErrTaskResultBinaryNotMaterialized = errors.New("task result binary payload was not materialized")
)
type taskPayloadBinaryError struct {
target error
code string
count int
}
func (e *taskPayloadBinaryError) Error() string {
return fmt.Sprintf("%s: detected %d inline binary value(s)", e.code, e.count)
}
func (e *taskPayloadBinaryError) ErrorCode() string {
return e.code
}
func (e *taskPayloadBinaryError) Is(target error) bool {
return target == e.target
}
type storageSanitizeReport struct {
Value any
BinaryCount int
}
// sanitizeJSONForStorage is the final task-domain persistence guard. It always
// returns a detached JSON-compatible value and replaces inline binary payloads
// with a bounded, deterministic placeholder.
func sanitizeJSONForStorage(value any) any {
return sanitizeJSONForStorageWithReport(value).Value
}
func sanitizeJSONForStorageWithReport(value any) storageSanitizeReport {
next, count := sanitizeJSONStorageValue(value, nil, 0)
return storageSanitizeReport{Value: next, BinaryCount: count}
}
func sanitizeJSONStorageValue(value any, path []string, depth int) (any, int) {
if depth >= storageJSONSanitizerMaxDepth {
return "[JSON,max-depth]", 0
}
switch typed := value.(type) {
case map[string]any:
if payload, contentType, ok := storageBufferObjectBytes(typed); ok {
return storageBinaryPlaceholder(payload, contentType, "buffer"), 1
}
next := make(map[string]any, len(typed))
count := 0
for key, child := range typed {
sanitized, childCount := sanitizeJSONStorageValue(child, appendStoragePath(path, key), depth+1)
next[key] = sanitized
count += childCount
}
return next, count
case []any:
if storagePathIsBinary(path) {
if payload, ok := storageNumberArrayBytes(typed); ok {
return storageBinaryPlaceholder(payload, "", "buffer"), 1
}
}
next := make([]any, len(typed))
count := 0
for index, child := range typed {
sanitized, childCount := sanitizeJSONStorageValue(child, path, depth+1)
next[index] = sanitized
count += childCount
}
return next, count
case []byte:
return storageBinaryPlaceholder(typed, "", "buffer"), 1
case string:
if placeholder, ok := storageStringPlaceholder(typed, path); ok {
return placeholder, 1
}
return typed, 0
default:
return value, 0
}
}
func appendStoragePath(path []string, key string) []string {
next := make([]string, len(path)+1)
copy(next, path)
next[len(path)] = key
return next
}
func storageStringPlaceholder(value string, path []string) (string, bool) {
raw := strings.TrimSpace(value)
if raw == "" || strings.HasPrefix(raw, storagePlaceholderPrefix) {
return "", false
}
encoded, contentType, dataURL := storageBase64StringParts(raw)
strict := dataURL || storagePathIsBinary(path)
if !strict && len(encoded) < storageGenericBase64MinLength {
return "", false
}
payload, ok := storageDecodeBase64(encoded)
if ok {
encoding := "raw"
if dataURL {
encoding = "data-uri"
}
return storageBinaryPlaceholder(payload, contentType, encoding), true
}
if strict && len(raw) >= storageInvalidBinaryMinLength {
return storageBinaryPlaceholder([]byte(raw), contentType, "raw"), true
}
return "", false
}
func storageBase64StringParts(value string) (encoded string, contentType string, dataURL bool) {
if !strings.HasPrefix(strings.ToLower(value), storageDataURLPrefix) {
return value, "", false
}
prefix, payload, ok := strings.Cut(value, ",")
if !ok || !strings.Contains(strings.ToLower(prefix), storageDataURLBase64Marker) {
return value, "", false
}
mediaType := strings.TrimSpace(prefix[len(storageDataURLPrefix):])
if before, _, found := strings.Cut(mediaType, ";"); found {
mediaType = before
}
if len(mediaType) > storageDataURLMaxContentType || !storageSafeContentType(mediaType) {
mediaType = ""
}
return payload, strings.ToLower(mediaType), true
}
func storageSafeContentType(value string) bool {
if value == "" {
return true
}
for _, char := range value {
switch {
case char >= 'a' && char <= 'z':
case char >= 'A' && char <= 'Z':
case char >= '0' && char <= '9':
case char == '/', char == '.', char == '+', char == '-':
default:
return false
}
}
return true
}
func storageDecodeBase64(value string) ([]byte, bool) {
normalized := removeStorageASCIIWhitespace(value)
if normalized == "" {
return nil, false
}
for _, encoding := range []*base64.Encoding{
base64.StdEncoding,
base64.RawStdEncoding,
base64.URLEncoding,
base64.RawURLEncoding,
} {
payload, err := encoding.DecodeString(normalized)
if err != nil || len(payload) == 0 {
continue
}
canonical := encoding.EncodeToString(payload)
if strings.TrimRight(normalized, "=") == strings.TrimRight(canonical, "=") {
return payload, true
}
}
return nil, false
}
func removeStorageASCIIWhitespace(value string) string {
return strings.Map(func(char rune) rune {
switch char {
case ' ', '\n', '\r', '\t':
return -1
default:
return char
}
}, value)
}
func storagePathIsBinary(path []string) bool {
if len(path) == 0 {
return false
}
key := normalizeStorageBinaryKey(path[len(path)-1])
if storageBinaryKey(key) {
return true
}
if len(path) < 2 {
return false
}
parent := normalizeStorageBinaryKey(path[len(path)-2])
return (parent == "inlinedata" || parent == "binary" || parent == "media") &&
(key == "data" || key == "content")
}
func normalizeStorageBinaryKey(value string) string {
return strings.Map(func(char rune) rune {
switch {
case char >= 'a' && char <= 'z':
return char
case char >= 'A' && char <= 'Z':
return char + ('a' - 'A')
case char >= '0' && char <= '9':
return char
default:
return -1
}
}, value)
}
func storageBinaryKey(key string) bool {
return key == "b64" ||
key == "b64json" ||
key == "base64" ||
key == "buffer" ||
key == "bytes" ||
strings.Contains(key, "base64") ||
strings.Contains(key, "buffer") ||
strings.Contains(key, "binary") ||
strings.HasSuffix(key, "b64") ||
strings.HasSuffix(key, "bytes")
}
func storageBufferObjectBytes(value map[string]any) ([]byte, string, bool) {
kind, _ := value["type"].(string)
if normalizeStorageBinaryKey(kind) != storageBufferObjectType {
return nil, "", false
}
contentType := firstNonEmpty(
stringFromAny(value["mime_type"]),
stringFromAny(value["mimeType"]),
stringFromAny(value["contentType"]),
)
switch data := value["data"].(type) {
case []byte:
if len(data) == 0 {
return nil, "", false
}
return append([]byte(nil), data...), contentType, true
case []any:
payload, ok := storageNumberArrayBytes(data)
return payload, contentType, ok
default:
return nil, "", false
}
}
func storageNumberArrayBytes(values []any) ([]byte, bool) {
if len(values) == 0 {
return nil, false
}
payload := make([]byte, len(values))
for index, value := range values {
next, ok := storageByteFromAny(value)
if !ok {
return nil, false
}
payload[index] = next
}
return payload, true
}
func storageByteFromAny(value any) (byte, bool) {
switch typed := value.(type) {
case byte:
return typed, true
case int:
if typed >= 0 && typed <= 255 {
return byte(typed), true
}
case int32:
if typed >= 0 && typed <= 255 {
return byte(typed), true
}
case int64:
if typed >= 0 && typed <= 255 {
return byte(typed), true
}
case float64:
asInt := int(typed)
if typed == float64(asInt) && asInt >= 0 && asInt <= 255 {
return byte(asInt), true
}
case json.Number:
asInt, err := strconv.ParseInt(string(typed), 10, 16)
if err == nil && asInt >= 0 && asInt <= 255 {
return byte(asInt), true
}
}
return 0, false
}
func storageBinaryPlaceholder(payload []byte, contentType string, encoding string) string {
digest := sha256.Sum256(payload)
prefix := base64.StdEncoding.EncodeToString(payload)
if len(prefix) > storageBinaryPrefixChars {
prefix = prefix[:storageBinaryPrefixChars]
}
contentType = strings.ToLower(strings.TrimSpace(contentType))
if contentType == "" || len(contentType) > 32 || !storageSafeContentType(contentType) {
contentType = "application/octet-stream"
}
switch encoding {
case "data-uri", "buffer":
default:
encoding = "raw"
}
return fmt.Sprintf(
"[GatewayBinary:v1;prefix=%s;sha256=%x;bytes=%d;mime=%s;encoding=%s]",
prefix,
digest,
len(payload),
contentType,
encoding,
)
}