☰
Django校园活动报名系统开发实战:并发安全与权限控制详解
2026/10/13 18:44:16 网站建设 项目流程

每年开学季,社团招新、讲座报名、文体活动接龙,都是校园里最让人头大的事。我见过太多社团负责人还在用共享Excel表格统计报名,一人填错覆盖全表、活动前夜在群里挨个核对名单、名额超了要手动劝退都是常态。后来我用Django给这类场景搭了一套校园社团活动报名管理系统,把发布活动、线上报名、名额控制、名单导出、消息通知这一整条链路一次理顺。这篇文章把整个过程拆开讲,从需求分析到数据建模,再到并发安全的报名流程和上线部署,适合想用Django练手完整Web系统的开发者,也适合需要自己搭管理平台的社团骨干。

1. 需求复盘:校园活动报名的真实痛点与功能边界

1.1 传统报名方式为什么撑不住

先说痛点,因为很多人一开始根本没意识到这是个需要做系统的场景。拿某高校的情况举例:一个300人名额的讲座,干事提前三天发共享表格,第一天就有五六十人同时在线编辑。手机端打开表格格式全乱,有人把自己的信息填到别人行里,有人填完又删,还有人直接覆盖了整列公式。等到报名截止那天,干事想导出名单,发现中间缺了二十多个人,只能去微信群里重新接龙补录。

微信接龙更不用提了,纯粹是流水账。几百条消息里混着报名、取消、改备注、重复报名的内容,真正要统计的时候得一条条翻,翻完还要担心漏看。问卷星这类表单工具收集数据方便,但管理端非常弱:有没有超员不知道,同一个人重复提交要不要去重,活动当天怎么快速比对签到,基本都要靠手工处理。

这些场景叠加起来,核心问题不是"缺一个填表工具",而是缺一个能承载完整报名流程的管理系统:活动发布、名额控制、报名状态记录、名单导出、签到核销,最好还有消息通知。搞清楚这一点,系统的功能边界就清楚了。

1.2 三类角色与核心流程

画一下系统的使用者,会发现其实就三类人。

学生端:登录系统 → 浏览活动列表 → 查看活动详情 → 点击报名 → 等待确认结果 → 收到通知 → 活动当天签到。整个路径很短,但对流畅度要求高,尤其是移动端页面。

社团干事端:创建活动 → 设置时间、地点、名额、报名截止时间 → 审核报名名单(如果开启审核制) → 报名结束后导出名单 → 活动当天扫码或手动签到 → 查看活动数据。干事的操作频次不高,但每次操作都在活动前的中后期集中爆发。

管理员端:审核社团入驻 → 管理用户 → 查看全局数据 → 发布公告。管理员是权限最高但操作最少的一类,往往只需要通过后台界面偶尔看一眼。

三类角色的核心流程梳理完,数据模型和接口设计就有底了。学生关注的是活动本身,干事关注的是活动与报名的关系,管理员关注的是社团和总览。后面所有设计都围绕这三条线展开。

1.3 MVP的功能边界取舍

做这类系统最容易犯的错,就是一上来就想把所有功能做完。我当时的做法是把需求分成两期。

第一期只做核心链路:用户注册登录、社团管理、活动发布、在线报名、取消报名、名额控制、名单导出、内置后台管理。这一期解决的是"报名不乱"这个根本问题。

第二期再考虑:候补队列、邮件通知、签到码核销、数据统计报表、公告推送。这些功能有价值,但没有它们系统也能跑,而且它们会显著增加开发和测试成本。

直接砍掉的功能包括:在线支付(校园社团活动几乎不涉及收费)、复杂审批流(社团活动不需要多级审批)、实时聊天(不是这个系统的定位)。每次有人建议加功能,我都会反问一句:不做这个功能,报名流程会乱吗?如果不会,那就先不做。

2. 为什么选Django:技术选型的思考过程

2.1 比了一圈之后的结论

做技术选型的时候,我认真把主流的方案过了一遍。

