简介:这是一份基于Django框架的视频点播后台管理系统源码,面向Django初中级开发者,可用于掌握内容管理、权限控制、视频上传等后台功能的实现路径。压缩包共43个文件、约1022KB,包含Python源码与字节码、XML配置、SQL数据库、图片和说明文档等类型;其中16个py文件负责核心业务逻辑,12个pyc为编译产物,7个xml承载项目配置,SQL文件可直接导入数据库。目前已有281人学习下载。借助Django自带后台与ORM机制,可快速理解视频信息增删改查的流程;项目采用模块化设计,目录结构清晰,适配二次开发或课程设计参考,也可作为视频平台相关项目的研发起点。

1. 用 Django 做视频点播后台,先分清“管理”和“播”

很多人拿到「基于Django框架的视频点播后台管理系统」这个需求,第一反应是去研究播放器、切片格式和CDN,结果后台管理系统做成了一堆前端页面加几个API,视频却卡在“不知道文件存哪”。做这类项目,真正的难点不是播放,而是把视频文件的存储位置、转码状态、分类归属、权限关系管理清楚。后台管理系统要回答的问题是:运营人员每天进来要干什么——传视频、改信息、上下架、看播放数据;至于播放器怎么播,那是另一个系统的活。

Django在这个场景下的优势是现成的Admin、ORM、权限体系和模板渲染,一个单体应用就能把“后台管理”完整落地,不需要一上来就拆微服务。适合的读者是有Django基础、想接视频类管理系统的开发,或者刚把Python Web学完、想找一个能写进简历的完整项目的人。标题里“设计源码”四个字,本质上是要求你把数据模型、后台配置和管理流程设计清楚,而不是只堆功能页面。

2. Django 视频点播后台的最小骨架:模型、URL 与视图的职责划分

2.1 视频点播项目里 MVT 各层该放什么

Django 的 MVT(Model-View-Template)在视频点播后台里,职责边界比普通 CRUD 项目更清晰。Model 层管视频的元数据,包括标题、分类、文件路径、时长、转码状态;View 层管后台的操作入口,比如“上传视频”“修改视频信息”“删除视频”;Template 层只负责渲染后台页面。不要把视频文件本身的读写逻辑写进 View,那是存储层和转码服务的事。

一个常见的错误是让 Django 直接服务视频文件,用 django.views.static.serve 去响应视频请求。开发环境图省事可以,生产环境必须把视频文件的访问交给 Nginx 或对象存储。后台管理系统里存的是视频的“地址”和“状态”,不是视频本身。这个边界想清楚,后面的代码才不至于越写越乱。

2.2 最小可运行的项目初始化命令

按最常见的做法,先创建一个 Django 项目和对应的 app。命令如下:

# 创建虚拟环境并激活
python -m venv venv
source venv/bin/activate

# 安装 Django,建议使用当前稳定版本
pip install django

# 创建项目 django_vod_admin,创建核心 app video_admin
django-admin startproject django_vod_admin
cd django_vod_admin
python manage.py startapp video_admin

# 同步数据库并创建管理员账号
python manage.py migrate
python manage.py createsuperuser

这段命令里, startproject 生成的是项目配置目录, startapp 生成的才是业务代码模块。视频点播后台通常把视频管理、分类管理、用户权限分成不同 app,但第一个项目从单一 video_admin 开始更利于看清代码流向。 createsuperuser 创建的账号默认有 Django Admin 的访问权限,后面做权限细分时再调整。

接着把 video_admin 注册到项目的 INSTALLED_APPS ,再配置媒体文件目录。开发阶段可以在 settings.py 里加两行:

MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'

MEDIA_URL 是浏览器访问媒体文件的 URL 前缀, MEDIA_ROOT 是视频文件落盘的物理目录。后台管理系统里的“上传视频”功能,本质上就是把上传的文件写到 MEDIA_ROOT 下某个子目录,再把路径存进数据库。

