缓存还了性能债,还差"内容":早报站的文章要能配图。今天打开用户生成内容(UGC)的大门------但这也是全系列危险等级最高的接口:文件上传 = 把用户的东西写进你的服务器。
🎯 本篇产出:图片上传接口(校验 + 流式写入 + 服务器命名)+ 图片展示路由 + 前端上传组件。含代码约110行。
📌 太长不看版(给想快速上手的你)
| 项目信息 | 一句话说明 |
|---|---|
| 本篇目标 | 给早报站加上图片上传与展示 |
| 代码行数 | ~110行(前后端两侧) |
| 依赖 | python-multipart |
| 核心功能 | 上传校验 + UUID命名 + 流式写入 + 展示 |
| 跑起来的命令 | uvicorn + npm run dev → 上传图片 |
| 核心知识点 | UploadFile、后缀白名单、路径穿越防护 |
| 做完你能得到 | 一个安全的文件上传接口 + 展示页面 |
🚨 一句话立住安全观 :用户上传的文件是"不可信输入 "------文件名、内容、大小、类型,没有一样可以信。
一、概念课:文件上传的三步与两个抉择
🤔 为什么上传不走JSON?
图片是二进制,HTTP传二进制用multipart/form-data编码------表单里enctype="multipart/form-data"时,FastAPI收到的就是UploadFile对象(含文件名、大小、内容流)。
📋 上传的三步
text
① 收文件(UploadFile)→ ② 校验(类型/大小)→ ③ 落盘(写到服务器磁盘)
⚠️ 每一步都有陷阱。
🎯 两个抉择先想清楚
| # | 抉择 | 选项 | 本篇选择 |
|---|---|---|---|
| ① | 文件存哪 | 本地磁盘(教学)vs 对象存储(S3/OSS,生产推荐------容量、CDN、防丢) | 本地磁盘,生产迁移路径在练习④ |
| ② | 文件名谁定 | 用户原名 vs 服务器生成 | 绝不用用户的原文件名 ------它可能包含路径穿越(../../etc/passwd)、特殊字符、重名冲突。服务器用UUID重新命名 |
二、第0步:配置与依赖
bash
pip install python-multipart # UploadFile需要它
🔧 Settings加上传配置(app/config.py)
python
class Settings(BaseSettings):
...
upload_dir: str = "uploads"
max_upload_mb: int = 2 # 单文件上限2MB
📄 .env示例
text
UPLOAD_DIR=uploads
MAX_UPLOAD_MB=2
🚫 .gitignore加一行
text
uploads/
📌 用户数据不进仓库------上传的图片是运行时数据,不是代码。
三、第1步:Article模型加字段(alembic的第四次兑现)
core/models.py 加一行:
python
class Article(Base):
...
image_url: Mapped[str] = mapped_column(server_default="") # 新增
bash
alembic revision --autogenerate -m "add article image_url"
alembic upgrade head
🎯 Alembic在这里再次兑现------加字段不用删库。
四、第2步:上传接口------校验、命名、流式写入
app/routers/images.py(新文件):
python
"""app/routers/images.py ------ 图片上传与展示"""
import uuid
from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session # ⚠️ 别忘导入
from app.config import settings
from app.deps import get_current_user, get_session
from core.models import Article, User
router = APIRouter(prefix="/images", tags=["images"])
ALLOWED_SUFFIX = {".jpg", ".jpeg", ".png", ".webp"} # 白名单,不是黑名单!
def _save_image(upload: UploadFile) -> str:
"""校验 + 命名 + 流式写入,返回文件名"""
# ① 后缀白名单:不在名单里的直接拒绝
suffix = Path(upload.filename or "").suffix.lower()
if suffix not in ALLOWED_SUFFIX:
raise HTTPException(status_code=400, detail="仅支持 jpg / png / webp")
# ② 大小限制:先看 size,超限直接拒(413)
if upload.size and upload.size > settings.max_upload_mb * 1024 * 1024:
raise HTTPException(status_code=413, detail="文件过大")
# ③ 服务器生成文件名:UUID防重名、防路径穿越、防原文件名注入
dest_dir = Path(settings.upload_dir)
dest_dir.mkdir(exist_ok=True)
dest = dest_dir / (uuid.uuid4().hex + suffix)
# ④ 流式写入:分块拷贝,大文件不会撑爆内存
with dest.open("wb") as out:
for chunk in upload.file:
out.write(chunk)
return dest.name
@router.post("/articles/{article_id}/image")
async def upload_image(
article_id: int,
file: UploadFile = File(...), # multipart表单字段名必须是file
session: Session = Depends(get_session),
user: User = Depends(get_current_user), # 登录门禁
):
article = session.get(Article, article_id)
if article is None:
raise HTTPException(status_code=404, detail="文章不存在")
name = _save_image(file)
article.image_url = f"/api/images/{name}" # 记录到文章上
session.commit()
return {"image_url": article.image_url}
🖼️ 展示路由(同文件追加)
python
@router.get("/{name}")
def serve_image(name: str):
# ⑤ 防路径穿越:name只能是纯文件名
if "/" in name or "\\" in name or name.startswith("."):
raise HTTPException(status_code=400, detail="非法路径")
path = Path(settings.upload_dir) / name
if not path.is_file():
raise HTTPException(status_code=404, detail="图片不存在")
return FileResponse(path)
📌 五个安全设计,全是"不可信输入"的防线
| # | 防线 | 做法 |
|---|---|---|
| ① | 后缀白名单 | 不在名单直接400 |
| ② | UUID文件名 | 原文件名只能当展示信息 |
| ③ | 大小上限 | 超限413 |
| ④ | 路径校验 | 拒绝穿越 |
| ⑤ | 流式写入 | 分块拷贝,大文件不撑爆内存 |
🔧 挂载路由(app/main.py)
python
from app.routers import images
app.include_router(images.router, prefix="/api")
五、第3步:前端上传组件------FormData与预览
src/components/ImageUploader.vue(新组件):
vue
<script setup>
import { ref } from 'vue'
import api from '../lib/api'
const props = defineProps({
articleId: Number,
modelValue: String,
})
const emit = defineEmits(['update:modelValue'])
const uploading = ref(false)
const errorMsg = ref('')
async function onFile(e) {
const file = e.target.files[0]
if (!file) return
uploading.value = true
errorMsg.value = ''
try {
const form = new FormData() // multipart编码的容器
form.append('file', file) // 字段名必须是file(与后端对齐)
// ⚠️ 不要手动设Content-Type,浏览器会自动带boundary
const data = await api.post(
`/images/articles/${props.articleId}/image`, form)
emit('update:modelValue', data.image_url)
} catch (err) {
errorMsg.value = err.message
} finally {
uploading.value = false
}
}
</script>
<template>
<div>
<input type="file" accept="image/jpeg,image/png,image/webp" @change="onFile" />
<p v-if="uploading">上传中...</p>
<p v-if="errorMsg" class="error">{{ errorMsg }}</p>
<img v-if="modelValue" :src="modelValue" style="max-width: 100%" />
</div>
</template>
📌 三个要点
| # | 要点 | 说明 |
|---|---|---|
| ① | FormData | multipart的容器(字段名file必须与后端File(...)对齐) |
| ② | 不要手动设Content-Type | axios发FormData时浏览器会自动带boundary,手动设反而坏事 |
| ③ | accept属性 |
在源头过滤文件类型(体验优化,不替代后端校验) |
🖼️ ArticleCard顶部加图片展示
src/components/ArticleCard.vue 加一个prop和一行模板:
vue
<script setup>
import defaultCover from '@/assets/default-cover.svg'
const props = defineProps({
...
image: { type: String, default: '' }, // 新增
})
// ⑤ 封面兜底:image_url 为空 或 加载失败(如 127.0.0.1 旧链接 404)→ 本地默认图
const coverFailed = ref(false)
const coverSrc = computed(() =>
(props.image_url && !coverFailed.value) ? props.image_url : defaultCover)
function onCoverError() {
coverFailed.value = true
}
</script>
<template>
<article class="card">
<img :src="coverSrc" class="cover" alt="" @error="onCoverError" /> <!-- 新增 -->
<h2>...</h2>
</article>
</template>
🚨 图片URL的跨域坑
📌
<img :src="/api/images/xxx">会请求前端服务器(5173),而不是后端(8000)。 因为相对路径是相对于当前页面地址的。
两种解法:
| 方案 | 做法 | 适用场景 |
|---|---|---|
| 方案A(推荐) | 后端返回完整URL :image_url = f"http://127.0.0.1:8000/api/images/{name}" |
简单直接 |
| 方案B | Vite配置代理,把/api转发到后端 |
需要学习代理配置 |
本篇用方案A ------在upload_image里改成:
python
article.image_url = f"http://127.0.0.1:8000/api/images/{name}"
⚠️ 生产环境要换成你的域名。更规范的做法是用环境变量存base URL。
六、验收清单
bash
1. POST /api/images/articles/1/image上传jpg → 返回完整image_url
2. 浏览器打开image_url → 图片正常显示
3. 上传.txt伪装.jpg → 400拒绝
4. 上传3MB大文件 → 413拒绝
5. 未登录上传 → 401
6. 上传名为../../etc/passwd的"文件" → 不可能(UUID重命名已防)
7. psql看articles.image_url已写入
8. 前端上传后图片正常显示(跨域问题已解决)
9. 全程无报错后提交Git
bash
git add .
git commit -m "图片上传:校验 + 流式写入 + 展示"
七、常见报错:这6个,文件上传的标配(重点!)
① 422: field required(file字段缺失)
🔍 原因 :前端FormData的字段名与后端File(...)的参数名不一致。
✅ 解法 :form.append('file', file)的'file'必须与后端参数名逐字相同。
② 上传成功但图片打不开
🔍 原因:
FileResponse没带media_type(一般会自动推断);- 或URL拼接错误(少了
/api前缀); - 或前端用了相对路径导致请求发到前端服务器(第5节跨域坑)。
✅ 解法 :检查返回的image_url是否完整(含协议+主机+端口)。
③ 超大文件把内存撑爆 / 卡死
🔍 原因 :file.read()一次性读入全部内容。
✅ 解法 :流式写入(第4节④的分块循环)------大文件只占一小块缓冲区。
④ 用户上传了改后缀的恶意文件
🔍 原因 :只查了扩展名------.jpg后缀里可能藏HTML/脚本。
✅ 解法 :练习①用Pillow Image.open(...).verify()校验"真的是图片";存储目录不执行任何脚本。
⑤ 上传的图片中文文件名变成乱码
🔍 原因 :你还在用原文件名存储------本篇从设计上就杜绝了(UUID命名)。
✅ 解法:无。
📌 服务器生成文件名,是文件上传的第一军规。
⑥ 同名文件互相覆盖
🔍 原因:同上------按原文件名存储。
✅ 解法 :同上。UUID命名天然全局唯一。
八、课后练习
| # | 练习 | 难度 | 提示 |
|---|---|---|---|
| 1 | 真图片校验:用Pillow验证上传内容真的是图片 | ⭐⭐⭐ | Image.open(file.file); img.verify()------try/except拒绝伪图片 |
| 2 | 缩略图:上传成功后生成200px宽的缩略图 | ⭐⭐⭐ | img.thumbnail((200, 200)); img.save(...) |
| 3 | 删除接口 :DELETE /api/images/{name}------只允许文章作者删除自己的配图 |
⭐⭐⭐ | 校验归属 |
| 4(选做) | 对象存储迁移:研究S3/OSS的SDK上传接口 | ⭐⭐⭐⭐ | 本地磁盘换成云存储,接口签名不变 |
📦 配套代码
上传接口、展示路由与前端组件已上传Git(python_daily/):【gitee仓库地址】