方案优势劣势结论
Django全栈框架,自带Admin后台、ORM、用户认证相对重,模板语言有自己的学习成本最推荐
Flask轻量灵活,组合自由认证、Admin、ORM都要自己拼装适合高手,不适合快速交付
Spring Boot企业级能力完善太重,Java人力要求高校园项目没必要
云开发/低代码平台上线最快定制受限,数据导出和权限控制容易被卡可以做原型,不适合长期用

最后选Django,理由很实际:这个系统的核心需求,几乎都是Django的开箱能力。用户认证和权限分组,Django自带;活动、报名这些模型的增删改查,ORM和Admin后台直接覆盖;CSRF防护、SQL参数化、XSS转义这些安全默认值,不用自己操心。团队只需要是Pythoner,就能很快接手。

2.2 Django自带的"隐形生产力"

很多人低估了Django内置后台的生产力,我举个具体例子。在系统一期里,管理员端几乎完全没有单独开发前端页面,就是靠Django Admin做的几个简单注册配置。社团管理、用户管理、活动管理全部在Admin里完成,改改list_display、加加filter就能当正经管理后台用。

类似的隐形生产力还有:

  • 用户认证:login、logout、session、password reset,全部开箱即用。
  • ORM迁移:模型改完直接makemigrations,数据库同步几乎没有手工操作。
  • 表单系统:Form和ModelForm能同时处理渲染和校验,报名表单用一套代码就搞定。
  • 安全默认值:CSRF中间件默认开启,模板变量默认转义,查询用ORM天然参数化。
  • 单元测试:Django test client在不用真实浏览器的情况下,就可以完成接口和页面的回归测试。

对于校园场景这种需要快速交付、后续又要长期维护的项目,这些默认能力能省下大量开发时间。

2.3 初始化项目与目录结构

项目搭建我比较推荐用虚拟环境加应用拆分的模式。首先创建虚拟环境并安装Django:

python -m venv venv source venv/bin/activate pip install django django-admin startproject campus_activity cd campus_activity python manage.py startapp accounts python manage.py startapp clubs python manage.py startapp activities python manage.py startapp enrollments python manage.py startapp notifications

我习惯把应用按领域拆,而不是按页面拆。这里的目录结构是这样:

campus_activity/ ├── manage.py ├── config/ # 项目配置 │ ├── settings.py │ ├── urls.py │ └── wsgi.py ├── apps/ │ ├── accounts/ # 用户注册登录、个人中心 │ ├── clubs/ # 社团信息、成员关系 │ ├── activities/ # 活动发布与管理 │ ├── enrollments/ # 报名记录与配额控制 │ └── notifications/ # 站内与邮件通知

每个应用只负责一个业务域,后续加功能不会演化成一个大杂烩模块。settings里的INSTALLED_APPS按这个顺序注册好,基础骨架就完成了。

3. 数据模型设计:活动、社团、报名三者关系梳理

3.1 核心模型完整定义

数据模型是整个系统最值得花时间的地方,因为报名系统的核心关系就三类:社团、活动、报名。我直接给出当时最终的模型设计。

自定义用户模型相关的内容放在后面权限章节,这里先看业务模型的核心部分:

