Django广告交易系统ADExchange实战:订单状态机、权限控制与报表聚合
2026/9/11 23:14:45 网站建设 项目流程

简介:这是基于Python+Django框架开发的ADExchange广告交换管理系统,面向计算机相关专业学生、教师及企业开发者,适合用作毕业设计、课程设计、项目初期立项演示,也适合希望通过真实项目进阶Django全栈开发的学习者。资源包共624个文件,压缩包整体约12.36MB,体积精简但内容完整,代码主体包含96个Python后端逻辑文件、53个Vue前端组件、210个JavaScript交互脚本及56个CSS样式文件,并配有HTML页面、PNG图标、字体资源与配置文件,可支持前后端联调与二次开发。项目已通过导师指导认可,代码经测试运行成功,功能完善,直接部署即可查看管理系统的完整流程;同时附带详细文档,便于理解Django项目结构、接口调用与部署思路。目前已有58人学习,是兼顾完整度与轻量易用性的实战型参考项目,也适合在此基础上进行功能扩展、界面改造或算法优化。

1. 一个广告交易系统的Django落地:ADExchange到底解决什么问题

ADExchange管理系统的本质是一套连接广告主、媒体方和运营的广告交易后台。表面上它管理广告位、订单和投放报表,实际难点在于订单状态流转、多角色权限边界和后台数据的实时聚合。这套基于Python+Django的ADExchange管理系统资料,源码完整、附带详细文档,代码已经跑通,适合毕业设计和课程设计直接使用,也适合想快速了解Django业务项目的老手做基线代码。拿到资料包后不要直接runserver,先理清里面的模型关系和目录结构,因为后面所有查询、报表和部署排错都围绕核心订单表展开。

2. ADExchange的模型设计与订单状态机:从ER图到Django ORM

2.1 业务实体与App划分

广告交易系统通常涉及四类角色:广告主、媒体方、运营管理员和系统维护人员。对应到Django项目,我会按照业务域拆分成accountsinventoryad_orderreport四个App,而不是把所有表塞进一个models.py。Django创建App的标准命令是:

python manage.py startapp accounts python manage.py startapp inventory python manage.py startapp ad_order python manage.py startapp report

需要注意,新版Django执行startapp后不会自动把App注册到settings.py,你得手动在INSTALLED_APPS里补齐。很多朋友复制源码后直接runserver,报No module named 'ad_order',其实就是注册这一行漏了。按业务域拆分的优势很直接:ad_order里的模型可以单独做权限控制,report只读表不会因为误操作被改,不同角色的数据边界在模型层就能体现。

App核心模型主要使用者
accounts用户、广告主资料、媒体方资料所有角色
inventory广告位、媒体站点媒体方
ad_order广告订单、订单状态流水广告主、运营
report点击流水、投放日报运营、财务

2.2 广告订单与状态机的Django模型设计

订单状态定义了从草稿、待审核、投放中、已暂停、已完成的完整生命周期。实际业务里,状态转换还会附带审核记录和扣费快照,因此我把状态单独写成IntegerFieldchoices,而不是CharField,因为状态编码涉及后端逻辑判断,整型在查询和比较时更节省索引空间。参考模型如下:

from django.db import models from django.conf import settings class OrderStatus(models.TextChoices): DRAFT = 0, '草稿' PENDING = 1, '待审核' ACTIVE = 2, '投放中' PAUSED = 3, '已暂停' COMPLETED = 4, '已完成' CANCELLED = 5, '已取消' class AdOrder(models.Model): order_no = models.CharField(max_length=32, unique=True, verbose_name='订单编号') advertiser = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='ad_orders', verbose_name='广告主' ) ad_slot = models.ForeignKey('inventory.AdSlot', on_delete=models.PROTECT, verbose_name='广告位') budget = models.DecimalField(max_digits=10, decimal_places=2, verbose_name='预算金额') daily_cap = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True, verbose_name='日预算') status = models.IntegerField(choices=OrderStatus.choices, default=OrderStatus.DRAFT, db_index=True, verbose_name='状态') start_at = models.DateTimeField(verbose_name='开始时间') end_at = models.DateTimeField(verbose_name='结束时间') created_at = models.DateTimeField(auto_now_add=True) class Meta: ordering = ['-created_at'] verbose_name = '广告订单' verbose_name_plural = '广告订单' def __str__(self): return f'{self.order_no} - {self.get_status_display()}'

参数选择上,有几个点值得解释:

  • ForeignKey指向settings.AUTH_USER_MODEL,而不是直接指向User,这样后续想扩展用户字段(比如广告主备注)时不用重构迁移。
  • ad_sloton_delete=models.PROTECT表示投放中的订单不允许删除广告位,数据库层面就拦住误删;而广告主字段用CASCADE,删除用户时订单一起清理,避免残留脏数据。
  • budgetdaily_capDecimalField而不是FloatField,金额场景下浮点计算会积累误差,尤其在生成报表求和时容易出现0.1+0.2不等于0.3的问题。
  • statusdb_index=True是因为列表页和后台筛选都会高频按状态过滤,订单量上来后,全表扫描的成本会明显放大。

