You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 

6.9 KiB

修复 Markdown 文件编码乱码问题

日期: 2026-02-06
类型: Bug 修复
关联: RFC 140 - 剧本文件存储重构


🐛 问题描述

上传到 OSS 的解析后 Markdown 文件在浏览器中打开时显示为乱码。

症状

  • 解析后的 Markdown 文件内容在浏览器中无法正常显示中文
  • 中文字符显示为乱码或 ? 符号
  • 下载文件后用文本编辑器打开也可能显示为乱码

根本原因

在上传 Markdown 文件到 OSS 时,虽然内容使用了 UTF-8 编码(markdown_content.encode('utf-8')),但没有在 HTTP 响应头中明确指定 charset=utf-8,导致:

  1. OSS 返回文件时,HTTP 响应头为:Content-Type: text/markdown
  2. 浏览器无法确定文件编码,可能按默认编码(如 GBK、Latin-1)解析
  3. 导致中文字符显示为乱码

解决方案

修改 StorageService.upload_bytes() 方法

文件: server/app/core/storage.py

在上传文本类型文件时,自动在 Content-Type 头中添加 charset=utf-8

async def upload_bytes(
    self,
    data: bytes,
    object_name: str,
    content_type: Optional[str] = None
) -> str:
    try:
        extra_args = {}
        if content_type:
            extra_args['ContentType'] = content_type
            # ✅ 如果是文本类型,添加 UTF-8 charset
            if content_type and content_type.startswith('text/'):
                extra_args['ContentType'] = f"{content_type}; charset=utf-8"
        
        self.client.put_object(
            Bucket=self.bucket_name,
            Key=object_name,
            Body=data,
            **extra_args
        )

修改前后对比

修改前

GET /jointo/screenplays/parsed/xxx.md HTTP/1.1
...

HTTP/1.1 200 OK
Content-Type: text/markdown
Content-Length: 12345
...

《狮⼦回头》  ← 浏览器按错误编码解析,显示为乱码

修改后

GET /jointo/screenplays/parsed/xxx.md HTTP/1.1
...

HTTP/1.1 200 OK
Content-Type: text/markdown; charset=utf-8  ← ✅ 明确指定 UTF-8
Content-Length: 12345
...

《狮子回头》  ← 正确显示中文

🎯 影响范围

受影响的文件类型

此修复自动应用于所有以 text/ 开头的 MIME 类型:

  • text/markdown - Markdown 文件(剧本解析后的文件)
  • text/plain - 纯文本文件
  • text/html - HTML 文件
  • text/csv - CSV 文件
  • text/xml - XML 文件

不受影响的文件类型

二进制文件不受影响(无需 charset):

  • application/pdf
  • application/vnd.openxmlformats-officedocument.wordprocessingml.document (DOCX)
  • image/*
  • video/*
  • audio/*

🧪 验证方法

1. 上传新剧本文件

# 上传 PDF/DOCX 剧本文件
curl -X POST "http://localhost:6170/api/v1/screenplays/file" \
  -H "Authorization: Bearer <token>" \
  -F "file=@test.pdf" \
  -F "name=测试剧本" \
  -F "projectId=<project_id>"

2. 等待解析完成

轮询 parse-status 接口直到 parsingStatus: "completed"

3. 获取 Markdown 文件 URL

{
  "data": {
    "parsingStatus": "completed",
    "fileUrl": "http://localhost:9000/jointo/screenplays/parsed/019c326b-ee0e-7fa0-8361-12924ba196d2.md"
  }
}

4. 在浏览器中打开

直接访问 fileUrl,验证中文字符是否正确显示。

5. 检查 HTTP 响应头

使用浏览器开发者工具或 curl:

curl -I "http://localhost:9000/jointo/screenplays/parsed/019c326b-ee0e-7fa0-8361-12924ba196d2.md"

预期响应头:

HTTP/1.1 200 OK
Content-Type: text/markdown; charset=utf-8  ← ✅ 包含 charset=utf-8
Content-Length: 12345
...

6. 验证内容

curl "http://localhost:9000/jointo/screenplays/parsed/019c326b-ee0e-7fa0-8361-12924ba196d2.md"

预期输出(正确显示中文):

《狮子回头》

## 第一场

场景:夜晚,街头

角色A:你好...

🔄 已上传文件的处理

旧文件(已上传)

已经上传到 OSS 的 Markdown 文件不会自动更新,因为:

  • OSS 对象的 metadata 是在上传时设置的
  • 已上传的文件保持原有的 Content-Type: text/markdown(无 charset)

解决方法

选项 1: 重新上传(推荐)

重新上传剧本文件,触发重新解析和 Markdown 文件生成:

  1. 删除旧剧本
  2. 重新上传同一文件
  3. 新生成的 Markdown 文件将包含正确的 charset=utf-8

选项 2: 批量更新 OSS 对象 metadata

如果有大量旧文件需要修复,可以使用以下脚本:

import boto3
from app.core.config import settings

s3_client = boto3.client(
    's3',
    endpoint_url=settings.S3_ENDPOINT_URL,
    aws_access_key_id=settings.S3_ACCESS_KEY_ID,
    aws_secret_access_key=settings.S3_SECRET_ACCESS_KEY,
    region_name=settings.S3_REGION
)

# 列出所有 Markdown 文件
response = s3_client.list_objects_v2(
    Bucket=settings.S3_BUCKET_NAME,
    Prefix='screenplays/',
)

for obj in response.get('Contents', []):
    if obj['Key'].endswith('.md'):
        # 复制对象到自身,更新 Content-Type
        s3_client.copy_object(
            Bucket=settings.S3_BUCKET_NAME,
            CopySource={'Bucket': settings.S3_BUCKET_NAME, 'Key': obj['Key']},
            Key=obj['Key'],
            ContentType='text/markdown; charset=utf-8',
            MetadataDirective='REPLACE'
        )
        print(f"✅ 已更新: {obj['Key']}")

📝 编码最佳实践

1. Python 字符串与字节的转换

# ✅ 正确:明确指定 UTF-8 编码
content_bytes = markdown_content.encode('utf-8')

# ❌ 错误:使用默认编码(可能不是 UTF-8)
content_bytes = markdown_content.encode()

2. 文件读写

# ✅ 正确:明确指定 UTF-8
async with aiofiles.open(file_path, 'r', encoding='utf-8') as f:
    content = await f.read()

# ❌ 错误:使用系统默认编码
async with aiofiles.open(file_path, 'r') as f:  # 可能是 GBK/Latin-1
    content = await f.read()

3. HTTP 响应头

# ✅ 正确:文本类型应指定 charset
Content-Type: text/markdown; charset=utf-8
Content-Type: text/plain; charset=utf-8

# ❌ 错误:缺少 charset
Content-Type: text/markdown

# ✅ 正确:二进制文件不需要 charset
Content-Type: application/pdf
Content-Type: image/jpeg

🔗 相关文档


总结

  • 问题: Markdown 文件上传到 OSS 后,缺少 charset=utf-8 导致乱码
  • 原因: HTTP 响应头未明确指定字符编码
  • 解决: 在上传文本文件时,自动添加 ; charset=utf-8Content-Type
  • 影响: 所有新上传的文本文件(text/*)都将正确设置编码
  • 旧文件: 需要重新上传或手动更新 OSS 对象 metadata