修复 AI Gateway 图片 MIME 类型错误:用魔数检测替代扩展名猜测

修复 AI Gateway 图片 MIME 类型错误:用魔数检测替代扩展名猜测

问题背景

在使用 Hermes Discord Gateway 发送图片时,遇到了一个隐蔽的 bug:

arduino 复制代码
HTTP 400: messages.16.content.1.image.source.base64: 
The image was specified using the image/webp media type, 
but the image appears to be a image/png image

Claude API 校验失败,原因是传入的 MIME type(image/webp)与图片实际内容(PNG 格式)不符。

根本原因分析

排查路径如下:

  1. Discord CDN 的行为 :Discord 下载入站图片附件时,会根据 HTTP Response 的 Content-Type header 决定本地缓存文件的扩展名
  2. CDN 转码问题 :Discord CDN 对 PNG 图片有时返回 content-type: image/webp(Discord 内部会自动做格式转换),导致文件被存成 .webp 后缀
  3. MIME 猜测逻辑缺陷 :Gateway 的 _guess_mime() 函数纯粹基于文件扩展名判断类型,文件名叫 .webp → 返回 image/webp
  4. Claude API 严格校验:Claude 在接收 base64 图片时会校验实际字节头与声明的 MIME type 是否一致,不匹配直接 400

整个链路:文件内容是 PNG,但扩展名是 .webp → MIME 声明错误 → Claude 拒绝

修复方案:魔数(Magic Number)检测

核心思路:不再依赖文件扩展名猜 MIME type,改为读取文件头字节,通过魔数来判断真实格式。

常见图片格式的魔数

格式 字节头(Hex) 说明
PNG 89 50 4E 47 0D 0A 1A 0A 固定 8 字节签名
JPEG FF D8 FF JFIF/EXIF 前缀
WebP 52 49 46 46 xx xx xx xx 57 45 42 50 RIFF....WEBP
GIF 47 49 46 38 GIF8

修复代码

python 复制代码
def _guess_mime(path: str, data: bytes | None = None) -> str:
    """
    优先用文件头魔数判断真实 MIME type,
    兜底才使用文件扩展名。
    """
    # 读取文件头字节
    header: bytes = b""
    if data is not None:
        header = data[:12]
    else:
        try:
            with open(path, "rb") as f:
                header = f.read(12)
        except OSError:
            pass

    # 魔数匹配
    if header[:8] == b"\x89PNG\r\n\x1a\n":
        return "image/png"
    if header[:3] == b"\xff\xd8\xff":
        return "image/jpeg"
    if header[:6] in (b"GIF87a", b"GIF89a"):
        return "image/gif"
    if header[:4] == b"RIFF" and header[8:12] == b"WEBP":
        return "image/webp"

    # 兜底:扩展名猜测
    ext = os.path.splitext(path)[1].lower()
    return {
        ".png": "image/png",
        ".jpg": "image/jpeg",
        ".jpeg": "image/jpeg",
        ".gif": "image/gif",
        ".webp": "image/webp",
    }.get(ext, "application/octet-stream")

关键细节

魔数优先于扩展名:无论文件被命名成什么扩展名,字节头永远反映真实格式。

传递 bytes 避免重复 IO_file_to_data_url 已经读了文件内容,直接把 bytes 传给 _guess_mime,不用再 open 一次:

python 复制代码
def _file_to_data_url(path: str) -> str:
    with open(path, "rb") as f:
        data = f.read()
    mime = _guess_mime(path, data=data)   # 传入已读字节
    b64 = base64.b64encode(data).decode()
    return f"data:{mime};base64,{b64}"

实际效果

修复后,即使 Discord CDN 把 PNG 文件存成了 .webp 扩展名,魔数检测也能正确识别出 image/png,Claude API 接收正常,HTTP 400 错误消失。

经验总结

不要用文件扩展名判断文件类型------这是安全和兼容性领域的经典教训,在 AI Gateway 场景下同样成立:

  • 扩展名是用户/系统可以随意命名的元数据,不可信
  • 文件头字节是由生成工具写入的实际数据,可靠
  • CDN、代理、缓存系统在转码/转发过程中可能改变扩展名但不改变内容

这个 bug 的出现恰好是"三方联动"------Discord CDN 的转码行为 + Gateway 的扩展名猜测逻辑 + Claude API 的严格校验,三者叠加才暴露出来。单独看每一方都"没错",但组合在一起就出问题了。这类分布式系统中的隐蔽 bug,往往需要把整个数据流串起来才能找到根因。

参考

相关推荐
成为你的宁宁2 小时前
【K8s Service 基础知识、五大类型应用机制及Endpoint 深度解析】
云原生·容器·kubernetes
老卢聊运维2 小时前
K8s 资源一直 Terminating?kubectl 强制删除完整实操手册
云原生·容器·kubernetes
眷蓝天2 小时前
Kubernetes Ingress 资源对象
云原生·容器·kubernetes
步步为营DotNet3 小时前
NET 11 中 C# 14 新特性在云原生微服务架构的深度实践
云原生·架构·c#
ん贤3 小时前
Kubernetes入门
云原生·容器·kubernetes
AI攻城狮17 小时前
为什么主流大厂 LLM 必须亲自下场做 Harness CLI:从 DeepSeek TUI 说开去
云原生
阿里云云原生18 小时前
阿里云 AI 网关支持 DeepSeek V4
云原生
阿里云云原生18 小时前
从可观测到可理解:用 UModel 构建 Agent 原生的代码知识图谱
云原生
阿里云云原生19 小时前
OpenClaw、Hermes合用?来自500+社区互动的真实看法
云原生