2.3 状态转换的查询与批量操作

状态机不只是定义字段,还要能安全地批量更新。Django执行查询删除对象是日常高频操作,但批量更新状态时要注意过滤条件的顺序。常见做法是先把可转换的订单主键查出来,再在事务里原子更新:

from django.db import transaction from django.utils import timezone from .models import AdOrder now = timezone.now() # 找出所有已到开始时间、且状态是待审核的订单 candidate_ids = list( AdOrder.objects.filter( status=AdOrder.OrderStatus.PENDING, start_at__lte=now ).values_list('id', flat=True) ) with transaction.atomic(): updated = AdOrder.objects.filter( id__in=candidate_ids, status=AdOrder.OrderStatus.PENDING ).update( status=AdOrder.OrderStatus.ACTIVE )

这段代码有两个易错点和两个关键设计:

  1. values_listupdate,是为了让状态判断和更新分离,避免在同一个QuerySet上用update时受到缓存影响。
  2. transaction.atomic()包裹后,即使后续要写投放流水,任何一个失败都会回滚状态,不会出现订单已激活但流水没写入的情况。
  3. update的过滤条件里再加一次status=OrderStatus.PENDING,相当于乐观锁,防止两个worker同时把同一个订单从待审核改成投放中。
  4. updated返回实际更新的行数,如果为0,说明另一个请求先改了状态,本次不需要再做后续动作。
当前状态可转换状态触发条件
草稿待审核广告主提交审核
待审核投放中/已驳回运营审核通过/驳回
投放中已暂停/已完成手动暂停/到达结束时间
已暂停投放中/已完成恢复投放/超出结束时间

实际校验时,建议在模型里写一个can_transition_to(target_status)方法,把这张表翻译成判断逻辑,避免视图层到处散落状态判断代码。如果订单到结束时间需要自动完成,可以用Celery定时任务,也可以写一个Django management command配合cron调用。

3. ADExchange的视图层实现:基于类的视图与权限控制

3.1 为什么选择Django类视图而不是函数视图

对于ADExchange这种需要大量列表、详情、表单的Web项目,Django中基于类的视图(CBV)能显著减少重复代码。举例来说,广告主查看自己的订单列表时,函数视图要写模板渲染、分页、权限判断,而ListView只需要指定modelqueryset。另外,类视图还支持LoginRequiredMixin,可以和用户状态联动。订单模块的列表视图我这样写:

from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin from django.views.generic import ListView, DetailView, CreateView from .models import AdOrder class OrderListView(LoginRequiredMixin, PermissionRequiredMixin, ListView): model = AdOrder template_name = 'ad_order/order_list.html' context_object_name = 'orders' paginate_by = 20 permission_required = 'ad_order.view_adorder' def get_queryset(self): qs = super().get_queryset() if self.request.user.is_staff: return qs.select_related('ad_slot', 'advertiser') # 普通广告主只能看到自己的订单 return qs.filter(advertiser=self.request.user).select_related('ad_slot')

permission_required使用的是ad_order.view_adorder,这是Django根据模型名自动生成的查看权限。角色权限分配建议用一次性数据迁移或脚本写入Group,否则未授权用户会直接得到403。这里is_staff的判断逻辑解决了一个实际问题:运营可以看到全量订单,广告主只能看自己的,而不需要写两套视图。

角色可访问视图权限码
广告主订单列表/详情、创建订单view_adorder / add_adorder
媒体方广告位列表、上下架view_adslot / change_adslot
运营/管理员全部订单、报表、后台管理全部权限

3.2 创建订单:表单处理与重定向传参

订单创建是ADExchange里比较复杂的表单场景,因为要校验预算、开始时间、结束时间,还要在提交成功后往详情页跳转并显示一条提示。Django的CreateView能做,但需要在form_valid里手动处理用户绑定和跳转:

from django.contrib import messages from django.shortcuts import redirect from django.views.generic.edit import CreateView from .models import AdOrder from .forms import AdOrderForm class OrderCreateView(LoginRequiredMixin, CreateView): model = AdOrder form_class = AdOrderForm template_name = 'ad_order/order_form.html' def form_valid(self, form): form.instance.advertiser = self.request.user self.object = form.save() messages.success(self.request, '订单创建成功,等待审核') return redirect('ad_order:order-detail', pk=self.object.pk)

这里有个Django重定向传递数据的细节:messages.success依赖session,所以redirect目标必须带上request,不能直接返回HttpResponseRedirect。在forms.py里还需要做字段清洗和展示控制:

