# 修复 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`: ```python 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 ) ``` ### 修改前后对比 #### 修改前 ```http GET /jointo/screenplays/parsed/xxx.md HTTP/1.1 ... HTTP/1.1 200 OK Content-Type: text/markdown Content-Length: 12345 ... 《狮⼦回头》 ← 浏览器按错误编码解析,显示为乱码 ``` #### 修改后 ```http 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. 上传新剧本文件 ```bash # 上传 PDF/DOCX 剧本文件 curl -X POST "http://localhost:6170/api/v1/screenplays/file" \ -H "Authorization: Bearer " \ -F "file=@test.pdf" \ -F "name=测试剧本" \ -F "projectId=" ``` ### 2. 等待解析完成 轮询 `parse-status` 接口直到 `parsingStatus: "completed"`。 ### 3. 获取 Markdown 文件 URL ```json { "data": { "parsingStatus": "completed", "fileUrl": "http://localhost:9000/jointo/screenplays/parsed/019c326b-ee0e-7fa0-8361-12924ba196d2.md" } } ``` ### 4. 在浏览器中打开 直接访问 `fileUrl`,验证中文字符是否正确显示。 ### 5. 检查 HTTP 响应头 使用浏览器开发者工具或 curl: ```bash curl -I "http://localhost:9000/jointo/screenplays/parsed/019c326b-ee0e-7fa0-8361-12924ba196d2.md" ``` 预期响应头: ```http HTTP/1.1 200 OK Content-Type: text/markdown; charset=utf-8 ← ✅ 包含 charset=utf-8 Content-Length: 12345 ... ``` ### 6. 验证内容 ```bash curl "http://localhost:9000/jointo/screenplays/parsed/019c326b-ee0e-7fa0-8361-12924ba196d2.md" ``` 预期输出(正确显示中文): ```markdown 《狮子回头》 ## 第一场 场景:夜晚,街头 角色A:你好... ``` --- ## 🔄 已上传文件的处理 ### 旧文件(已上传) 已经上传到 OSS 的 Markdown 文件**不会自动更新**,因为: - OSS 对象的 metadata 是在上传时设置的 - 已上传的文件保持原有的 `Content-Type: text/markdown`(无 charset) ### 解决方法 **选项 1: 重新上传(推荐)** 重新上传剧本文件,触发重新解析和 Markdown 文件生成: 1. 删除旧剧本 2. 重新上传同一文件 3. 新生成的 Markdown 文件将包含正确的 `charset=utf-8` **选项 2: 批量更新 OSS 对象 metadata** 如果有大量旧文件需要修复,可以使用以下脚本: ```python 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 字符串与字节的转换 ```python # ✅ 正确:明确指定 UTF-8 编码 content_bytes = markdown_content.encode('utf-8') # ❌ 错误:使用默认编码(可能不是 UTF-8) content_bytes = markdown_content.encode() ``` ### 2. 文件读写 ```python # ✅ 正确:明确指定 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 响应头 ```python # ✅ 正确:文本类型应指定 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 ``` --- ## 🔗 相关文档 - [RFC 140: 剧本文件存储重构](../rfcs/140-screenplay-file-storage-refactor.md) - [解析服务实现](../../app/services/screenplay_file_parser_service.py) - [存储服务实现](../../app/core/storage.py) --- ## ✨ 总结 - **问题**: Markdown 文件上传到 OSS 后,缺少 `charset=utf-8` 导致乱码 - **原因**: HTTP 响应头未明确指定字符编码 - **解决**: 在上传文本文件时,自动添加 `; charset=utf-8` 到 `Content-Type` - **影响**: 所有新上传的文本文件(`text/*`)都将正确设置编码 - **旧文件**: 需要重新上传或手动更新 OSS 对象 metadata