2.3 核心模型字段的设计与选择

视频点播后台的模型设计,决定了后面所有管理功能好不好写。不要一上来就建“视频表”和“分类表”两个表就完事,建议先枚举运营需要哪些信息。下面是一组常见的最小字段:

from django.db import models

class VideoClassification(models.Model):
    """视频分类,比如电影、纪录片、教学视频"""
    name = models.CharField(max_length=50, unique=True, verbose_name="分类名称")
    sort_order = models.IntegerField(default=0, verbose_name="排序权重")

    class Meta:
        db_table = "video_classification"
        verbose_name = "视频分类"
        verbose_name_plural = verbose_name
        ordering = ["sort_order", "-id"]

    def __str__(self):
        return self.name


class VideoAsset(models.Model):
    """视频资源表:存的是元数据,不是视频文件本身"""
    STATUS_CHOICES = [
        ("pending", "待转码"),
        ("transcoding", "转码中"),
        ("completed", "已就绪"),
        ("failed", "转码失败"),
    ]
    title = models.CharField(max_length=200, verbose_name="视频标题")
    classification = models.ForeignKey(
        VideoClassification,
        on_delete=models.PROTECT,
        db_index=True,
        verbose_name="所属分类",
    )
    video_file = models.FileField(upload_to="videos/%Y/%m/", verbose_name="视频文件")
    duration_seconds = models.IntegerField(default=0, verbose_name="视频时长(秒)")
    transcode_status = models.CharField(
        max_length=20, choices=STATUS_CHOICES, default="pending", db_index=True
    )
    cover_image = models.ImageField(upload_to="covers/%Y/%m/", blank=True, verbose_name="封面图")
    is_published = models.BooleanField(default=False, verbose_name="是否上架")
    created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
    updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间")

    class Meta:
        db_table = "video_asset"
        verbose_name = "视频资源"
        verbose_name_plural = verbose_name
        ordering = ["-created_at"]

    def __str__(self):
        return self.title

这段模型有三个重要的设计决策。 VideoClassification 的分类表用 PROTECT 外键,防止运营误删一个分类导致下面所有视频失去归类; transcode_status 用 choices 限定合法状态值,后台下拉框直接渲染,避免脏数据; video_file 用 FileField 而不是直接存字符串路径,上传时会自动按 upload_to 里写的日期格式生成子目录,文件多了以后不至于全堆在一个文件夹里。

字段类型的取舍上,视频时长用 IntegerField 存秒数而不是 DurationField ,因为秒数方便前端播放器直接使用,也方便做统计聚合。状态字段用 db_index=True ,因为后台列表页最常见的筛选条件就是“只看转码失败的视频”。

2.4 URL 路由设计的两个层次

Django 视频点播后台的 URL 分两层:项目级路由和 app 级路由。项目级 urls.py 只负责挂载 app 和 Admin:

from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path("admin/", admin.site.urls),
    path("", include("video_admin.urls")),
]

video_admin/urls.py 里放业务页面路由:

from django.urls import path
from . import views

urlpatterns = [
    # 后台 Dashboard,显示视频总数、分类数量、待转码数
    path("dashboard/", views.dashboard, name="dashboard"),
    # 视频列表页,支持按分类和状态筛选
    path("videos/", views.video_list, name="video_list"),
    # 视频详情/编辑页,URL 里带视频 ID
    path("videos/<int:video_id>/", views.video_detail, name="video_detail"),
]

<int:video_id> 这种路由写法会把 URL 里的数字自动转成 int 类型传给视图函数,比用字符串主键更安全。列表页和详情页分开路由,让 Django Admin 和自定义后台页面可以共存,后面在“第4章”会讲到怎么取舍。

3. 视频点播的数据模型进阶:播放列表、转码状态与查询优化

3.1 把“视频列表”抽象成可复用的查询逻辑