from django.db import models from django.conf import settings class Club(models.Model): """社团基本信息""" name = models.CharField("社团名称", max_length=50, unique=True) description = models.TextField("社团简介", blank=True) owner = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="owned_clubs", verbose_name="负责人", ) members = models.ManyToManyField( settings.AUTH_USER_MODEL, through="ClubMembership", related_name="joined_clubs", verbose_name="成员", ) created_at = models.DateTimeField("创建时间", auto_now_add=True) def __str__(self): return self.name class ClubMembership(models.Model): """社团成员关系,记录加入时间与角色""" club = models.ForeignKey(Club, on_delete=models.CASCADE) user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) joined_at = models.DateTimeField(auto_now_add=True) is_manager = models.BooleanField("是否社团管理员", default=False) class Meta: unique_together = ("club", "user") class Activity(models.Model): """活动:由某个社团发起,包含名额和时间""" club = models.ForeignKey(Club, on_delete=models.CASCADE, related_name="activities") title = models.CharField("活动标题", max_length=100) content = models.TextField("活动详情") location = models.CharField("活动地点", max_length=100) start_time = models.DateTimeField("开始时间") end_time = models.DateTimeField("结束时间") enroll_deadline = models.DateTimeField("报名截止时间") quota = models.PositiveIntegerField("报名名额", default=30) need_review = models.BooleanField("是否开启审核", default=False) enrollment_count = models.PositiveIntegerField("已报名人数", default=0) created_at = models.DateTimeField("创建时间", auto_now_add=True) def __str__(self): return self.title class Enrollment(models.Model): """报名记录,连接用户和活动""" STATUS = [ ("pending", "待审核"), ("confirmed", "已确认"), ("canceled", "已取消"), ("rejected", "已拒绝"), ("attended", "已签到"), ] user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="enrollments") activity = models.ForeignKey(Activity, on_delete=models.CASCADE, related_name="enrollments") status = models.CharField("状态", max_length=10, choices=STATUS, default="confirmed") remark = models.CharField("备注", max_length=200, blank=True) created_at = models.DateTimeField("报名时间", auto_now_add=True) class Meta: unique_together = ("user", "activity")

注意几个关键设计决策。

第一,Club和User的成员关系用了中间表ClubMembership,而不是直接ManyToMany。这样可以在关系上额外存is_manager字段,方便识别谁是社团管理员,后续扩展社团职务也很容易。

第二,Enrollment加了unique_together,保证同一个人不能重复报名同一活动,这在应用层之外又加了一层数据兜底。

第三,Activity上的enrollment_count字段看起来有冗余,但它对并发控制和高频计数非常有用。如果每次都count,查询量一大就很难受;配合事务锁,这个冗余字段能保证报名数据准确。

3.2 报名状态流转设计

报名不是只有"报上了"和"没报上"两种状态,实际场景要复杂得多。我当时设计了五种状态:

状态含义触发时机
pending待审核提交报名且活动开启审核制
confirmed已确认无需审核直接通过,或干事审核通过
canceled已取消用户主动取消
rejected已拒绝干事审核不通过
attended已签到活动当天核销

为什么要用状态字段而不用直接删除记录?太重要了。一是保留审计痕迹,出问题能查是谁什么时候报的名;二是取消后可以候补回补,没有历史记录根本没法做;三是活动结束后要统计实际到场率,必须区分"报过名但没来"和"压根没报名"。

3.3 配额与候补机制

配额就是Activity.quota,这个没啥好说。真正需要多想一步的是候补机制。

当时的需求是:名额满了之后,学生仍然可以报名,但状态标记为waiting,一旦前面有人取消,就按报名时间顺序自动替补。实现上不需要额外建表,在Enrollment里加一个状态"waiting"即可,判断逻辑也很简单:报名时如果当前enrollment_count小于quota就确认,否则进入候补。有人取消时,把最早的waiting记录改成confirmed,同时补上名额。这一段逻辑要放到事务里执行,前后台都要保证数据一致。

4. 用户认证与权限控制:三种角色如何分级管理

4.1 自定义用户模型

Django项目一开始就应该用AbstractUser自定义用户模型,哪怕刚开始不需要额外字段。原因很现实:一旦系统上线有了真实用户数据,再想改用户模型,迁移会非常痛苦。我见过一些人图省事直接用默认User,后面要加学号、手机号字段时只能另建Profile表,查询和关联都绕了一圈。

我的做法是:

from django.contrib.auth.models import AbstractUser from django.db import models class User(AbstractUser): """自定义用户模型""" ROLE_CHOICES = [ ("student", "学生"), ("club_staff", "社团干事"), ("admin", "管理员"), ] student_id = models.CharField("学号", max_length=20, blank=True) phone = models.CharField("手机号", max_length=11, blank=True) role = models.CharField("角色", max_length=20, choices=ROLE_CHOICES, default="student") @property def is_club_staff(self): return self.role == "club_staff" @property def is_admin_user(self): return self.role == "admin"

