使用Django6.1开发博客(11) - 图片上传与OSS

博客经常需要给文章配图。我加入图片附件模型,并把图片写入阿里云 OSS。五个配置全部来自环境变量,密钥不写进代码。

https://static.xiongneng.me/oss-deployment-20260926000000.png

上传链路是 Django 收文件,ImageField 校验图片,自定义 Storage 调用 OSS SDK,数据库只保存对象键。

自定义 Storage

新建 blog/storage.py,继承 django.core.files.storage.Storage。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import os
import oss2
from django.core.exceptions import ImproperlyConfigured
from django.core.files.storage import Storage
from django.utils.deconstruct import deconstructible

@deconstructible
class AliyunOSSStorage(Storage):
    def _config(self):
        keys = ["OSS_ACCESS_KEY_ID", "OSS_ACCESS_KEY_SECRET", "OSS_BUCKET_NAME", "OSS_ENDPOINT", "OSS_REGION"]
        values = {key: os.environ.get(key) for key in keys}
        missing = [key for key, value in values.items() if not value]
        if missing:
            raise ImproperlyConfigured("缺少 OSS 环境变量:" + ", ".join(missing))
        return values

    def _bucket(self):
        config = self._config()
        auth = oss2.ProviderAuthV4(oss2.credentials.EnvironmentVariableCredentialsProvider())
        endpoint = config["OSS_ENDPOINT"]
        if not endpoint.startswith(("http://", "https://")):
            endpoint = "https://" + endpoint
        return oss2.Bucket(auth, endpoint, config["OSS_BUCKET_NAME"], region=config["OSS_REGION"])

@deconstructible 不能少。没有它,Django 生成迁移时无法序列化 storage=AliyunOSSStorage()。

保存文件时生成新对象键。

1
2
3
4
5
6
7
8
from datetime import datetime
from uuid import uuid4

def _save(self, name, content):
    ext = os.path.splitext(name)[1].lower()
    key = f"blog/attachments/{datetime.now():%Y%m%d}/{uuid4().hex}{ext}"
    self._bucket().put_object(key, content.read())
    return key

用 UUID 命名可以避免覆盖。原文件名不再出现在对象键里,数据库里的 title 负责给人看。

附件模型和后台

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
from django.db import models
from .storage import AliyunOSSStorage

class Attachment(models.Model):
    post = models.ForeignKey(
        Post, verbose_name="文章", on_delete=models.CASCADE,
        related_name="attachments",
    )
    image = models.ImageField(
        "图片", upload_to="blog/attachments/", storage=AliyunOSSStorage()
    )
    title = models.CharField("标题", max_length=80, blank=True)
    uploaded_by = models.ForeignKey(
        "auth.User", verbose_name="上传人", on_delete=models.CASCADE
    )
    created_at = models.DateTimeField("创建时间", auto_now_add=True)

ImageField 会检查上传内容能不能作为图片打开。post 用 PROTECT 也可以,这里选择 CASCADE,表示附件是文章的一部分。

Admin 注册很直接。

1
2
3
4
@admin.register(Attachment)
class AttachmentAdmin(admin.ModelAdmin):
    list_display = ["title", "post", "uploaded_by", "created_at"]
    raw_id_fields = ["post"]

详情页展示附件。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
{% if post.attachments.all %}
  <div class="attachments">
    {% for attachment in post.attachments.all %}
      <figure>
        <img src="{{ attachment.image.url }}" alt="{{ attachment.title|default:attachment.image.name }}">
        {% if attachment.title %}<figcaption>{{ attachment.title }}</figcaption>{% endif %}
      </figure>
    {% endfor %}
  </div>
{% endif %}

测试时不请求阿里云

测试用本地 FileSystemStorage 替换字段上的存储,验证 Django 模型和上传流程。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
from django.core.files.storage import FileSystemStorage
from django.core.files.uploadedfile import SimpleUploadedFile

png = bytes.fromhex("89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000a49444154789c6300010000050001")
original = Attachment._meta.get_field("image").storage
with tempfile.TemporaryDirectory() as root:
    Attachment._meta.get_field("image").storage = FileSystemStorage(location=root)
    try:
        attachment = Attachment.objects.create(
            post=post,
            uploaded_by=user,
            title="结构图",
            image=SimpleUploadedFile("diagram.png", png, content_type="image/png"),
        )
        self.assertTrue(Path(attachment.image.path).exists())
    finally:
        Attachment._meta.get_field("image").storage = original

真实上传由 OSS SDK 完成,单元测试不重复测试 SDK。它要证明的是模型、字段和存储接口接得正确。

运行迁移和测试。

1
2
3
4
uv add Pillow oss2
uv run manage.py makemigrations blog
uv run manage.py migrate
uv run manage.py test

迁移输出如下。

1
2
3
Migrations for 'blog':
  blog\migrations\0005_attachment.py
    + Create model Attachment

测试结果如下。

1
2
3
Ran 26 tests in 5.135s

OK

运行前导出五个环境变量。.env 不进入 Git。

1
2
3
4
5
export OSS_ACCESS_KEY_ID=...
export OSS_ACCESS_KEY_SECRET=...
export OSS_BUCKET_NAME=...
export OSS_ENDPOINT=...
export OSS_REGION=...

图片上传到 OSS 后,详情页只引用远端 URL。Django 进程不长期保存文件,也不把桶地址和密钥混进数据库。

源码

GitHub 地址:https://github.com/yidao620c/simpleblog