后台管理系统的查询逻辑,和前台视频网站不一样。前台要的是“最新、最热、推荐”,后台要的是“哪个视频还没转码、哪个分类下面视频数量异常、哪个视频上传之后一直没人处理”。这些查询不能用 VideoAsset.objects.all() 一把梭,得做成可复用的 QuerySet 方法:

from django.db import models

class VideoAssetQuerySet(models.QuerySet):
    def published(self):
        return self.filter(is_published=True)

    def by_status(self, status):
        return self.filter(transcode_status=status)

    def with_classification(self):
        # select_related:外键一次查出来,避免 N+1 查询
        return self.select_related("classification")

class VideoAsset(models.Model):
    # ... 字段代码同 2.3 节,省略
    objects = VideoAssetQuerySet.as_manager()

select_related 是视频列表页性能的关键。列表页默认要显示“分类名称”,如果不用 select_related ,每显示一条视频记录都会额外执行一次分类表查询,100 条记录就是 101 条 SQL。加了之后,Django 会用一条 JOIN 把外键数据带出来。后台管理列表页的数据量在几千条以内时,这个优化能明显感觉到页面响应变快。

3.2 播放列表模型:视频点播后台的编排需求

很多视频点播系统不止有单条视频,还有“专辑”“剧集”“合集”的概念。播放列表模型和视频模型是多对多关系,中间表要带上排序字段:

class Playlist(models.Model):
    """播放列表,用于组织多集视频或者主题合集"""
    name = models.CharField(max_length=100, verbose_name="播放列表名称")
    description = models.TextField(blank=True, verbose_name="列表描述")
    videos = models.ManyToManyField(
        VideoAsset,
        through="PlaylistItem",
        related_name="playlists",
        verbose_name="包含视频",
    )
    created_at = models.DateTimeField(auto_now_add=True)


class PlaylistItem(models.Model):
    """播放列表项:中间表带排序权重,控制视频在列表内的先后顺序"""
    playlist = models.ForeignKey(Playlist, on_delete=models.CASCADE)
    video = models.ForeignKey(VideoAsset, on_delete=models.CASCADE)
    position = models.PositiveIntegerField(default=0, verbose_name="列表内排序")

    class Meta:
        ordering = ["position", "id"]
        unique_together = ("playlist", "video", "position")

中间表 PlaylistItem 带 position 字段,是视频点播后台管理里很容易忽略的设计点。如果不带排序字段,直接用 Django 的 ManyToManyField 默认中间表,你会发现视频在列表里的先后顺序无法控制,编辑人员只能在“列表编辑页”里通过奇怪的顺序调换来实现排序,体验很差。 unique_together 保证了同一个播放列表里不会出现两条相同 position 的数据,避免排序冲突。

后台编辑播放列表的正确方式是使用 TabularInline ,在 Django Admin 里做成“列表编辑页面下面直接拖视频进去排顺序”的效果:

from django.contrib import admin
from .models import Playlist, PlaylistItem

class PlaylistItemInline(admin.TabularInline):
    model = PlaylistItem
    extra = 5
    verbose_name = "列表视频"
    verbose_name_plural = "列表视频"

@admin.register(Playlist)
class PlaylistAdmin(admin.ModelAdmin):
    inlines = [PlaylistItemInline]

extra = 5 表示新增一条播放列表时,默认展示 5 行空的视频条目拖拽位。实际项目中如果每个列表的视频数量差异很大,可以改成 10,或者让前端动态添加行再提交。

3.3 删除对象时,视频文件怎么办

Django 的 ORM 删除操作 VideoAsset.objects.get(id=1).delete() 只删数据库记录,不会自动删除磁盘上的视频文件。这在视频点播后台里是个常见的“隐雷”:后台显示视频已删除,但存储空间还在被占着。

可靠的方案是重写模型的 delete 方法,或者在视图里处理删除逻辑:

import os
from django.conf import settings
from django.db import models