from django import forms from .models import AdOrder class AdOrderForm(forms.ModelForm): class Meta: model = AdOrder fields = ['ad_slot', 'budget', 'daily_cap', 'start_at', 'end_at'] def clean(self): cleaned = super().clean() start = cleaned.get('start_at') end = cleaned.get('end_at') if start and end and end <= start: raise forms.ValidationError('结束时间必须晚于开始时间') return cleaned

表单里的fields白名单很关键:广告主无法通过构造POST请求修改status字段,因为status根本没出现在表单字段里。时间先后校验放在clean方法中,比在视图层判断更内聚。

3.3 模板中的静态资源与前端样式文件

这套ADExchange资料里包含多个styles.*.cssbootstrap.min14ed.css这样的散装静态文件,说明项目的前端采用了打包压缩后的资源。在Django中,模板里加载静态资源的标准做法是:

{% load static %} <link rel="stylesheet" href="{% static 'css/bootstrap.min14ed.css' %}"> <link rel="stylesheet" href="{% static 'css/styles.f13d105a8a0b726b47d03fbb25cad893.css' %}">

这里需要区分两种静态文件目录:如果CSS放在项目根目录下的static/而不是某个App内,必须在settings.py里配置STATICFILES_DIRS = [BASE_DIR / 'static'],否则模板渲染后浏览器请求这些路径会直接404。开发环境下Django会自己找静态文件,但生产环境需要先执行python manage.py collectstatic,把散落在各App和根目录的静态文件统一收集到STATIC_ROOT指定的目录,再交给Nginx或Gunicorn对应层处理。特别是这些带哈希的文件名,它们通常来自前端构建工具,丢失任何一个都可能导致页面布局错乱。

4. 管理后台与报表查询:Django Admin优化与聚合查询实战

4.1 Admin界面美化与列表配置

Django自带admin后台虽然实用,但默认界面比较朴素,项目资料里包含大量CSS文件,很可能是对admin做了二次美化。常见的做法是先不引入第三方库,把ModelAdmin字段配置好,让后台在功能优先的情况下可用:

from django.contrib import admin from .models import AdOrder @admin.register(AdOrder) class AdOrderAdmin(admin.ModelAdmin): list_display = ('order_no', 'advertiser', 'ad_slot', 'budget', 'status', 'created_at') list_filter = ('status', 'start_at', 'end_at') search_fields = ('order_no', 'advertiser__username') list_per_page = 30 date_hierarchy = 'created_at' def get_queryset(self, request): qs = super().get_queryset(request) return qs.select_related('advertiser', 'ad_slot')

list_display里如果出现外键字段,必须在get_querysetselect_related,否则列表页每显示一行都会多查一次数据库,订单上千后admin页面会明显变慢。search_fieldsadvertiser__username是跨表搜索,用双下划线穿透关系,这个语法在Django查询和Admin里一致。

如果希望更漂亮,可以安装django-simpleui,把它加到settings.pyINSTALLED_APPS最前面:

pip install django-simpleui

然后执行collectstatic。注意这会替换admin默认样式,原来资料包里的styles.*.css若和simpleui冲突,需要保留其中一套,不建议同时加载两套admin样式。

4.2 投放报表的聚合查询

报表是ADExchange管理系统最核心的一环。投放量、消耗金额、点击率这些指标,最怕用Python循环去统计,正确姿势是使用Django ORM的聚合函数。假设AdClick模型记录了每次点击和扣费:

from django.db.models import Sum, Count, Avg, F from .models import AdOrder, AdClick stats = AdClick.objects.filter( order__status=AdOrder.OrderStatus.ACTIVE ).aggregate( total_clicks=Count('id'), total_cost=Sum('cost'), avg_cost=Avg('cost'), )

这里aggregate返回一个字典而不是QuerySet,适合在视图中取单个汇总指标。若需要按订单分组看日报,应该用annotate

from django.db.models.functions import TruncDate daily_stats = ( AdClick.objects .filter(order_id=order_id) .annotate(day=TruncDate('created_at')) .values('day') .annotate( clicks=Count('id'), cost=Sum('cost'), ecpm=(Sum('cost') / Count('id')) * 1000 ) .order_by('day') )

这个查询里有几个参数需要特别注意:

  • TruncDatecreated_at截断为日期,解决按天分组时带小时分钟的问题。
  • values('day')必须放在annotate前面,否则会按AdClick全字段分组,结果完全错误。这是一条很容易踩坑的规则。
  • ecpm的计算是总消耗除以点击数再乘1000,直接在数据库完成,省去Python二次计算。
  • AdClick表,建议在order_idcreated_at上建联合索引,因为日报查询几乎都按这两个字段过滤和分组。
聚合函数用途典型场景
Count计数点击量、展示量
Sum求和消耗金额、预算消耗
Avg平均平均点击单价
Min / Max极值最长投放周期

