简介:这是一套开箱即用的广告竞价页订单管理系统源码及配套搭建教程,面向数字营销从业者、中小型广告代理公司及PHP初学者,解决竞价推广场景下订单分散、人工核对低效、通知滞后等核心痛点。资源基于开源caozha-admin深度定制,集成订单管理、回收站、产品维护、批量导入导出(支持.xls/.xlsx/.csv)、重复订单检测、下单表单调用及邮件/短信实时提醒等功能,并内置细粒度查看权限控制机制。压缩包含2000个文件,主体为855个PHP业务逻辑文件、399个GIF/PNG/JPG等界面资源、112个JS交互脚本、103个HTML模板及63个Markdown文档,辅以Layui、UEditor等前端组件,整体20.4MB,结构清晰便于二次开发与模块化学习。目前已有91人下载学习,可直接部署运行,亦可结合源码快速掌握广告系统后端架构设计与权限管控实践。
1. 广告竞价页订单管理系统不是“拼凑页面”,而是把流量转化链路压进一个可追踪、可回溯、可干预的闭环
很多运营同学拿到“广告竞价页订单管理系统”这个需求时,第一反应是:做个带表单的落地页,再连个 MySQL 存下手机号和来源渠道就完事了。但真实场景中,一个跑信息流或搜索广告的团队,每天要面对几十个投放计划、上百个创意变体、多渠道归因(UTM、微信场景值、头条回调参数)、订单状态异步更新(支付成功/退款/发货/拒收),以及销售侧手动补单、客服侧批量改单、财务侧对账冲正等操作。这时候,如果系统没有统一订单 ID、没有来源字段标准化、没有状态机驱动、没有操作日志审计,轻则数据对不上、ROI 算不准,重则广告费打水漂、客诉无从查起。本教程面向的是已具备基础 Web 开发能力(能写接口、会配 Nginx、懂数据库建模)的中小型广告代理公司技术负责人、独立站运营开发者或 SAAS 工具集成工程师——不讲“什么是订单”,只讲怎么用最小成本,在 2 天内搭出一个能上线跑真实广告订单、支持来源归因、支持状态流转、支持导出对账的可用系统。
2. 用 Django + PostgreSQL 搭建核心订单模型与来源解析层:为什么选它而不是 Node.js 或 PHP?
2.1 选型逻辑:广告订单系统最怕“字段膨胀”和“归因错位”,Django ORM 天然适配
广告订单的核心矛盾在于:前端落地页 URL 带着大量动态参数(如?utm_source=wechat&campaign_id=2024q3_bj&creative=video_v2),后端必须在创建订单时准确提取并固化这些字段,且后续不可修改;同时,订单状态需严格遵循“待支付 → 已支付 → 已发货 → 已签收 → 已退款”等有限状态集,不能靠字符串自由填写。Django 的Model层强制字段定义 +choices枚举 +default和blank=False控制,天然防止“来源字段存成 null”或“状态填成 ‘payed’ 这类拼写错误”。相比之下,Express.js 依赖开发者手动校验,PHP Laravel 的 migration 虽强但缺乏 Django Admin 那种开箱即用的数据审核界面——而广告运营人员恰恰需要随时查看某条订单来自哪个广告计划、是否被人工改过状态。
提示:不要用 SQLite 做生产环境订单库。广告订单写入频次高(尤其秒杀类活动),SQLite 的写锁机制会导致并发插入失败;PostgreSQL 支持行级锁、JSONB 字段存原始请求参数、原生支持
pg_trgm做模糊搜索(比如按“北京朝阳区”查地址),是更稳妥的选择。
2.2 订单主表设计:6 个必存字段 + 1 个 JSONB 扩展字段
# models.py from django.db import models from django.contrib.postgres.fields import JSONField class Order(models.Model): # 核心业务字段(不可为空,带索引) order_id = models.CharField(max_length=32, unique=True, db_index=True, help_text="全局唯一订单号,建议用 uuid4 或 时间戳+随机数") phone = models.CharField(max_length=11, db_index=True, help_text="用户手机号,用于短信通知和 CRM 同步") status = models.CharField( max_length=16, choices=[ ('pending', '待支付'), ('paid', '已支付'), ('shipped', '已发货'), ('delivered', '已签收'), ('refunded', '已退款'), ], default='pending', db_index=True ) created_at = models.DateTimeField(auto_now_add=True, db_index=True) updated_at = models.DateTimeField(auto_now=True) # 来源归因字段(全部设为非空,避免漏传) utm_source = models.CharField(max_length=64, blank=False, help_text="如 wechat / toutiao / baidu") utm_medium = models.CharField(max_length=64, blank=False, help_text="如 cpc / banner / search") campaign_id = models.CharField(max_length=64, blank=False, help_text="广告计划ID,用于归因分析") # 扩展字段:存原始 query string、设备指纹、IP 等,不参与业务逻辑但供排查用 raw_params = JSONField(default=dict, help_text="原始 GET 参数字典,如 {'utm_source': 'wechat', 'device_id': 'abc123'}") class Meta: ordering = ['-created_at'] verbose_name = "广告订单" verbose_name_plural = "广告订单列表"字段说明与实操要点:
| 字段名 | 为什么必须设blank=False | 生产环境注意事项 |
|---|---|---|
order_id | 避免用自增 ID 暴露订单量,也防止被爬虫遍历;生成逻辑建议用uuid.uuid4().hex[:16]或int(time.time() * 1000) + random.randint(100, 999) | 在save()方法中覆盖,确保创建时必生成,不可为空 |
phone | 手机号是广告效果归因的黄金字段,也是短信触达、CRM 同步的唯一键 | 建议加validators=[RegexValidator(r'^1[3-9]\d{9}$')]做格式校验 |
utm_source/utm_medium/campaign_id | 这三个是 Google Analytics 和主流 DSP 的标准字段,缺失将导致归因报表断层 | 必须在前端落地页 URL 中强制携带,后端不做默认值兜底,宁可报错也不填空 |
raw_params | 当某天发现“为什么这个订单没记录 campaign_id?”时,可直接查该字段看原始请求长什么样 | PostgreSQL 中JSONField实际映射为jsonb类型,支持raw_params->>'utm_source'索引查询 |
2.3 来源解析中间件:自动从 request.GET 提取 UTM 参数并注入到订单创建上下文
# middleware.py from django.utils.deprecation import MiddlewareMixin class UtmParamMiddleware(MiddlewareMixin): def process_request(self, request): # 从 query string 提取标准 UTM 参数,存入 request.utm_context utm_context = { 'utm_source': request.GET.get('utm_source', '').strip() or 'direct', 'utm_medium': request.GET.get('utm_medium', '').strip() or 'organic', 'campaign_id': request.GET.get('campaign_id', '').strip(), } # 补充非标准但高频的广告平台参数 if not utm_context['campaign_id']: utm_context['campaign_id'] = request.GET.get('adgroup_id', '') or request.GET.get('creative_id', '') # 存入 request 对象,供视图函数调用 request.utm_context = utm_context request.raw_params = dict(request.GET.items()) # 保留原始参数全量# views.py from django.http import JsonResponse from django.views.decorators.csrf import csrf_exempt import json @csrf_exempt def create_order(request): if request.method != 'POST': return JsonResponse({'error': '仅支持 POST'}, status=405) try: data = json.loads(request.body) phone = data.get('phone', '').strip() if not phone or len(phone) != 11: return JsonResponse({'error': '手机号格式错误'}, status=400) # 从中间件注入的 context 中取归因参数 utm = getattr(request, 'utm_context', {}) if not all([utm.get('utm_source'), utm.get('utm_medium')]): return JsonResponse({'error': '缺少必要归因参数(utm_source/utm_medium)'}, status=400) # 创建订单 order = Order.objects.create( order_id=f"ORD{int(time.time())}{random.randint(100, 999)}", phone=phone, utm_source=utm['utm_source'], utm_medium=utm['utm_medium'], campaign_id=utm['campaign_id'], raw_params={**request.raw_params, 'user_agent': request.META.get('HTTP_USER_AGENT', '')} ) return JsonResponse({ 'success': True, 'order_id': order.order_id, 'status': order.status }) except Exception as e: return JsonResponse({'error': f'创建失败:{str(e)}'}, status=500)注意:
UtmParamMiddleware必须在settings.py的MIDDLEWARE列表中放在SessionMiddleware之后、CommonMiddleware之前,确保能读取到原始 GET 参数;@csrf_exempt是因为广告落地页通常跨域提交,CSRF token 难以同步,改用签名验证或 IP 白名单更实际。
3. 前端落地页对接:用纯 HTML + Fetch 实现零框架提交,兼容微信/抖音内嵌浏览器
3.1 落地页必须携带的 4 类参数:UTM、广告平台特有参数、设备标识、防重复提交 Token
一个合格的广告竞价页 URL 不应只是https://example.com/landing.html?utm_source=toutiao,而应包含:
| 参数类型 | 示例值 | 用途 | 是否必需 |
|---|---|---|---|
| 标准 UTM | utm_source=toutiao&utm_medium=cpc&utm_campaign=2024q3_brand | 归因统计基础 | ✅ |
| 平台特有参数 | ad_id=123456789&creative_id=987654321 | 用于头条/广点通回调匹配 | ⚠️(按平台要求) |
| 设备指纹 | device_id=web_abc123xyz | 防刷单、识别同一设备多次提交 | ✅(建议用 localStorage 生成一次持久化) |
| 提交 Token | submit_token=sha256(时间戳+随机数+IP) | 后端做幂等性校验 | ✅(关键防重) |
<!-- landing.html --> <!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>限时优惠 - 立即领取</title> <script> // 1. 从 URL 解析所有参数 function getQueryParams() { const urlParams = new URLSearchParams(window.location.search); const params = {}; for (let [key, value] of urlParams) { params[key] = value; } return params; } // 2. 生成 device_id(首次访问生成并存 localStorage) function getDeviceId() { let deviceId = localStorage.getItem('ad_device_id'); if (!deviceId) { deviceId = 'web_' + Math.random().toString(36).substr(2, 9) + Date.now(); localStorage.setItem('ad_device_id', deviceId); } return deviceId; } // 3. 生成 submit_token(简单哈希,生产环境建议后端下发) function generateSubmitToken(params) { const seed = JSON.stringify(params) + new Date().getTime() + navigator.userAgent; return btoa(seed).substring(0, 16); // 简化版,实际用 crypto.subtle.digest 更安全 } // 页面加载完成后注入参数到表单 document.addEventListener('DOMContentLoaded', function() { const params = getQueryParams(); const deviceId = getDeviceId(); const token = generateSubmitToken(params); // 将参数写入隐藏域 document.getElementById('utm_source').value = params.utm_source || 'direct'; document.getElementById('utm_medium').value = params.utm_medium || 'organic'; document.getElementById('campaign_id').value = params.campaign_id || params.ad_id || ''; document.getElementById('device_id').value = deviceId; document.getElementById('submit_token').value = token; // 绑定提交事件 document.getElementById('order-form').onsubmit = async function(e) { e.preventDefault(); await submitOrder(); }; }); async function submitOrder() { const form = document.getElementById('order-form'); const formData = new FormData(form); try { const res = await fetch('/api/order/', { method: 'POST', body: JSON.stringify(Object.fromEntries(formData)), headers: { 'Content-Type': 'application/json' } }); const result = await res.json(); if (result.success) { alert('提交成功!客服将在 5 分钟内联系您'); location.href = '/success.html?order_id=' + result.order_id; } else { alert('提交失败:' + result.error); } } catch (err) { alert('网络错误,请重试'); } } </script> </head> <body> <form id="order-form"> <input type="hidden" id="utm_source" name="utm_source"> <input type="hidden" id="utm_medium" name="utm_medium"> <input type="hidden" id="campaign_id" name="campaign_id"> <input type="hidden" id="device_id" name="device_id"> <input type="hidden" id="submit_token" name="submit_token"> <label>手机号:<input type="tel" name="phone" required maxlength="11"></label> <button type="submit">立即领取</button> </form> </body> </html>关键细节说明:
device_id存 localStorage 而非 Cookie:微信 iOS 内置浏览器对第三方 Cookie 限制极严,localStorage 更可靠;submit_token不依赖后端下发:对于中小团队,每次前端生成一个带时间戳的哈希值,后端校验abs(time.time() - token_time) < 300即可防重放,比 JWT 简单;- 表单不设 action,全由 JS 控制:避免用户点击两次导致重复提交,也方便后续加埋点(如
gtag('event', 'conversion', {...}))。
4. 订单状态机与异步通知:用 Django Signals + Celery 实现支付成功自动更新
4.1 状态流转必须受控:禁止直接order.status = 'paid',改用状态机方法
直接赋值状态极易引发业务逻辑错乱(例如“已发货”订单又被改成“已支付”)。Django 没有内置状态机,但可用django-fsm库或手写方法约束:
# models.py from django_fsm import FSMField, transition class Order(models.Model): # ... 其他字段保持不变 status = FSMField(default='pending') @transition(field=status, source=['pending'], target='paid') def pay(self): pass @transition(field=status, source=['paid'], target='shipped') def ship(self): pass @transition(field=status, source=['shipped'], target='delivered') def deliver(self): pass @transition(field=status, source=['paid', 'shipped'], target='refunded') def refund(self): pass注意:
@transition装饰器会拦截非法状态变更,比如order.refund()在status='pending'时会抛TransitionNotAllowed异常,比 if-else 判断更健壮。
4.2 接入微信/支付宝支付回调:用 Celery 异步处理,避免阻塞主请求
# tasks.py from celery import shared_task from .models import Order @shared_task def handle_payment_callback(order_id, payment_result): try: order = Order.objects.get(order_id=order_id) if payment_result == 'success': order.pay() # 触发状态机 order.save() # 发送企业微信通知 send_wecom_alert(f"新支付订单:{order_id},来自 {order.utm_source}") elif payment_result == 'failed': # 记录失败原因,不改状态 order.raw_params['payment_error'] = payment_result order.save() except Order.DoesNotExist: pass # 订单不存在,可能是测试回调# views.py(支付回调入口) from django.http import HttpResponse from .tasks import handle_payment_callback def wechat_pay_callback(request): if request.method == 'POST': # 1. 验证签名(略,参考微信官方 SDK) # 2. 解析 XML 回调体 import xml.etree.ElementTree as ET root = ET.fromstring(request.body) out_trade_no = root.find('out_trade_no').text result_code = root.find('result_code').text # 3. 异步触发状态更新 handle_payment_callback.delay(out_trade_no, 'success' if result_code == 'SUCCESS' else 'failed') return HttpResponse('<xml><return_code><![CDATA[SUCCESS]]></return_code><return_msg><![CDATA[OK]]></return_msg></xml>', content_type='application/xml')Celery 配置要点(celery.py):
# celery.py from celery import Celery import os os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'ad_order.settings') app = Celery('ad_order') app.config_from_object('django.conf:settings', namespace='CELERY') app.autodiscover_tasks() # 生产环境务必设置 broker 为 Redis,不要用 RabbitMQ(部署复杂) # CELERY_BROKER_URL = 'redis://127.0.0.1:6379/0' # CELERY_RESULT_BACKEND = 'redis://127.0.0.1:6379/1'提示:本地开发可用
celery -A ad_order worker --loglevel=info启动 worker;生产环境建议用 Supervisor 或 systemd 管理进程,并配置--concurrency=4防止单任务阻塞。
5. 数据导出与对账:用 Django Admin 自定义动作 + Pandas 生成 Excel 对账单
5.1 在 Django Admin 中添加“导出选中订单”动作,支持按日期、来源、状态筛选
# admin.py import pandas as pd from django.http import HttpResponse from django.contrib import admin from .models import Order @admin.register(Order) class OrderAdmin(admin.ModelAdmin): list_display = ['order_id', 'phone', 'status', 'utm_source', 'utm_medium', 'campaign_id', 'created_at'] list_filter = ['status', 'utm_source', 'utm_medium', 'created_at'] search_fields = ['order_id', 'phone', 'campaign_id'] date_hierarchy = 'created_at' actions = ['export_selected_orders'] def export_selected_orders(self, request, queryset): # 使用 Pandas 构建 DataFrame,保证中文列名和日期格式正确 df = pd.DataFrame(list(queryset.values( 'order_id', 'phone', 'status', 'utm_source', 'utm_medium', 'campaign_id', 'created_at', 'updated_at' ))) df['created_at'] = pd.to_datetime(df['created_at']).dt.strftime('%Y-%m-%d %H:%M:%S') df['updated_at'] = pd.to_datetime(df['updated_at']).dt.strftime('%Y-%m-%d %H:%M:%S') df.columns = ['订单号', '手机号', '状态', '来源', '媒介', '广告计划ID', '创建时间', '更新时间'] # 生成 Excel 文件 response = HttpResponse(content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') response['Content-Disposition'] = f'attachment; filename=ad_orders_{int(time.time())}.xlsx' df.to_excel(response, index=False) return response export_selected_orders.short_description = "导出选中订单为 Excel"5.2 对账关键字段校验:用 SQL 直查防止 ORM 缓存导致数据偏差
广告财务对账最常问的三个问题:
① “今天微信来源的已支付订单总金额是多少?”
② “campaign_id=2024q3_brand 的订单里,有多少还没发货?”
③ “同一个手机号,是否在 24 小时内提交了超过 3 次?”
这些问题用 Django ORM 查易出错(如.count()和.aggregate(Sum())在大数据量下性能差),直接写 SQL 更稳:
-- ① 微信已支付订单金额(假设订单表有 amount 字段,此处为扩展示意) SELECT SUM(amount) FROM ad_order_order WHERE utm_source = 'wechat' AND status = 'paid' AND created_at >= CURRENT_DATE; -- ② 某计划未发货订单数 SELECT COUNT(*) FROM ad_order_order WHERE campaign_id = '2024q3_brand' AND status IN ('pending', 'paid'); -- ③ 同一手机号 24 小时内提交次数(防羊毛党) SELECT phone, COUNT(*) as cnt FROM ad_order_order WHERE created_at >= NOW() - INTERVAL '24 hours' GROUP BY phone HAVING COUNT(*) > 3;注意:PostgreSQL 中
INTERVAL '24 hours'比BETWEEN now() - interval '1 day' AND now()更精确;HAVING必须跟在GROUP BY后,不能写成WHERE COUNT(*) > 3。
5.3 导出模板预设:给运营人员提供带筛选条件的 Excel 下载链接
在 Admin 页面顶部加一个自定义按钮,点击后跳转到/admin/export/?utm_source=wechat&status=paid&date_from=2024-06-01,后端根据 query string 动态生成 SQL:
# urls.py from django.urls import path from . import views urlpatterns = [ path('admin/export/', views.export_orders_by_params, name='export_orders'), ]# views.py def export_orders_by_params(request): filters = {} if request.GET.get('utm_source'): filters['utm_source'] = request.GET['utm_source'] if request.GET.get('status'): filters['status'] = request.GET['status'] if request.GET.get('date_from'): filters['created_at__gte'] = request.GET['date_from'] queryset = Order.objects.filter(**filters) # 复用上面的 export_selected_orders 逻辑 ...这样运营人员只需改 URL 参数,就能一键导出指定条件的对账单,无需登录数据库或写 SQL。
本文还有配套的精品资源,点击获取