class VideoAsset(models.Model):
    # ... 字段省略

    def delete(self, *args, **kwargs):
        # 先拿到文件路径,再删数据库记录
        file_path = self.video_file.path if self.video_file else None
        cover_path = self.cover_image.path if self.cover_image else None
        super().delete(*args, **kwargs)
        # 数据库删除成功后再清文件;文件不存在时报错忽略
        for path in [file_path, cover_path]:
            if path and os.path.exists(path):
                try:
                    os.remove(path)
                except OSError:
                    pass

这里把“删数据库记录”和“删物理文件”放在一步里完成, super().delete() 放在前面是为了避免文件删了一半数据库记录还在的情况。注意这段代码只对调用 instance.delete() 的场景生效,如果用 VideoAsset.objects.filter(...).delete() 这种批量删除,Django 出于性能考虑不会遍历每个实例,也就不会触发重写的 delete 方法。治理方案是列表页禁止批量删除视频,只提供单条删除动作。

3.4 后台列表查询的常用检索写法

视频点播后台的列表页要支持“按标题模糊搜索”“按分类筛选”“按转码状态筛选”,Django Admin 里通过 get_search_results 或常规 ORM 都能实现。自定义后台时常用的组合如下:

def search_videos(request):
    videos = VideoAsset.objects.with_classification()
    keyword = request.GET.get("q", "").strip()
    classification_id = request.GET.get("classification_id", "").strip()
    status = request.GET.get("status", "").strip()

    if keyword:
        videos = videos.filter(title__icontains=keyword)
    if classification_id:
        videos = videos.filter(classification_id=classification_id)
    if status:
        videos = videos.filter(transcode_status=status)

    return videos

title__icontains 生成的是 LIKE '%keyword%' 查询,会做全表扫描,但在后台数据量不大时足够用。真正的性能瓶颈不在模糊搜索,而在“根据状态统计数量”这类聚合查询,比如首页显示“待转码 23 条”。这种统计用干净的 filter(transcode_status="pending").count() 即可,不要把所有记录加载到 Python 里再数。

4. Django 后台管理界面:admin 定制与 simpleui 的取舍

4.1 先想清楚:要不要用 Django Admin

视频点播后台的管理界面,有两条成熟路线。第一条是直接用 Django Admin,加少量定制;第二条是 Admin 只做登录和权限,业务页面全部用 Django 视图 + 模板自己写。标题里强调“后台管理系统”,意味着管理界面的运营体验是交付的一部分,不是能跑就行。

直接上 Admin 的优点是快:模型建好,注册一下,列表页、编辑页、权限管理全有了。缺点也明显:默认界面样式老旧,上传视频这种操作在 Admin 里对运营人员不友好。常见折中方案是安装 django-simpleui,把 Admin 换皮成现代化界面,保留 Django 自带的后台逻辑:

pip install django-simpleui

然后在 settings.py 里调整配置:

INSTALLED_APPS = [
    "simpleui",
    "django.contrib.admin",
    "django.contrib.auth",
    # ... 其他 app
]

# 指定 Admin 首页展示的菜单和图表
SIMPLEUI_HOME_TITLE = "视频点播后台"
SIMPLEUI_HOME_PAGE_TITLE = "运营数据概览"

simpleui 要放在 django.contrib.admin 之前,这样模板覆盖才会生效。它内部还带了操作日志、菜单图标、图表组件,对“源码型交付”的项目来说,等于免费赠送了菜单配置和操作审计功能。

4.2 定制 Admin 列表页的关键参数

不管用不用 simpleui, ModelAdmin 的定制参数是核心。视频管理列表页要做成“一眼能看到所有关键状态”的样子:

from django.contrib import admin
from .models import VideoAsset

