【Web全栈进阶】文件上传与图片展示:早报站的第一张图片

缓存还了性能债,还差"内容":早报站的文章要能配图。今天打开用户生成内容(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仓库地址】

相关推荐
川石课堂软件测试1 小时前
JMeter异步接口测试实战
数据库·python·jmeter·mysql·kubernetes·测试用例·prometheus
alwaysrun1 小时前
Web之wasm-bindgen详解
前端·wasm·bindgen
aichitang20241 小时前
Meta Muse Spark Open:816B 巨型开放权重多模态 Agent 模型架构、部署实战与社区首测全解
大数据·人工智能·python·算法·ai·架构·spark
有同事要进步1 小时前
leecode每日一题 第66题:加1
python
鬼手点金1 小时前
如何选择模型
java·前端·人工智能·git·深度学习
恋猫de小郭1 小时前
Dart 3.15 新特性:this 终于支持类型提升了
android·前端·flutter
小马不起床1 小时前
Django 后端实战指南_16:动作分类、动作库、动作收藏查询接口
数据库·python·django
ycydynq1 小时前
django搜索功能
数据库·python·django
Zella折耳根1 小时前
字符田字格绘制
python