settings里设置AUTH_USER_MODEL = "accounts.User"。记住必须在第一次migrate之前就配好,这是铁律。

4.2 角色的实现方式

角色的实现有三种常见方案,我直接说取舍。

方案一是Django自带的Group和Permission,适合权限粒度细、角色多的场景,但配置繁琐。方案二是多用户表继承,看起来很清晰,但查询时容易出幺蛾子,不推荐。方案三就是我用的User.role字段,二三个角色时最直白,配合@property方法,视图和模板里都能直接判断。

实际开发中,权限控制不光要判断"是不是干事",还要判断"这是不是自己社团的活动"。所以干事端的所有视图都要双重校验:一是角色,二是资源归属。

4.3 视图层权限保护实例

看一个典型的活动管理视图:

from django.contrib.auth.decorators import login_required from django.core.exceptions import PermissionDenied from django.shortcuts import get_object_or_404 def club_staff_required(view_func): """自定义装饰器:必须是社团干事""" @login_required def wrapper(request, *args, **kwargs): if not request.user.is_club_staff: raise PermissionDenied return view_func(request, *args, **kwargs) return wrapper @club_staff_required def manage_activity(request, activity_id): activity = get_object_or_404(Activity, pk=activity_id) # 干事只能管理自己社团的活动 if activity.club.owner_id != request.user.id: raise PermissionDenied # 正常处理业务逻辑 ...

这里的关键是第二层判断:activity.club.owner_id != request.user.id。没有这层判断,一个干事只要猜出活动ID就能管理别的社团的活动。所有涉及社团资源修改的接口,都必须带着资源归属校验一起做。

5. 报名核心流程:并发安全、取消与签到

5.1 报名请求可能遇到的并发坑

报名是整系统里最需要抠细节的功能,因为它天然有并发属性。活动剩余最后一个名额时,两个人同时点报名,如果代码是"先查一下有没有满,没满就插入",两个请求同时查到剩余1个名额,然后各自插入一条记录,活动就超员了。这跟秒杀超卖是同一个问题。

规避方式是用事务加行锁:

from django.db import transaction from django.db.models import F @transaction.atomic def enroll(request, activity_id): # 锁住活动行,防止并发修改 activity = Activity.objects.select_for_update().get(pk=activity_id) if activity.enrollment_count >= activity.quota: # 名额已满,可以转入候补或直接返回错误 return error_response("报名人数已满") enrollment, created = Enrollment.objects.get_or_create( user=request.user, activity=activity, defaults={"status": "confirmed"}, ) if not created: return error_response("请勿重复报名") activity.enrollment_count = F("enrollment_count") + 1 activity.save(update_fields=["enrollment_count"]) return success_response("报名成功")

select_for_update会在数据库层面锁定这一行,其他事务必须等锁释放才能继续读或写。这样即使两个请求同时进来,第二个请求在锁释放后看到的enrollment_count已经是最新的,不会超员。

再说说为什么我用了enrollment_count而不是直接count。如果报名后都去count一遍,在事务里会加额外的锁和查询开销,而且count要遍历索引,数据量上来会拖慢报名接口。用冗余字段加F表达式,在单条UPDATE语句里原子自增,效率和正确性都更好。

5.2 取消报名与名额回补

取消报名看起来很简单,改个状态就行,但它的正确性同样依赖事务。

from django.db import transaction @transaction.atomic def cancel_enrollment(request, enrollment_id): enrollment = Enrollment.objects.select_for_update().get( pk=enrollment_id, user=request.user ) if enrollment.status in ("canceled", "rejected"): return error_response("当前状态不可取消") enrollment.status = "canceled" enrollment.save(update_fields=["status", "updated_at"]) activity = Activity.objects.select_for_update().get(pk=enrollment.activity_id) # 只有已确认/已签到才算占用名额,取消候补不需要回补名额 if enrollment.status_was_occupying_quota: activity.enrollment_count = F("enrollment_count") - 1 activity.save(update_fields=["enrollment_count"]) # 如果开启了候补,把最早的候补记录顶上来 promote_waiting_enrollment(activity) return success_response("已取消")

