基于Django的同城临期视频平台源码设计与实战解析

发布时间:2026/10/13 20:40:40
基于Django的同城临期视频平台源码设计与实战解析
做同城项目这几年我见过太多人一上来就纠结“用什么框架”结果产品逻辑还没想清楚代码就写了一堆冗余功能。今天分享的这个项目——一个基于Django的同城临期视频平台是我最近整理的一套完整源码编号69305核心思路就一句话不要为了炫技而设计而是要让“同城、临期、视频”这三个词真正落到产品流程里。文章会从需求拆解、数据建模、核心代码实现到部署排坑全部讲透适合正在做Django课程设计、毕业设计或者想快速搭建同城信息类产品的开发者直接参考。所谓“同城临期视频平台”本质上是一个本地生活信息发布工具商家或个人把即将到期的商品食品、日用品、票券等以视频或图片形式发布出来平台按城市维度聚合展示买家刷到后可以直接联系发布者完成交易。它介于闲鱼和本地团购之间比纯图文多了视频展示的真实感比外卖平台又少了履约环节轻量、直接非常适合用Django这类“全家桶”框架来做。下面我把这套源码的设计思路、关键实现和踩坑记录全部展开。1. 项目核心思路与方案选型1.1 需求拆解如何把“同城”“临期”“视频”落到功能上先别急着写代码把标题里的三个关键词翻译成产品需求。这是我做这套源码时第一个动作也建议你先照着做一遍。同城意味着平台要按城市维度组织内容。用户注册时选择城市发布商品时绑定城市首页列表默认展示当前用户所在城市的内容。这是信息聚合逻辑属于平台的基础过滤条件。临期意味着商品有明确的到期时间且这个时间直接决定商品是否还展示。设计上要有一个“到期时间”字段配合状态机在售、已售罄、已过期、下架还要在详情页给出倒计时给买家压迫感增加转化。视频意味着内容展示以视频优先、图片辅助。考虑到个人开发者做不了视频转码平台采用两种方案一是直接上传MP4文件二是填写外部视频链接URL前端用HTML5 video播放。封面图单独存一张用于列表页的卡片展示。这三个需求落下来核心业务对象只有一个临期商品Product。用户、城市、视频、状态这些都围绕它展开。这也是Django模型的天然设计方式一个Product模型配合几个关联模型就够了不需要一开始就拆成微服务。另外还有一个隐藏需求买卖双方的连接方式。同城平台不一定需要在线支付因为临期商品的线下自提更符合实际。所以源码里只做了“联系买家/卖家”功能——展示发布者的联系方式或者站内留言没有做支付闭环这大幅降低了开发量也更贴合真实场景。1.2 为什么选Django而不是Flask或Spring选型这件事不能光看“哪个火”要看项目特征。这套平台涉及用户注册登录、商品发布、后台审核、媒体文件管理、状态流转功能都是“业务管理系统”的属性。Django天生适合这种项目自带Admin后台商家管理、商品审核、用户封禁直接就能用省掉单独做运营后台的工作量ORM对关联查询支持很好比如“查询某个城市所有未过期且含视频的商品”一条filter链式调用就能搞定用户认证、表单校验、分页、消息框架等都是内置的不用自己造轮子模板系统配合Bootstrap能快速把PC端和管理后台做出来。如果换成Flask确实更轻但你需要自己集成Flask-SQLAlchemy、Flask-Login、Flask-Admin而且这些库之间的兼容性偶尔要折腾。Spring Boot对中小型个人项目和课程设计来说又偏重环境要求更高。我个人的经验是如果你需要快速交付一套能跑、能展示、还能用于二次开发的信息管理类项目Django的“全家桶”优势是最明显的。生产环境选型上源码默认使用SQLite做开发库方便本地跑部署时切到MySQL也不需要改业务代码只改settings里的DATABASES配置。1.3 项目技术栈与目录结构这套源码采用Django 4.x前端是Bootstrap 5 原生JavaScript视频播放用HTML5的video标签地图相关暂时不做同城采用城市字段经纬度距离双重筛选。这样的组合兼顾开发效率和项目可读性。目录结构上我不建议把所有代码堆在一个app里。源码按业务拆成了三个appebags/ # 项目配置目录 users/ # 用户模块注册登录、个人资料、城市选择 products/ # 商品模块发布、列表、详情、状态管理 messages/ # 站内联系模块买家留言 static/ # 静态文件 media/ # 用户上传的图片和视频 templates/ # 共用模板为什么拆成三个app而不写在一个views.py里因为后面的二次开发一定会遇到“用户要加店铺功能”“商品要加分类管理”之类的需求模块之间边界清晰后面改起来才不痛苦。特别是用户模块独立成app由于Django的AbstractUser扩展操作会生成迁移文件独立app能减少迁移耦合。2. 核心功能模块与实现细节2.1 用户模块扩写AbstractUser并区分角色用户模块是整个平台的地基。源码里没有直接用Django默认的User而是继承AbstractUser做扩展增加四个字段手机号、城市、头像、用户类型。# users/models.py from django.contrib.auth.models import AbstractUser from django.db import models class User(AbstractUser): MOBILE mobile SELLER seller USER_TYPES ( (MOBILE, 普通用户), (SELLER, 商家), ) phone models.CharField(手机号, max_length11, blankTrue) city models.CharField(所在城市, max_length32, blankTrue) avatar models.ImageField(头像, upload_toavatars/, blankTrue) user_type models.CharField(用户类型, max_length10, choicesUSER_TYPES, defaultMOBILE)为什么用CharField存城市而不是用外键关联城市表因为对于这种轻量级项目城市只是一个过滤标签用户手动选择即可全国城市数据几万条外键反而增加维护成本。如果你后续要做城市联动、热门城市排序再单独建City表也不迟索引一下city字段性能完全够用。商家和普通用户的权限区分源码里没有用复杂的Django权限系统而是靠user_type判断。发布商品时视图里校验user.user_type seller不满足就返回提示。这样实现简单逻辑直观也方便二次开发时改成组权限。2.2 临期商品模块核心模型和状态机设计Product模型是这个平台最重要的表字段设计直接决定业务能不能跑通。源码里的核心字段如下# products/models.py from django.db import models from django.conf import settings from django.utils import timezone class Product(models.Model): STATUS_ON_SALE on_sale STATUS_SOLD sold STATUS_EXPIRED expired STATUS_OFFLINE offline STATUS_CHOICES ( (STATUS_ON_SALE, 在售), (STATUS_SOLD, 已售罄), (STATUS_EXPIRED, 已过期), (STATUS_OFFLINE, 已下架), ) title models.CharField(标题, max_length120) desc models.TextField(描述) cover models.ImageField(封面图, upload_tocovers/) video models.FileField(视频, upload_tovideos/, blankTrue) video_url models.URLField(视频外链, blankTrue) price models.DecimalField(现价, max_digits8, decimal_places2) original_price models.DecimalField(原价, max_digits8, decimal_places2) expire_at models.DateTimeField(到期时间) city models.CharField(城市, max_length32) address models.CharField(自提地址, max_length255) contact models.CharField(联系方式, max_length50) status models.CharField(状态, max_length10, choicesSTATUS_CHOICES, defaultSTATUS_ON_SALE) seller models.ForeignKey(settings.AUTH_USER_MODEL, related_nameproducts, on_deletemodels.CASCADE) created_at models.DateTimeField(发布时间, auto_now_addTrue) class Meta: ordering [-created_at]关于状态机我最想重点说明的是状态不能只靠定时任务去刷新要在查询入口兜底过滤。也就是说商品expire_at已经过了即便状态还是on_sale列表查询也应该过滤掉。这样即使Celery定时任务挂了用户也不会刷到已过期的内容。列表查询的核心逻辑就是“同城 未过期 状态在售三条件组合”后面代码部分会演示。2.3 同城匹配逻辑城市字段为主经纬度为辅“同城”是这个平台的灵魂实现方案有两种源码里都写了你可以根据需求二选一。方案A源码默认按city字段精确匹配。查询时用Product.objects.filter(cityrequest.user.city, ...)即可简单直观性能好。适合平台早期用户少、商品少的情况。方案B按经纬度计算距离。用户发布时定位经纬度查询时计算一定半径内的商品。SQLite本身不擅长地理计算所以源码里用的是Haversine公式把范围内的经纬度查出来再在Python里算距离排序。# products/utils.py import math def haversine_distance(lat1, lng1, lat2, lng2): R 6371.0 phi1 math.radians(lat1) phi2 math.radians(lat2) d_phi math.radians(lat2 - lat1) d_lambda math.radians(lng2 - lng1) a math.sin(d_phi/2)**2 math.cos(phi1) * math.cos(phi2) * math.sin(d_lambda/2)**2 return 2 * R * math.asin(math.sqrt(a))方案B适合做“附近临期商品”的地图模式但实际开发中它有两个坑一是经纬度要用户授权获取很多浏览器环境拿不到二是计算量大商品多了之后必须在数据库层面先粗筛比如限定经纬度加减0.1度再精算否则全表算距离性能会很差。我的建议是第一版用方案A等平台有了真实用户量再上方案B。源码里把两个方案都保留了方便你对照学习。2.4 后台管理与运营侧设计商家发布商品需要后台审核吗我的观点是如果平台处于冷启动阶段审核流程先做最简单的状态切换即可所有新发布商品默认on_sale但管理员可以通过Admin后台一键下架违规内容。Django Admin在这一步帮了大忙。在admin.py里注册Product配置list_display和list_filter管理员就能在后台按城市、状态、时间筛选商品一键批量下架# products/admin.py from django.contrib import admin from .models import Product admin.register(Product) class ProductAdmin(admin.ModelAdmin): list_display (title, city, status, price, expire_at, seller, created_at) list_filter (status, city, created_at) search_fields (title, seller__username) actions [force_offline] admin.action(description强制下架所选商品) def force_offline(self, request, queryset): queryset.update(statusoffline)这里有个操作细节自定义action中queryset.update()会直接执行SQL更新不走模型的save()方法。如果以后你在save()里做了状态同步逻辑记得改用遍历queryset调用实例方法的方式避免状态不同步。3. 实操过程与核心环节实现3.1 环境初始化与项目结构搭建拿到源码后第一步是创建虚拟环境并安装依赖。我建议用venv配合国内镜像源安装会快很多python -m venv venv source venv/bin/activate # Windows 使用 venv\Scripts\activate pip install django pillow # pillow 用于 ImageField 处理pillow是Django ImageField的硬性依赖不安装的话迁移时不会报错但上传图片就会报错。如果你使用了MySQL还要安装mysqlclient或pymysql这里以开发环境SQLite为例。初始化数据库和创建管理员账号python manage.py makemigrations python manage.py migrate python manage.py createsuperuser这里有一个Django版本相关的坑如果settings.py里没有配置AUTH_USER_MODEL而你已经执行过migrate生成了默认的auth_user表之后再改成自定义User迁移就会报冲突。源码是一开始就指定了AUTH_USER_MODEL users.User如果你是新建项目务必按这个顺序来先写好自定义User再执行第一次migrate。3.2 核心模型代码与迁移细节核心模型上文已经给出了User和Product补充一个站内留言模型用于买家联系卖家的场景# messages/models.py from django.db import models from django.conf import settings from django.utils import timezone from products.models import Product class Message(models.Model): product models.ForeignKey(Product, on_deletemodels.CASCADE, related_namemessages) sender models.ForeignKey(settings.AUTH_USER_MODEL, on_deletemodels.CASCADE, related_namesent_messages) content models.TextField(留言内容) created_at models.DateTimeField(留言时间, auto_now_addTrue) is_read models.BooleanField(是否已读, defaultFalse) class Meta: ordering [-created_at]注意外键on_deletemodels.CASCADE的使用当商品被删除时关联留言会一并删除。这在信息发布类平台没有问题但如果后续要做“删除商品但保留聊天记录”就要改成on_deletemodels.SET_NULL并让product字段允许为空。两类场景的取舍取决于你的产品定位源码选择CASCADE是因为简化演示逻辑。字段选型方面有两个我踩过的坑得提醒你。第一个是DecimalField必须指定max_digits和decimal_places价格字段别用FloatField浮点数做金额计算会在边界值出现精度问题虽然临期商品单价不高但电商类项目用Float存价格是原则性错误。第二个是城市字段city要加db_indexTrue因为同城查询是最高频的访问路径数据库查询计划会先走索引。3.3 核心查询逻辑同城与过期过滤首页列表视图是这个项目的重头戏。它的职责是展示当前用户所在城市、状态为在售、且未超过到期时间的商品。代码要同时处理未登录和已登录两种情况# products/views.py from django.shortcuts import render, get_object_or_404 from django.utils import timezone from django.contrib.auth.decorators import login_required from .models import Product from .utils import haversine_distance def product_list(request): queryset Product.objects.select_related(seller).filter(statusProduct.STATUS_ON_SALE, expire_at__gttimezone.now()) city request.GET.get(city, ) if request.user.is_authenticated and not city: city request.user.city if city: queryset queryset.filter(citycity) # 可选按距离排序 # lat request.GET.get(lat); lng request.GET.get(lng) # if lat and lng: # products [p for p in queryset if haversine_distance(...) 10] page_obj Paginator(queryset, 12).get_page(request.GET.get(page)) return render(request, products/list.html, {page_obj: page_obj, city: city})这里用select_related(seller)是为了避免在模板里循环商品时重复查询卖家信息属于Django ORM的性能优化基础操作。如果商品列表还要展示留言数量建议再配合Count注解from django.db.models import Count queryset queryset.annotate(msg_countCount(messages))发布商品视图需要校验用户类型和表单数据。源码使用Django ModelForm来简化校验逻辑# products/forms.py from django import forms from .models import Product class ProductForm(forms.ModelForm): class Meta: model Product fields [title, desc, cover, video, video_url, price, original_price, expire_at, city, address, contact] widgets { expire_at: forms.DateTimeInput(attrs{type: datetime-local}), } def clean_video(self): video self.cleaned_data.get(video) if video: if video.size 100 * 1024 * 1024: # 限制100MB raise forms.ValidationError(视频大小不能超过100MB) return video在clean_video中限制视频大小非常必要。Django在DEBUG模式下接收大文件会占用大量内存太小的限制又影响实际使用。我的经验值是本地演示限制100MB生产环境直接用OSS或者对象存储Django侧就不放文件了。3.4 模板与前端播放细节列表页模板用卡片式布局展示商品封面、标题、价格和倒计时。核心是一个Bootstrap卡片循环{# templates/products/list.html #} {% for p in page_obj %} div classcol-md-4 mb-4 div classcard h-100 img src{{ p.cover.url }} classcard-img-top alt{{ p.title }} div classcard-body h5 classcard-title{{ p.title }}/h5 p classtext-danger font-weight-bold¥{{ p.price }} del classtext-muted small¥{{ p.original_price }}/del/p p classtext-muted small>// static/js/countdown.js document.querySelectorAll([data-expire-at]).forEach(el { const expireAt new Date(el.dataset.expireAt).getTime(); function update() { const diff expireAt - Date.now(); if (diff 0) { el.textContent 已过期; return; } const hours Math.floor(diff / 3600000); const minutes Math.floor((diff % 3600000) / 60000); const seconds Math.floor((diff % 60000) / 1000); el.textContent 还剩 ${hours}小时${minutes}分${seconds}秒; } update(); setInterval(update, 1000); });这里有一个容易踩的坑expire_at|date:c输出的ISO 8601格式末尾没有时区信息如果Django的TIME_ZONE和浏览器时区不一致倒计时会差8小时。解决方案是在settings.py中设置TIME_ZONE Asia/Shanghai并且USE_TZ True同时确保传给模板的时间是带时区的。在渲染前统一转成本地时间再输出字符串是最稳妥的方案。源码里通过自定义模板过滤器解决源码也保留了一个简单版本在视图里手动拼接expire_at.strftime(%Y-%m-%dT%H:%M:%S08:00)。3.5 视频播放的两种方式视频字段的设计让不少第一次看到源码的人困惑为什么既有FileField还留着URLField原因很简单——平台方不可能替用户承担视频存储和带宽成本。小规模演示时用户上传本地MP4存在Django服务器里真实运营时引导商家粘贴视频平台的分享链接平台只负责展示。对于本地上传的视频模板里直接用video标签播放video controls classw-100 poster{{ product.cover.url }} source src{{ product.video.url }} typevideo/mp4 您的浏览器不支持视频播放 /video如果是外链视频比如用户粘贴了某个公开视频平台的嵌入地址直接把iframe渲染出来即可。但要注意外部链接必须做白名单校验不能允许用户随意粘贴脚本类伪URL这是XSS攻击的高发点。源码中使用了一个干净的做法在表单clean方法里限定video_url必须以http://或https://开头渲染到模板时再让Django自动转义这样即使URL里带了特殊字符也不会被当作HTML执行。4. 常见问题与排查技巧实录4.1 图片上传后页面显示不了大概率是MEDIA配置问题这是Django开发中出现频率最高的一个问题。“我明明上传了图片模板里也写了{{ p.cover.url }}为什么浏览器访问返回404”原因就是MEDIA_ROOT和MEDIA_URL没有配置或者生产环境下没有把媒体文件交给服务期处理。开发环境最简单的方式是修改项目的urls.py# ebags/urls.py from django.conf import settings from django.conf.urls.static import static urlpatterns [ # ... 其他路径 ] if settings.DEBUG: urlpatterns static(settings.MEDIA_URL, document_rootsettings.MEDIA_ROOT)同时settings.py里必须写清楚MEDIA_URL /media/ MEDIA_ROOT BASE_DIR / media部署到服务器后Debug关闭static()不再生效这时需要用Nginx把/media/路径指向MEDIA_ROOT目录或者借助第三方存储的CDN。千万不要把资源文件直接丢给Django处理生产环境Django的静态文件服务性能非常差。4.2 大视频上传超时或内存溢出的应对本地上传大视频文件时如果你用的是Gunicorn这类同步工作进程默认请求体大小限制可能会导致413错误。Django本身没有硬编码上传大小限制但中间件和Web服务器会拦一道。Nginx的配置加上client_max_body_size 100m;同时Django的settings.py中也可以设置DATA_UPLOAD_MAX_MEMORY_SIZE 104857600 # 100MB但这里有一个更深的隐性问题Django会先把上传文件放到内存超过FILE_UPLOAD_MAX_MEMORY_SIZE默认2.5MB才写入临时文件。大视频接近100MB时内存与硬盘之间的临时写入会频繁发生影响响应速度。真正的解决方案是生产环境把上传改为直传对象存储用前端签名直传的方式绕开应用服务器Django只接收文件URL。4.3 时区问题倒计时相差8小时时区问题在Django中非常经典但每次遇到还是会有人卡半天。核心矛盾在于数据库或代码中存储的时间是UTC而模板展示时转成了本地时间如果你在模板过滤器里没有做转换就会出现相差8小时的幻觉。源码的处理方式是settings中USE_TZ True所有模型接收用户输入的到期时间后代码里做一个显式转换import pytz from django.conf import settings from datetime import datetime def parse_local_datetime(value): local_tz pytz.timezone(settings.TIME_ZONE) naive datetime.strptime(value, %Y-%m-%dT%H:%M) return local_tz.localize(naive)这样入库后expire_at就是带时区的正确时间查询时用timezone.now()比较就会得到正确结果。注意USE_TZ False设置下Django会使用本地时间处理但对于生产部署在云服务器上的应用系统时区未必是北京时间所以我强烈建议保持USE_TZ True只在边界做转换不要图省事关掉时区支持。4.4 同城筛选的边界问题相邻城市怎么处理同城筛选最简单的方案是按字段匹配但真实用户的需求往往是“我家住在A城市和B城市的交界处我希望能看到两个城市的商品”。这种边界情况在业务早期可以忽略但后期一定会被用户提出来。我的建议是保持筛选逻辑简单在界面上增加一个“附近城市”选项。当用户勾选后视图从城市字段匹配改为通过省市级联表匹配同一个省。不过这是一项不小的功能增量源码只实现了基础版本二次开发时可自行扩展。如果到了要按距离筛选的阶段一定记得Haversine公式配合粗筛别上来就全表遍历计算。4.5 后台批量下架后状态不同步的问题Admin的action中queryset.update()没有调用模型save()方法所以如果我们在Product的save()里写了自动状态同步逻辑Admin中的批量操作会绕过。标准做法是admin.action(description强制下架所选商品) def force_offline(self, request, queryset): for obj in queryset: obj.status Product.STATUS_OFFLINE obj.save() # 触发完整逻辑这样遍历更新的代价是执行N条UPDATE语句但换来了状态一致性和后续扩展性。个人经验Admin后台主要用于运营小规模操作性能不是首要关注点逻辑一致性更重要。5. 源码结构与二次开发建议5.1 源码目录总览与文件说明套源码的完整目录大致如下django-exp-demo/ # 项目根目录 ├── ebags/ # settings和根URL配置 │ ├── settings.py │ ├── urls.py │ └── wsgi.py ├── users/ # 用户扩展、注册登录 │ ├── models.py │ ├── views.py │ ├── admin.py │ └── forms.py ├── products/ # 商品模型、视图、表单 │ ├── models.py │ ├── views.py │ ├── admin.py │ └── forms.py ├── messages/ # 站内留言 │ ├── models.py │ └── views.py ├── templates/ # 前端模板 │ ├── base.html │ ├── products/ │ └── users/ ├── static/ │ ├── css/ │ └── js/ └── media/ # 上传文件 ├── covers/ # 商品封面 ├── videos/ # 商品视频 └── avatars/ # 用户头像这基本是一个标准的“Django应用拆分”结构。用户、商品、消息三个业务模块各自独立互相之间的依赖只通过外键或查询建立没有出现“循环import”的情况这套结构学透了以后做任何信息发布类项目都可以直接套用。5.2 从源码Demo到可运营产品的三条扩展路径源码可以跑通但离真实上线运营还有一段距离。我根据自己的实际经验列出三条最值得做的扩展方向按性价比排序第一加ElasticSearch或全文检索。商品标题、描述的中文搜索用Django ORM的contains匹配在数据量小的时候勉强够用超过一万条明显变慢。导入ElasticSearch会让搜索体验上一个台阶但运维成本也上来了。如果你的项目只是毕设或课程设计这一步可以延后。第二接入微信小程序端。同城生活类产品的用户大概率在手机上刷信息微信小程序比Web端更适合这个场景。需要做的是后端提供JSON接口Django REST Framework或干脆用JsonResponse做轻量API前端小程序负责调用和渲染。这也是源码解耦前后端的重要原因——如果你打算继续扩展建议尽早把视图改成API风格。第三做推荐排序。临期商品的“时间紧迫性”非常适合做活动加权——越接近到期时间、折扣越大的商品排名越靠前。排序公式score (original_price - price) / original_price * 100 urgency_bonusurgency_bonus根据剩余小时数分档计算实现起来不复杂但对平台转化率有明显帮助。5.3 我做完这套源码后的一些体会最后聊点实在的。把一个“同城临期视频平台”从零搭起来最大的感受是这种项目的难点根本不在技术而在内容治理和信任体系。临期食品本身有食品安全风险平台如果不做商品审核、到期强制下架、虚假信息举报机制越往后越危险。从技术侧看Django确实是最适合这类项目的框架它让你把80%的精力放在业务规则上而不是基础设施上。从产品侧看同城平台的冷启动非常难因为你必须在一个城市里同时搞定供给商家发布和需求买家浏览源码给了你一套完整的基础系统剩下的是运营策略和内容审核机制这些就不是代码能解决的了的了。如果你正在做类似的课程设计或者想快速搭一个同城信息发布的Demo这套源码的核心代码可以直接复用。有一点一定要记住代码里所有上传文件、城市字段、状态流转的设计都是为了“简洁可跑通”服务的二次开发时先想清楚业务再动手别急着堆新功能稳定压倒一切。