
1. 需求拆解与整体架构1.1 这个网站到底在解决什么问题先说清楚“广告策划网站”是个什么东西。它本质上是一个面向广告策划、创意设计团队的内容管理加项目协作平台承担两件事对外展示设计师的作品案例对内跑通“客户提需求 — 策划师接单 — 团队创作 — 交付归档”这条业务链路。我见过太多小型广告工作室的现状作品躺在百度网盘和朋友圈里客户需求散落在微信聊天记录中改稿意见靠截图传递项目档期靠Excel排。这套东西在客户量少的时候还能撑一撑一旦同时跑四五个项目资源错配、需求遗漏、交付延期就是家常便饭。所以我做的这个网站核心目标就两个一是把设计师的作品系统化地展示出来让客户看案例不用再翻聊天记录二是把需求对接从微信群拉回到结构化流程里每个项目从创建到交付都有据可查。这套项目选型非常直白——Python作为主力语言处理业务逻辑Django作为Web框架提供全套基础设施。适合谁参考两种人一种是刚学完Django基础想找一个带业务场景的完整项目练手的中级开发者另一种是广告行业里懂点技术、想给工作室搭管理系统的设计师或运营人员。前者学技术架构后者学业务建模都能在这篇文章里找到对应的东西。1.2 Django为什么适合干这个活选Django而不是其他框架理由其实很朴素。这个项目是典型的内容管理加业务流转Django的MTV架构正好卡在这个点上Model定义数据结构Template负责页面渲染View处理请求逻辑三者边界清晰团队协作时不容易互相踩脚。自带Admin后台是压倒性优势。广告策划网站的后台管理需求非常多——审核案例、管理分类、查看项目进度、维护设计师资料Django Admin等于白送了一套完整的数据管理界面自己只需要做少量定制就能应付初期上线的管理需求。如果是用FastAPI或Flask这些都得从头搭建时间成本立刻翻倍。权限系统这块也省了大功夫。广告策划网站天然有角色区分访客只能看公开案例客户登录后看自己的项目设计师管理自己参与的项目管理员统筹全局。Django内置的Group、Permission机制配合装饰器和Mixin几行代码就能把这种层级控制落到视图层不需要自己写Session级别的角色判断逻辑。但也要说清楚Django的局限。这个项目用服务端渲染加少量JavaScript就够了Django的模板系统天然契合这种轻交互场景。如果以后要做复杂的拖拽式创意排版、实时协作编辑这类前端重交互功能Django模板会显得笨重那才需要考虑前后端分离加Django REST Framework的方案。我现在的建议是不要过度设计先把核心业务跑通API层等真正有需求再补。1.3 功能模块的整体划分整个网站按业务边界切成了五个模块每个模块独立开发、独立测试案例展示模块访客可见展示设计师的过往项目作品按广告类别品牌全案、平面广告、电商详情页、新媒体海报等分类筛选。这是网站的“脸面”也是获客入口。用户与权限模块注册、登录、角色标识设计师/客户/管理员负责控制不同角色的访问范围。需求对接模块客户创建需求单填写广告类型、预算区间、期望交付时间、创意方向描述可以上传参考图。设计师端看到需求池可以认领或拒绝。项目管理模块项目从“待认领”到“已完成”的状态流转包括项目进度更新、交付文件上传、历史记录留痕。这个模块是整个业务链路的枢纽。后台管理模块基于Django Admin定制用于站点管理员审核案例、分配项目、维护基础数据。每个模块互相独立但共享同一套数据模型这样做的直接好处是后面要给某个模块加功能不需要动其他模块的代码。比如案例展示模块想加一个“按设计师筛选”的功能只需要新增一个视图加一个URL路由不用碰项目流程的代码。2. 数据模型设计——先把业务关系理清楚2.1 自定义用户模型别偷懒用默认UserDjango官方文档里有一条非常重要的建议在第一次迁移之前就决定是否使用自定义用户模型。默认的User模型只带username、password、email这几个字段对于这个项目完全不够用——设计师需要头像、个人简介、擅长方向客户需要公司名称、联系方式。等数据库迁移跑完再想改用户模型那滋味我试过极其酸爽牵一发动全身。正确做法是继承AbstractUser只需要在app启动前完成配置。以users这个app为例第一步在settings.py里声明# settings.py AUTH_USER_MODEL users.CustomUser然后在users/models.py里定义from django.contrib.auth.models import AbstractUser from django.db import models class CustomUser(AbstractUser): USER_TYPE_CHOICES [ (designer, 设计师), (client, 客户), (admin, 管理员), ] user_type models.CharField( 用户类型, max_length20, choicesUSER_TYPE_CHOICES, defaultclient ) phone models.CharField(手机号, max_length20, blankTrue) avatar models.ImageField(头像, upload_toavatars/, blankTrue, nullTrue) bio models.TextField(个人简介, blankTrue) company models.CharField(公司名称, max_length200, blankTrue) is_approved models.BooleanField(是否通过审核, defaultFalse) class Meta: verbose_name 用户 verbose_name_plural 用户这里有个细节容易被忽视设计师账号建议加一个is_approved字段由管理员审核通过后才允许接单。原因很简单广告行业直接对接客户如果什么人都能注册成设计师接需求那平台上的需求单就会被乱七八糟的人污染最后客户流失网站信誉受损。审核机制虽然简单但能挡掉80%的垃圾注册。2.2 核心模型广告类别、项目、需求单用户模型定好后接着处理业务核心。广告策划这个行业有个特点项目类型杂、交付形态多、客户描述需求非常不规范。所以数据建模时一定要把“类别”和“需求单”做成独立模型避免在项目表里堆字符串字段。广告类别用一张简单的表维护方便后台随时增删class Category(models.Model): name models.CharField(分类名称, max_length50) slug models.SlugField(URL标识, uniqueTrue) description models.TextField(分类说明, blankTrue) sort_order models.IntegerField(排序权重, default0) class Meta: ordering [sort_order, id]项目表是核心承载整个业务流转class Project(models.Model): STATUS_CHOICES [ (pending, 待认领), (in_progress, 制作中), (review, 待确认), (revision, 修改中), (completed, 已完成), (cancelled, 已取消), ] title models.CharField(项目名称, max_length200) category models.ForeignKey(Category, on_deletemodels.PROTECT, verbose_name广告类别) description models.TextField(项目描述) budget_min models.DecimalField(预算下限, max_digits10, decimal_places2, nullTrue, blankTrue) budget_max models.DecimalField(预算上限, max_digits10, decimal_places2, nullTrue, blankTrue) deadline models.DateField(期望交付日期) cover_image models.ImageField(封面图, upload_toproject_covers/, blankTrue, nullTrue) status models.CharField(项目状态, max_length20, choicesSTATUS_CHOICES, defaultpending) client models.ForeignKey( settings.AUTH_USER_MODEL, on_deletemodels.CASCADE, related_nameclient_projects, verbose_name客户 ) designer models.ForeignKey( settings.AUTH_USER_MODEL, on_deletemodels.SET_NULL, related_namedesigner_projects, nullTrue, blankTrue, verbose_name负责设计师 ) created_at models.DateTimeField(创建时间, auto_now_addTrue) updated_at models.DateTimeField(更新时间, auto_nowTrue) class Meta: ordering [-created_at]几个字段选择的讲究点值得展开说。状态字段用CharField加choices而不是在MySQL里建枚举类型这是Django社区的主流实践方便以后往状态表里加新状态而不需要改数据库结构。外键关系里的related_name一定要显式设置否则Django默认生成project_set这种名字两个外键指向同一个用户模型时就会打架。我这里把客户的关联命名为client_projects设计师的关联命名为designer_projects查询时一眼就能看出语义差异。on_delete的行为也直接用上了。项目被删时连带删除关联需求单是合理的CASCADE但负责设计师被删时项目本身不能跟着消失用SET_NULL把关联置空就好项目还能被管理员重新分配。2.3 外键关系和反向查询的命名规范数据模型里第二个容易踩坑的地方是模型之间的引用方式。建议把外键、多对多关系全部收拢在一个页面里核对清楚否则后期写查询时会被一连串的_set搞崩溃。举个例子需求单Requirement挂在项目下但又独立于项目模型class Requirement(models.Model): project models.ForeignKey(Project, on_deletemodels.CASCADE, related_namerequirements, verbose_name所属项目) deliverable_type models.CharField( 交付物类型, max_length30, choices[ (proposal, 提案PPT), (creative, 创意平面图), (copy, 广告文案), (video_script, 视频脚本), (social_post, 新媒体推文), ] ) creative_brief models.TextField(创意方向说明) reference_images models.JSONField(参考图链接列表, defaultlist, blankTrue) created_at models.DateTimeField(auto_now_addTrue)这里单独提一下reference_images用JSONField而不是再建一张图像表。参考图本质上就是一组URL没有独立的业务行为用JSON数组存在同一个需求单里查询时一次就能取完避免为这么轻量的数据额外多做一对多关联。等真的需要对单张图片做单独的处理记录比如反馈意见再拆表也不迟。反向查询命名规范是我的血泪教训。所有外键都要显式写related_name且命名格式统一为“从父到子”的语义。比如需求单里的related_namerequirements那么从Project查需求单就是project.requirements.all()。以前偷懒没写结果代码里到处是project.requirement_set.all()时间一长根本分不清哪个project对应哪条链路的_set。3. 核心业务链路从需求发布到项目交付3.1 需求单创建把客户的模糊需求结构化业务链路的起点是客户创建需求单。广告行业的客户往往说不清自己到底要什么他们只会说“我要一个高端大气上档次的海报”。所以需求表单设计得越结构化后面对接设计的沟通成本就越低。我在表单里设计了一组必填项和一个选填项必填项目名称、选择广告类型、项目描述、期望交付日期选填预算区间这步做成滑块区间选择、参考图上传视图层用Django的CreateView配合自定义表单封装# forms.py class RequirementForm(forms.ModelForm): class Meta: model Project fields [title, category, description, budget_min, budget_max, deadline, cover_image] widgets { deadline: forms.DateInput(attrs{type: date}), } def clean_deadline(self): deadline self.cleaned_data[deadline] if deadline timezone.now().date() timedelta(days3): raise forms.ValidationError(交付日期至少需要留出3天时间) return deadline这里设置clean_deadline校验的意义在于广告项目最短也要走完“需求理解 — 初步创意脑暴 — 初稿设计 — 修改反馈”的流程3天是底线。如果不加这个限制第二天就要求交稿的需求单进来设计师接单时只会觉得平台不专业。表单校验过后在视图里手动把当前登录用户写入client字段class RequirementCreateView(LoginRequiredMixin, CreateView): model Project form_class RequirementForm template_name projects/requirement_form.html def form_valid(self, form): form.instance.client self.request.user form.instance.status pending return super().form_valid(form) def get_success_url(self): return reverse(project_detail, kwargs{pk: self.object.pk})3.2 设计师接单并发与状态流转的处理客户提交需求后需求单进入“待认领”状态。设计师在需求池里看单、认领。这个环节最容易出问题的是并发两个设计师同时点“认领”同一单不能两个都成功。Django默认的get_object_or_404加save()做不到原子性。正确做法是用select_for_update加事务锁from django.db import transaction login_required def claim_project(request, pk): if not request.user.user_type designer: return HttpResponseForbidden(只有设计师可以认领项目) with transaction.atomic(): project get_object_or_404( Project.objects.select_for_update(), pkpk, statuspending ) project.designer request.user project.status in_progress project.save() # 此处发送站内通知给客户 return redirect(project_detail, pkproject.pk)select_for_update会在数据库层锁定这行记录直到事务结束。另一个请求进来拿锁时只能等待锁释放后重新读取发现状态已经不是pending自然就进不了认领逻辑。这个细节在单机开发时感觉不到一上线同时在线几十个设计师抢单不处理并发就会出双负责人事故。关于状态流转再多说两句。项目状态从pending到completed中间至少要经过in_progress、review、revision这几个节点。状态变更时最好留痕方便事后追溯“这个项目什么时候改过稿、谁改的”。我建了一张简单的日志表记录状态变化class ProjectLog(models.Model): project models.ForeignKey(Project, on_deletemodels.CASCADE, related_namelogs) operator models.ForeignKey(settings.AUTH_USER_MODEL, on_deletemodels.SET_NULL, nullTrue) from_status models.CharField(max_length20) to_status models.CharField(max_length20) note models.TextField(备注, blankTrue) created_at models.DateTimeField(auto_now_addTrue)在每次状态变更时随手写一条日志。后面做数据统计、纠纷回溯、客户投诉调查时这张表就是完整的审计线索。一个广告项目纠纷的仲裁全靠日志说话。3.3 交付环节文件管理与版本记录项目到了交付阶段设计师需要上传交付文件。广告行业的交付文件有个特点文件大、版本多、命名混乱。客户昨天说“还是用第一版吧”如果文件管理没做好设计师要找半天哪个是第一版。交付文件用独立模型管理挂在项目下class DeliveryFile(models.Model): project models.ForeignKey(Project, on_deletemodels.CASCADE, related_namedelivery_files) version models.CharField(版本号, max_length20, defaultv1) file models.FileField(交付文件, upload_todeliveries/%Y/%m/) description models.TextField(版本说明, blankTrue) uploaded_by models.ForeignKey(settings.AUTH_USER_MODEL, on_deletemodels.SET_NULL, nullTrue) created_at models.DateTimeField(auto_now_addTrue)上传时强制要求填版本号和版本说明从制度上倒逼设计师规范化命名。注意upload_to按年月分目录存放文件量大了以后目录结构就是天然的按时间归档后续做冷备份、定期清理都比一锅粥强。文件上传的安全控制放在本章第五节单独说这里只提醒一点客户上传参考图、设计师上传交付文件这两类文件的可信度完全不一样。参考图泄露最多是客户自己的创意泄露交付文件一旦被恶意上传可执行文件服务器安全直接告急。交付文件目录要加一层隔离通过URL直接访问时用Django的视图做权限校验不能把静态文件裸奔在MEDIA_URL下。3.4 权限控制页面级的三类角色防线权限控制要做到页面级别而不是只靠模板里藏按钮。我的做法是装饰器加Mixin双管齐下。对于函数视图用自定义装饰器def require_user_type(allowed_types): def decorator(view_func): wraps(view_func) def wrapper(request, *args, **kwargs): user request.user if not user.is_authenticated: return redirect(login) if user.user_type not in allowed_types: return HttpResponseForbidden(无权访问该页面) return view_func(request, *args, **kwargs) return wrapper return decorator require_user_type([designer, admin]) def designer_dashboard(request): ...对于类视图继承派生的Mixinclass DesignerRequiredMixin(LoginRequiredMixin, UserPassesTestMixin): def test_func(self): return self.request.user.user_type in [designer, admin]注意列表页和详情页还要做对象级权限过滤。客户登录后只能看到自己创建的项目。设计师只能看到自己参与的项目。这两个条件都满足不了细节权限时管理员的is_staff天然支撑后台访问。三层角色权限体系搭起来后基本不需要再额外写“谁能看谁不能看”的散装判断。还有一个容易被忽略的点模板里也要配合权限做按钮级控制。Django模板里可以直接判断user.is_authenticated和user.user_type注意别把逻辑写重了。视图层负责安全模板层只负责展示安全逻辑绝不能在模板里兜底。4. 用户交互细节模板渲染、图片优化与上传安全4.1 设计师作品集的图片压缩与缩略图广告网站是重图片的行业设计师的作品图动辄几兆甚至几十兆原图直接丢给浏览器加载页面卡成幻灯片是必然的。解决这个问题的标准做法是“原图入库展示用压缩图”。用Pillow配合Django的ImageField可以做上传时压缩from PIL import Image from io import BytesIO from django.core.files.base import ContentFile def compress_image(uploaded_image, max_width1600, quality85): img Image.open(uploaded_image) img_format img.format if hasattr(img, format) else JPEG if img.width max_width: ratio max_width / img.width new_height int(img.height * ratio) img img.resize((max_width, new_height), Image.LANCZOS) buffer BytesIO() if img_format in (JPEG, JPG): img.save(buffer, formatJPEG, qualityquality, optimizeTrue) else: img.save(buffer, formatPNG, optimizeTrue) return ContentFile(buffer.getvalue())调用时机在表单的clean_cover_image或save()方法里上传成功后压缩再入库。压缩质量和宽度的取舍看网站定位案例展示页用宽1600px、质量85%足够覆盖4K屏幕的高清展示需求列表页的缩略图额外用sorl-thumbnail或easy-thumbnails生成300px的小图。这里提醒一句Pillow处理PNG透明背景图时要小心直接转JPEG会把透明区域变成黑底。处理方案是判断格式透明PNG走PNG通道压缩只压缩尺寸不强制转格式。4.2 文件上传的安全白名单文件上传是整个项目安全的最薄弱环节尤其这种开放注册、多方上传的站点。我的上传校验清单如下文件扩展名白名单.jpg .jpeg .png .gif .pdf .zip .ai .psd广告行业常用的源文件格式全放进来content_type二次校验后端读取file.content_type与扩展名对应上才放行文件大小上限参考图单张不超过10MB交付文件单次总大小不超过100MB文件名统一重写用uuid4().hex 原扩展名生成新文件名彻底避免“客户报价表.exe”这类危险命名和中文路径问题import uuid import os def generate_upload_path(instance, filename): ext os.path.splitext(filename)[1].lower() safe_name f{uuid.uuid4().hex}{ext} return fuploads/{instance.__class__.__name__}/{safe_name}在models.py里对应字段设置upload_togenerate_upload_path即可。这套方案上线到现在没有一次因为文件名特殊字符或中文路径触发服务器异常。对文件类型也要保持清醒认知扩展名和content_type都可以被伪造真正安全要求高的时候还要读文件头做魔数校验。广告策划网站不必做到银行级安全但扩展名加content_type双重校验至少能挡掉99%的脚本小子上传这就够了。4.3 模板层的展示策略模板渲染这块我坚持服务端渲染加少量JavaScript增强没有上复杂的前端框架。原因很实际网站的主要访问者是甲方客户和设计师他们用手机、平板、笔记本各种设备看案例首屏加载速度比花哨的交互效果重要得多。案例列表页的核心结构就是一个响应式卡片网格div classcase-grid {% for project in projects %} a href{% url project_detail project.pk %} classcase-card img src{{ project.cover_thumbnail.url }} loadinglazy alt{{ project.title }} h3{{ project.title }}/h3 p{{ project.category.name }}/p /a {% empty %} p暂无案例展示/p {% endfor %} /div关键是那个loadinglazy。列表页一次性渲染几十个卡片每个卡片一张压缩图加上懒加载首屏只需要加载视口范围内的两三张图流量和加载速度都友好很多。详情页的结构是图文混排项目背景描述、创意说明、交付物预览、设计师信息、底部再来一个“同类案例”推荐。广告行业的客户看案例时最关注“这个团队有没有做过我所在行业的东西”所以详情页必须有分类标签和同类推荐模块否则客户要点好几层才能对比不同团队的作品。模板里还可以自定义过滤器来美化展示。比如预算区间、状态字段的中文展示register.filter(nameproject_status) def project_status_display(status): mapping dict(Project.STATUS_CHOICES) return mapping.get(status, status)这类小工具按需补充就好不用一次性全部实现。5. 典型问题排查与性能优化实录5.1 开发环境配置Python版本、依赖安装与数据库选型整个项目开发下来第一道坎其实是环境安装。热搜词里大量出现“python安装”“django安装不成功”这类问题不是没原因的。这套项目我推荐的环境组合是Python 3.10或3.11加Django 4.2 LTS理由很简单LTS版本有长期的官方安全维护3.10以上的Python对类型提示和异步支持都更成熟。开发环境用SQLite起步完全没问题但要明确这只是权宜之计。SQLite和PostgreSQL在on_delete外键行为、并发支持、JSONField处理上都有差异上线前一周再切换数据库容易手忙脚乱不如一开始就用PostgreSQL开发。依赖安装建议一次性装齐pip install django4.2.* pillow psycopg2-binary python-decouplepsycopg2-binary直接下载编译好的二进制包省去本机编译的麻烦。Windows机器上特别容易卡在这一步。5.2 CSRF 403错误的坑与排查Django的CSRF机制是安全防线但新手经常被403卡得莫名其妙。有三类高频情况模板表单里漏了{% csrf_token %}POST请求直接403。这是最基础也最常见的。AJAX POST请求需要手动带CSRF Token获取方式和跨域无关用Django提供的getCookie方法读取csrftokenCookie再放进请求头。核心是JS里要取到Cookie里的token值然后设置X-CSRFToken请求头。子域名部署时CSRF_COOKIE_DOMAIN和CSRF_TRUSTED_ORIGINS没配置跨子域POST必挂。遇到403时最快排查路径是先看请求头里有没有X-CSRFToken再看Cookie里有没有csrftoken然后确认页面模板有没有渲染{% csrf_token %}。这三个点按顺序查五分钟内必定位问题。5.3 ORM查询优化select_related和prefetch_related的判断这个项目的ORM性能优化核心就是搞清楚什么时候用select_related什么时候用prefetch_related。两者都是减少SQL查询次数但适用条件完全不同。select_related用于“单值关系”的外键和一对一。比如渲染项目列表时要显示每条项目对应的分类名和客户名这就是典型的跨外键访问。用默认查询每渲染一条项目记录都会额外执行一次分类查询和一次客户查询列表渲染30条就要多跑60条SQL。加上select_related(category, client)后一条查询用JOIN全部取回。prefetch_related用于“多值关系”的反向外键和多对多。比如渲染项目详情时要显示这个项目的所有交付文件和所有需求单。这种情况用JOIN反而会造成结果集爆炸一条SQL JOIN两个关联表笛卡尔积能把数据量放大几倍数据库和内存都吃不消。正确姿势是project Project.objects.prefetch_related( delivery_files, requirements, logs ).get(pkpk)Django会先查项目再分别查交付文件、需求单、日志总共4条SQL代码里却完全不需要手动处理数据组装。5.4 分页与数据库查询量控制案例列表、需求池、项目列表这些页面数据量上来后必须分页。我用Django内置的Paginatorfrom django.core.paginator import Paginator def project_list(request): projects Project.objects.filter(is_publishedTrue).select_related(category, designer) paginator Paginator(projects, 12) page_number request.GET.get(page) page_obj paginator.get_page(page_number) return render(request, projects/list.html, {page_obj: page_obj})模板里用page_obj做上一页下一页控制核心是paginator分页要配合select_related做预加载否则数据库并发一大就是满屏的N1查询。5.5 整体性能体检清单项目上线前我做了几轮性能摸排几个关键指标值得关注数据库查询次数用django-debug-toolbar查看每个页面渲染的SQL次数。案例详情页如果超过20条SQL优先检查漏配的select_related和prefetch_related。图片体积列表页单张图超过200KB就算压缩策略没过关。后期可以用srcset做响应式图片根据设备宽度加载不同分辨率的版本。缓存策略案例列表这类静态数据较多的页面用Django的cache_page做全页缓存TTL设置5分钟能极大减轻数据库压力。广告网站的素材数据变更频率不高每5分钟刷新一次完全能满足业务需求。页面响应时间开发模式下用django-debug-toolbar看时间损耗核心场景列表页、详情页目标控制在500ms以内达不到就往缓存和查询优化方向排查。这些优化做完以后站点在中等配置的云服务器上撑住一千个并发访问是可行的广告策划网站的访问量特性早上客户上班高峰期、下午看稿高峰期完全能覆盖。在这套项目做完之后最让我有感的其实不是写完了多少行Python代码而是Django把内容管理、权限、安全这些Web开发里最繁琐的基建工程收敛成了一套清晰的结构让我能把精力全部集中在“广告需求对接”和“案例展示”这两个真正有业务价值的部分上。如果你打算自己复刻这个项目我建议从用户模型和项目模型入手先把数据结构和业务链路想清楚再往下写视图和模板方向对了后面基本不会有大返工。