@admin.register(VideoAsset)
class VideoAssetAdmin(admin.ModelAdmin):
    list_display = (
        "title",
        "classification",
        "transcode_status",
        "is_published",
        "duration_seconds",
        "created_at",
    )
    list_filter = ("transcode_status", "classification", "is_published")
    search_fields = ("title",)
    list_per_page = 20
    actions = ["mark_as_published", "mark_as_unpublished"]

    @admin.action(description="批量上架所选视频")
    def mark_as_published(self, request, queryset):
        queryset.update(is_published=True)

    @admin.action(description="批量下架所选视频")
    def mark_as_unpublished(self, request, queryset):
        queryset.update(is_published=False)

list_display 是列表页展示的列, list_filter 是右侧的筛选栏, search_fields 决定顶部搜索框按哪些字段搜。注意 actions 里用的是 queryset.update() ,这是直接 SQL 更新,不会触发模型的 delete 重写逻辑,但因为只改布尔字段,没有副作用。

4.3 权限控制:同一个后台,不同角色看到的东西不一样

视频点播后台的使用者不只有管理员,常见的还有“内容编辑”和“审核员”。Django 自带的权限体系基于 Group 和 Permission,可以做到“编辑只能上传和修改视频,不能删除;审核员只能上下架,不能改内容本身”。

先创建权限码,在模型 Meta 里加:

class VideoAsset(models.Model):
    # ... 字段省略

    class Meta:
        # ... 原有 Meta 配置
        permissions = [
            ("can_publish_video", "可以上架视频"),
            ("can_delete_video", "可以删除视频"),
        ]

执行 python manage.py makemigrations 和 migrate 后,到 Django Admin 的用户组管理页面,把权限勾选到对应分组。代码层面需要校验权限时:

from django.contrib.auth.decorators import permission_required

@permission_required("video_admin.can_publish_video", raise_exception=True)
def publish_video(request, video_id):
    VideoAsset.objects.filter(id=video_id).update(is_published=True)
    return redirect("video_list")

raise_exception=True 会让没有权限的用户直接看到 403 页面,而不是被重定向到登录页。后台管理系统里,视图层的权限校验能防住“有人猜到 URL 直接操作”的情况。

4.4 前后端分离项目的接口权限怎么做

如果视频点播后台用了 Vue 或 React 做前端,Django 只提供 JSON 接口,那么权限校验要从“页面级”下沉到“接口级”。这时不建议继续用 login_required 这套,改造起来别扭。常见做法是用 Django REST framework 的 IsAuthenticated 配合自定义权限类:

from rest_framework.permissions import BasePermission

class CanPublishVideoPermission(BasePermission):
    message = "没有视频上架权限"

    def has_permission(self, request, view):
        return request.user.has_perm("video_admin.can_publish_video")

然后把权限类配置到对应的视图集上。好处是权限逻辑和接口绑定,前端控件显不显示按钮只是体验问题,真正的安全边界在接口权限上。视频点播后台的接口权限清单要单独维护一份,比如“上传接口只允许内容编辑组”“删除接口只允许管理员组”,不要把所有权限都挂在用户个人身上,那样后续换人接手会查不清。

5. 用 Django StreamingHttpResponse 做视频流响应与断点续传技巧

后台管理系统里经常要“预览视频”,也就是点击列表里的某条视频,直接在浏览器里播放。如果把视频文件当作普通文件响应,用 FileResponse 也能播,但有两个问题:不支持拖动播放进度条,大文件会一次性读进内存。正确做法是用 StreamingHttpResponse 手动处理 HTTP Range 请求。

先看 Django 自带的解决方案能覆盖什么场景:

from django.http import FileResponse

def stream_video_legacy(request, video_id):
    video = VideoAsset.objects.get(id=video_id)
    # FileResponse 支持文件迭代读取,但不处理 Range 头
    return FileResponse(video.video_file.open("rb"), content_type="video/mp4")

FileResponse 在 Django 3.0 之后已经支持部分 Range 请求,比如拖进度条的功能。但遇到“多清晰度切换”“临时签名 URL”这类需求时,仍然需要手动控制响应头。下面的代码实现了带 Range 支持的视频流响应:

import os
import re
from django.http import StreamingHttpResponse