这里有个容易忽略的点:取消候补名额不应该减少enrollment_count,因为候补本来就不占名额。我把"是否占名额"的判断封装在原来的状态检查里,减少出错概率。回补候补时也要用事务,防止同一候补被两个取消操作同时顶上。

5.3 签到与名单导出

活动当天,干事登录系统进入活动详情页,可以逐个标记签到,也可以用活动签到码让学生自助确认。原理都一样:把Enrollment状态从confirmed改成attended。

名单导出用CSV最简单,不需要额外依赖,Excel也能直接打开:

import csv from django.http import HttpResponse def export_enrollments(request, activity_id): activity = get_object_or_404(Activity, pk=activity_id) enrollments = activity.enrollments.select_related("user").filter( status__in=["confirmed", "attended"] ) response = HttpResponse(content_type="text/csv; charset=utf-8") response["Content-Disposition"] = f'attachment; filename="activity_{activity.id}.csv"' writer = csv.writer(response) writer.writerow(["姓名", "学号", "手机号", "状态", "报名时间", "备注"]) for item in enrollments: writer.writerow([ item.user.get_full_name(), item.user.student_id, item.user.phone, item.get_status_display(), item.created_at.strftime("%Y-%m-%d %H:%M"), item.remark, ]) return response

导出名单时加一列状态,比只导出报名成功的人有用得多。干事能看到多少人取消了、多少人待审核,核对的时候一目了然。

6. 消息通知设计与后台管理体验

6.1 通知触发时机

一个报名系统做不到完全无通知,否则学生报完名还要天天自己来查,体验太差。我当时把通知分为四类:

  • 报名成功/确认通知:无需审核时报名即通知,审核制时干事确认后通知。
  • 拒绝通知:审核不通过时说明理由。
  • 活动提醒:活动开始前一天,给所有confirmed状态的学生发提醒。
  • 候补转正通知:有人取消后,候补学生被顶上来时发通知。

通知的实现不需要引入消息队列,一个Notification模型加一条简单的发送函数就够了。关键在于把触发逻辑放在业务事务中,避免通知成功但业务失败,也不要业务成功但漏发通知。

模型很简单:

class Notification(models.Model): receiver = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="notifications" ) title = models.CharField("标题", max_length=100) content = models.TextField("内容") is_read = models.BooleanField("已读", default=False) created_at = models.DateTimeField("创建时间", auto_now_add=True)

学生端个人中心顶栏加一个红点,查询is_read=False的条数即可。发送的时候注意批量创建,一次活动确认几十个人,不要循环单条insert。

6.2 邮件通知

站内通知虽然够用,但很多学生不会天天登录系统,邮件是第二层触达。Django的send_mail配置很简单,settings里设置SMTP地址、端口、账号密码即可。我自己实际用的时候,对邮件做了节流:同一用户每天最多发三封,防止活动提醒和其他通知叠加导致被当成垃圾邮件。这个限制用一天内的发送记录count判断就行,不值得为此引入额外服务。

6.3 用Admin后台减少开发量

很多开发者在Django项目里把Admin当成鸡肋,其实对于这类系统,Admin就是干事和管理员的主操作台。

在admin.py里这样配置,就能撑起一个可用的管理界面:

from django.contrib import admin from .models import Activity, Enrollment @admin.register(Activity) class ActivityAdmin(admin.ModelAdmin): list_display = ["title", "club", "start_time", "quota", "enrollment_count"] list_filter = ["club", "start_time", "need_review"] search_fields = ["title", "club__name"] date_hierarchy = "start_time" @admin.register(Enrollment) class EnrollmentAdmin(admin.ModelAdmin): list_display = ["user", "activity", "status", "created_at"] list_filter = ["status", "activity__club"] search_fields = ["user__username", "user__student_id", "activity__title"]

