图片上传与媒体文件
学习目标
为文章增加可选封面,正确使用 request.FILES 和 multipart 表单,限制文件内容、体积、格式与像素,保持媒体文件独立存储。
为什么只检查扩展名不够
把可执行内容改名成 .png 不会把它变成图片。ImageField 会尝试解析图像,表单进一步限制 JPEG、PNG、WebP、2 MB 和 1600 万像素。文件名由 UUID 生成,并根据检测到的格式设置后缀,避免用户控制存储路径或上传 HTML 后缀。
文件和数据库不是一个事务系统。数据库保存失败后已经写入的图片可能成为孤立文件,替换封面也不会自动删除旧图片。本教程保留旧文件,维护章节提供只读清单,人工确认后再清理,避免误删共享或仍被引用的图片。
具体怎么做
python -m pip install -r requirements.txt
python manage.py makemigrations blog
python manage.py migrate
更新模型、表单、视图和模板。POST 表单必须增加 enctype='multipart/form-data',构造表单时同时传入 request.POST 与 request.FILES。配置 MEDIA_ROOT 和 MEDIA_URL,开发路由仅在 DEBUG 时提供媒体;生产由 Nginx 处理。
下方已包含迁移文件。使用阶段代码时只需 migrate,不要额外生成重复迁移。
本章新增或替换的完整文件
以下路径均相对于练习项目根目录。替换同名文件的全部内容;未列出的文件保持上一章版本。
blog/forms.py
from django import forms
from .models import Post
class PostForm(forms.ModelForm):
class Meta:
model = Post
fields = ("title", "category", "body", "status", "cover")
widgets = {"body": forms.Textarea(attrs={"rows": 12})}
def clean_title(self):
title = self.cleaned_data["title"].strip()
if len(title) < 5:
raise forms.ValidationError("标题至少需要 5 个字符。")
return title
def clean_body(self):
body = self.cleaned_data["body"].strip()
if len(body) < 20:
raise forms.ValidationError("请至少输入 20 个字符,说明你的想法。")
return body
def clean_cover(self):
image = self.cleaned_data.get("cover")
if not image or not hasattr(image, "image"):
return image
if image.size > 2 * 1024 * 1024:
raise forms.ValidationError("图片不能超过 2 MB。")
if image.image.format not in {"JPEG", "PNG", "WEBP"}:
raise forms.ValidationError("只支持 JPEG、PNG 和 WebP 图片。")
if image.image.width * image.image.height > 16_000_000:
raise forms.ValidationError("图片像素过大,请缩小后再上传。")
suffixes = {"JPEG": ".jpg", "PNG": ".png", "WEBP": ".webp"}
image.name = "cover" + suffixes[image.image.format]
return image
blog/migrations/0002_post_cover.py
# Generated by Django 6.1.2 on 2026-11-01 11:13
import blog.uploads
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('blog', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='post',
name='cover',
field=models.ImageField(blank=True, upload_to=blog.uploads.cover_path, verbose_name='封面'),
),
]
blog/models.py
from django.conf import settings
from django.db import models
from django.urls import reverse
from .uploads import cover_path
class Category(models.Model):
name = models.CharField("名称", max_length=40, unique=True)
slug = models.SlugField("地址标识", unique=True)
class Meta:
ordering = ["name", "pk"]
def __str__(self):
return self.name
class Post(models.Model):
class Status(models.TextChoices):
DRAFT = "draft", "草稿"
PUBLISHED = "published", "已发布"
title = models.CharField("标题", max_length=120)
body = models.TextField("正文")
author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="posts")
category = models.ForeignKey(Category, on_delete=models.PROTECT, related_name="posts")
status = models.CharField("状态", max_length=12, choices=Status, default=Status.DRAFT)
cover = models.ImageField("封面", upload_to=cover_path, blank=True)
created_at = models.DateTimeField("创建时间", auto_now_add=True)
updated_at = models.DateTimeField("修改时间", auto_now=True)
class Meta:
ordering = ["-created_at", "-pk"]
indexes = [models.Index(fields=["status", "-created_at"], name="post_status_created_idx")]
constraints = [models.CheckConstraint(condition=models.Q(status__in=["draft", "published"]), name="post_valid_status")]
def __str__(self):
return self.title
def get_absolute_url(self):
return reverse("blog:detail", args=[self.pk])
blog/uploads.py
from pathlib import Path
from uuid import uuid4
def cover_path(instance, filename):
return f"covers/{uuid4().hex}{Path(filename).suffix.lower()}"
blog/views.py
from django.shortcuts import render, redirect, get_object_or_404
from django.views.decorators.http import require_GET, require_http_methods
from django.contrib.auth.decorators import login_required
from django.contrib import messages
from .forms import PostForm
from django.core.paginator import Paginator
from django.db.models import Q
from .models import Category, Post
@require_GET
def home(request):
posts = Post.objects.filter(status=Post.Status.PUBLISHED).select_related("author", "category")
q = request.GET.get("q", "").strip()[:100]
category = request.GET.get("category", "").strip()[:50]
if q:
posts = posts.filter(Q(title__icontains=q) | Q(body__icontains=q))
if category:
posts = posts.filter(category__slug=category)
page_obj = Paginator(posts, 6).get_page(request.GET.get("page"))
return render(request, "blog/home.html", {"page_obj": page_obj, "q": q,
"selected_category": category, "categories": Category.objects.all()})
@require_GET
def detail(request, pk):
visible = Q(status=Post.Status.PUBLISHED)
if request.user.is_authenticated:
visible |= Q(author=request.user)
post = get_object_or_404(Post.objects.select_related("author", "category").filter(visible), pk=pk)
return render(request, "blog/detail.html", {"post": post})
@require_GET
def about(request):
return render(request, "blog/about.html")
@require_GET
def old_home(request):
return redirect("blog:home")
@login_required
@require_GET
def mine(request):
page_obj = Paginator(Post.objects.filter(author=request.user).select_related("category"), 10).get_page(request.GET.get("page"))
return render(request, "blog/mine.html", {"page_obj": page_obj})
@login_required
@require_http_methods(["GET", "POST"])
def create(request):
form = PostForm(request.POST if request.method == "POST" else None, request.FILES or None)
if request.method == "POST" and form.is_valid():
post = form.save(commit=False)
post.author = request.user
post.save()
messages.success(request, "文章已保存。")
return redirect(post)
return render(request, "blog/form.html", {"form": form, "heading": "写文章"})
@login_required
@require_http_methods(["GET", "POST"])
def edit(request, pk):
post = get_object_or_404(Post, pk=pk, author=request.user)
form = PostForm(request.POST if request.method == "POST" else None, request.FILES or None, instance=post)
if request.method == "POST" and form.is_valid():
form.save()
messages.success(request, "修改已保存。")
return redirect(post)
return render(request, "blog/form.html", {"form": form, "heading": "编辑文章"})
@login_required
@require_http_methods(["GET", "POST"])
def delete(request, pk):
post = get_object_or_404(Post, pk=pk, author=request.user)
if request.method == "POST":
post.delete()
messages.success(request, "文章已删除。")
return redirect("blog:mine")
return render(request, "blog/confirm_delete.html", {"post": post})
config/settings.py
import os
from pathlib import Path
from django.core.exceptions import ImproperlyConfigured
BASE_DIR = Path(__file__).resolve().parent.parent
DEBUG = os.environ.get("DJANGO_DEBUG", "1") == "1"
SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "development-only-never-use-this-key-in-production")
if not DEBUG and (SECRET_KEY.startswith("development-") or len(SECRET_KEY) < 50):
raise ImproperlyConfigured("生产环境必须设置至少 50 字符的独立 DJANGO_SECRET_KEY")
ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1,[::1]").split(",")
INSTALLED_APPS = [
"django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes",
"django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles",
"accounts.apps.AccountsConfig", "blog.apps.BlogConfig",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "config.urls"
TEMPLATES = [{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [BASE_DIR / "templates"], "APP_DIRS": True,
"OPTIONS": {"context_processors": [
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
]},
}]
WSGI_APPLICATION = "config.wsgi.application"
ASGI_APPLICATION = "config.asgi.application"
DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": BASE_DIR / "db.sqlite3"}}
AUTH_PASSWORD_VALIDATORS = [
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
]
LANGUAGE_CODE = "zh-hans"
TIME_ZONE = "Asia/Shanghai"
USE_I18N = True
USE_TZ = True
STATIC_URL = "/static/"
STATICFILES_DIRS = [BASE_DIR / "static"]
STATIC_ROOT = BASE_DIR / "staticfiles"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
MAILERS = {"default": {"BACKEND": "django.core.mail.backends.console.EmailBackend"}}
AUTH_USER_MODEL = "accounts.User"
LOGIN_URL = "login"
LOGIN_REDIRECT_URL = "blog:home"
LOGOUT_REDIRECT_URL = "blog:home"
MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "media"
DATA_UPLOAD_MAX_MEMORY_SIZE = 3 * 1024 * 1024
FILE_UPLOAD_MAX_MEMORY_SIZE = 2 * 1024 * 1024
config/urls.py
from django.contrib import admin
from django.urls import path, include
urlpatterns = [path("accounts/", include("accounts.urls")), path("admin/", admin.site.urls), path("", include("blog.urls"))]
from django.conf import settings
from django.conf.urls.static import static
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
requirements.txt
Django==6.1.2
pillow==13.0.0
templates/blog/detail.html
{% extends "base.html" %}
{% block title %}{{ post.title }} · 知行博客{% endblock %}
{% block content %}<article>
<h1>{{ post.title }}</h1><p class="muted">{{ post.author.username }} · {{ post.category.name }} · {{ post.created_at|date:"Y-m-d H:i" }}</p>
{% if post.status == 'draft' %}<p>这是一篇草稿,只有作者可以查看。</p>{% endif %}
{% if user.pk == post.author_id %}<p><a href="{% url 'blog:edit' post.pk %}">编辑</a> · <a href="{% url 'blog:delete' post.pk %}">删除</a></p>{% endif %}
{% if post.cover %}<img src="{{ post.cover.url }}" alt="{{ post.title }}的封面" loading="lazy">{% endif %}
<div>{{ post.body|linebreaks }}</div>
</article>{% endblock %}
templates/blog/form.html
{% extends "base.html" %}
{% block title %}{{ heading }} · 知行博客{% endblock %}
{% block content %}<h1>{{ heading }}</h1><form method="post" enctype="multipart/form-data" class="panel">{% csrf_token %}
{{ form.as_p }}<button type="submit">保存文章</button> <a href="{% url 'blog:mine' %}">取消</a></form>{% endblock %}
如何确认成功
新建文章上传一张正常 PNG,详情完整展示封面,media/covers/ 下出现随机文件名。编辑时不选新文件,旧封面继续保留;勾选清除后详情不显示图片。
再上传一个重命名为 .png 的文本文件、一张超过 2 MB 的图片和一个 GIF,均应得到表单错误。浏览器不能仅凭 accept 属性保证安全,所以验证以服务端结果为准。
练习:观察图片 URL 与磁盘路径的差别,解释为什么模板使用 .url 而不是拼接 MEDIA_ROOT。使用手机拍摄的竖图检查比例,图片应保持高度自适应而不是被拉伸。
常见问题
request.FILES 为空? 检查表单编码、文件字段名称和请求方法。Pillow 未安装? 确认 pip 使用的是项目虚拟环境。DATA_UPLOAD_MAX_MEMORY_SIZE 能限制所有上传吗? 它并不代替反向代理请求体限制和文件级校验;部署章节设置 client_max_body_size。隐藏图片 URL 能保护草稿封面吗? 不能,当前媒体属于公开静态资源;真正私密附件需要鉴权下载或私有对象存储。
官方参考
登录后可保存阅读位置、标记章节完成,并在其他设备继续学习。
登录并保存学习进度 →联系站长
微信:VIPdjango。技术问题建议先在 社区讨论 中交流,方便更多人参与和查阅。
章节讨论 0