def stream_video_with_range(request, video_id):
    video = VideoAsset.objects.get(id=video_id)
    file_path = video.video_file.path
    file_size = os.path.getsize(file_path)

    range_header = request.META.get("HTTP_RANGE", "").strip()
    content_type = "video/mp4"

    # 没有 Range 头,返回完整文件
    if not range_header:
        response = StreamingHttpResponse(
            video.video_file.chunks(),
            content_type=content_type,
        )
        response["Content-Length"] = str(file_size)
        response["Accept-Ranges"] = "bytes"
        return response

    # 解析 Range 头,形如: bytes=0-1023
    range_match = re.match(r"bytes=(\d*)-(\d*)", range_header)
    if not range_match:
        return HttpResponse(status=416)

    start_str, end_str = range_match.groups()
    start = int(start_str) if start_str else 0
    end = int(end_str) if end_str else file_size - 1

    if start >= file_size:
        response = HttpResponse(status=416)
        response["Content-Range"] = f"bytes */{file_size}"
        return response

    end = min(end, file_size - 1)
    length = end - start + 1

    def file_iterator(f, start_byte, end_byte, chunk_size=8192):
        f.seek(start_byte)
        remaining = length
        while remaining > 0:
            read_size = min(chunk_size, remaining)
            data = f.read(read_size)
            if not data:
                break
            remaining -= len(data)
            yield data

    response = StreamingHttpResponse(
        file_iterator(video.video_file.open("rb"), start, end),
        content_type=content_type,
        status=206,
    )
    response["Content-Length"] = str(length)
    response["Content-Range"] = f"bytes {start}-{end}/{file_size}"
    response["Accept-Ranges"] = "bytes"
    return response

这段代码里最重要的参数是 Content-Range 和 Accept-Ranges 。 Content-Range: bytes 0-1023/2048 告诉浏览器“本次只返回文件的这一段,文件总大小是 2048 字节”; Accept-Ranges: bytes 告诉浏览器“服务器支持按字节范围请求”。浏览器播放器在用户拖进度条时,会自动发送 Range: bytes=... 请求,服务器返回 206 Partial Content 状态码即可。

file_iterator 里的 seek 是视频点播后台预览功能的关键。没有 seek 到指定偏移量,无论 Range 写的是什么,读到的都是文件开头的数据,播放器就会从 0 秒开始播。 chunk_size=8192 表示每轮最多读 8KB,避免大文件一次性载入内存。真实项目中,Django 只负责预览这个轻量场景,正式对外服务还是用 Nginx 的 X-Accel-Redirect 转发,或者在 Nginx 层直接配 location /videos/ 做静态文件服务并启用 sendfile 。

验证流式响应是否生效,用 curl 发一个带 Range 的请求,检查响应头里的状态码和 Content-Range 头:

# 请求文件的前 1024 字节
curl -I -H "Range: bytes=0-1023" http://127.0.0.1:8000/videos/1/stream/

正常返回 HTTP/1.1 206 和 Content-Range: bytes 0-1023/视频总字节数 ,说明断点续传生效。如果在浏览器里测试,把视频地址直接拖进播放器地址栏,加载后拖动进度条,观察开发者工具 Network 面板里是否出现多个 206 请求即可。对外提供视频播放时,建议在响应头里加上 Cache-Control: private, max-age=3600 让浏览器缓存分片,减少重复请求对 Django 进程的压力。

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

Logo

火山引擎视频云技术社区,是面向 AI 音视频开发者的技术交流平台。这里汇聚源自抖音、豆包等亿级 DAU 产品的 RTC、直播、点播、AI 媒体处理、音视频互动技术,提供接入指南、最佳实践、性能调优、场景案例、Demo 代码、开源项目、白皮书和 API 文档。社区汇聚官方工程师与一线开发者,为 AI 视频通话、数字人、AI 视频处理等应用的开发与落地提供技术支持。

更多推荐