需要注意一个细节:如果直接让所有干事都能访问Admin,他们就能看到所有数据,权限就失控了。解决方法是重写Admin的get_queryset,让干事只能看到自己社团的数据:

class ActivityAdmin(admin.ModelAdmin): def get_queryset(self, request): qs = super().get_queryset(request) if request.user.is_admin_user: return qs if request.user.is_club_staff: return qs.filter(club__owner=request.user) return qs.none()

同时给非管理员降低Admin权限。这样管理后台既省开发量,又不会变成数据泄露口。

7. 性能优化与部署上线:真实运营中的经验

7.1 列表页N+1查询

系统刚上线时活动列表页很慢,原因不是复杂查询,而是经典的N+1问题。Activity列表展示社团名称和报名人数,循环里每次访问activity.club就发起一次查询,100个活动就是101条SQL。解决办法是一句话的事:

activities = Activity.objects.select_related("club") \ .prefetch_related("enrollments") \ .order_by("-start_time")

select_related解决一对一和多对一的JOIN查询,prefetch_related解决多对多和反向一对多。模板里能少用属性就少用,尤其是过滤类属性。如果想显示每个活动的报名人数,建议直接用enrollment_count字段,而不是activity.enrollments.count(),后者在模板里很容易再触发N+1。

7.2 分页与列表性能

活动列表,控制在每页20条以内。用Django自带的Paginator,配合视图里的page参数,简单不折腾。搜索框也加上了,按活动标题过滤,底层就是icontains,数据量小时完全够用。

7.3 部署要点

部署这步是很多教程写得最模糊的,我踩过一次坑后再说说最稳的路线。

生产环境别用runserver,用gunicorn跑Django应用,前面挂nginx做反向代理和静态文件服务。我用whitenoise统一处理静态文件,省去配nginx静态目录的麻烦。

pip install gunicorn whitenoise # 迁移和静态文件收集 python manage.py migrate python manage.py collectstatic --noinput # 启动 gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers 3

settings里关键在于这几个配置:

DEBUG = False ALLOWED_HOSTS = ["your-domain.com"] SECRET_KEY = os.environ["DJANGO_SECRET_KEY"] CSRF_TRUSTED_ORIGINS = ["https://your-domain.com"]

SECRET_KEY一定不要写死在代码里,从环境变量读。DEBUG=False之后静态文件、跨域、CSRF各种问题都可能冒出来,所以上线前最好在类生产环境完整跑一遍流程。数据库方面,生产环境用PostgreSQL,本地开发再用SQLite都行。上线之后每天凌晨自动备份一次数据库,备份文件保留三十天,这个习惯能救命。

7.4 日常运营里的提醒

系统不是部署完就结束了。实际运营中最常见的故障,几乎都是"干事忘记关闭报名"导致的超员争议。我在Activity模型里加了一个逻辑:报名截止时间一到,活动自动关闭报名,不能再说忘了。其实就是在查询报名入口时判断一下当前时间是否在enroll_deadline之前,小于一分钟的成本,少了一整年的扯皮。

候补报名的邮件提醒,我最后也做成了定时任务。每天上午十点检查一次,把昨天新增的候补转正、活动临近等通知统一发送,避免通知轰炸。Django用django-cron或者系统的crontab都能跑,核心还是逻辑要收敛,别让定时任务搞出重复通知。

做这类校园系统,我的个人体会是:第一版一定要小,跑通一个学期,让真实用户把问题暴露出来,再迭代第二版。很多功能你以为学生需要,实际上他们根本不点;反而一些你没想到的细节,比如"导出名单时加上备注列"“报名截止前自动发提醒”,成了大家每天都要用的功能。如果现在让我重新做一遍,我会把候补队列和消息通知直接放进第一版,因为它们才是让报名系统真正"稳"住的关键。

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

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

立即咨询