Django执行查询删除对象时,报表表不建议物理删除,保留原始点击流水,后面审计才能追溯。如果需要清理历史数据,可以用Django shell执行:

python manage.py shell -c "from report.models import AdClick; AdClick.objects.filter(created_at__lt='2024-01-01').delete()"

4.3 导出CSV的桌面端技巧

除了页面展示,运营经常会要求导出报表。Django生成CSV的常见做法是通过StreamingHttpResponse,避免一次性把所有行加载进内存:

import csv from django.http import StreamingHttpResponse def export_stats_csv(request, order_id): def iter_rows(): yield ['日期', '点击', '消耗'] for row in daily_stats_by_order(order_id): yield [row['day'], row['clicks'], row['cost']] response = StreamingHttpResponse(iter_rows(), content_type='text/csv') response['Content-Disposition'] = 'attachment; filename="stats.csv"' return response

这里用生成器逐行产出,适合报表行数很多的场景。如果导出的CSV在Excel里打开中文乱码,需要在文件头部写入UTF-8 BOM:

yield '\ufeff' # UTF-8 BOM

这个技巧很实用,属于导出文件时不太容易注意的坑。

5. 从开发到生产:宝塔部署Django ADExchange的关键参数与排错

5.1 部署环境准备

本地能跑通不代表生产环境能跑通。部署Django项目最常用的方式是在Linux上安装Python,用venv隔离依赖,再用gunicorn启动,最后由宝塔面板的Nginx反向代理。命令行操作如下:

apt update apt install -y python3 python3-venv python3-pip python3 -m venv venv source venv/bin/activate pip install -r requirements.txt pip install gunicorn

requirements.txt里如果有mysqlclient,安装前要先装default-libmysqlclient-dev,否则会报编译失败,这在宝塔环境里是最常见的错误之一。

5.2 静态文件与ALLOWED_HOSTS配置

调试模式下Django自己处理静态文件,但生产环境必须把静态文件收集到指定目录。调整settings.py

DEBUG = False ALLOWED_HOSTS = ['www.example.com', '你的服务器IP'] STATIC_ROOT = '/www/wwwroot/adexchange/staticfiles/'

然后执行:

python manage.py collectstatic --noinput python manage.py migrate

注意:ALLOWED_HOSTS不能留空,否则访问会报DisallowedHost。Nginx配置里需要把静态文件请求指向STATIC_ROOT,否则页面有样式但加载不出来:

location /static/ { alias /www/wwwroot/adexchange/staticfiles/; } location / { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; }

关键参数是proxy_set_header Host $host,少了它Django的request.get_host()拿不到真实域名,CSRF校验和站点路由都会异常。

5.3 gunicorn启动参数与supervisor保活

gunicorn启动命令不是随便写的,它决定了并发能力和超时控制:

gunicorn adexchange.wsgi:application \ --bind 127.0.0.1:8000 \ --workers 3 \ --timeout 120 \ --max-requests 5000 \ --access-logfile /www/wwwroot/adexchange/logs/gunicorn-access.log \ --error-logfile /www/wwwroot/adexchange/logs/gunicorn-error.log

workers一般取CPU核数*2+1,云服务器2核4G用3个worker足够,开多了内存反而不够。timeout 120需要特别关注,admin后台导出大报表或执行聚合查询超过30秒时,默认30秒worker会被强制杀掉,表现为页面504。max-requests 5000让worker处理5000个请求后自动重启,可以缓解内存泄漏。用宝塔的supervisor守护进程时,先建好日志目录:

mkdir -p /www/wwwroot/adexchange/logs

5.4 常见排错:admin样式丢失、CSRF拒绝与数据库连接断开

部署后最常遇到的三个问题:admin样式丢失,几乎都是collectstatic没执行或Nginx的location /static/路径与STATIC_URL不一致;表单提交403 CSRF,除了模板里加{% csrf_token %},还需要在settings.py里配置CSRF_TRUSTED_ORIGINS,HTTPS下尤其容易踩;数据库报OperationalError: MySQL server has gone away,多半是MySQL的wait_timeout比Django的CONN_MAX_AGE短,把连接改为短连接即可:

DATABASES = { 'default': { 'ENGINE': 'django.db.backends.mysql', 'NAME': 'adexchange', 'USER': 'root', 'PASSWORD': '你的密码', 'HOST': '127.0.0.1', 'PORT': '3306', 'CONN_MAX_AGE': 60, } }

CONN_MAX_AGE设为60秒,远小于MySQL默认的8小时,基本能避免空闲连接被服务端关闭。最后补一个部署技巧:修改STATIC_ROOT后,需要在Nginx里reload并重新执行collectstaticDEBUG=False时如果模板里使用了{{ debug }}这类变量会直接报错,遇到这种问题不要先查view,先确认模板上下文是否包含对应变量。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询