“django-flask基于python的电竞赛事报名裁判管理系统”这个标题初看有点杂糅,两个Web框架同时出现,乍一看像“为了技术而技术”。但实际做完之后你会发现,这种组合并不是拍脑袋,而是被需求逼出来的。我最近帮一家赛事运营方把整个办赛流程从“微信群接龙+Excel登记”搬到这套系统上,从选手报名、裁判分配、赛程编排到计分统计,一口气跑通了。这篇文章就把这套系统的做法、框架分工、核心代码和踩过的坑,从里到外掰开讲一遍。无论你是打算自己搭一套赛事系统,还是正在纠结Django和Flask怎么共存,都能从这里找到能直接用的方案。
1. 项目定位:办赛方真正缺的不是“登记表”
1.1 办一场电竞赛事,到底在痛什么
先还原一下没有系统的时候,办赛方是怎么工作的。报名阶段通常是一张在线表格或者微信群接龙,选手自己填战队名、队员身份、联系方式,运营同学再手工复制到Excel里做资格审核。到了比赛日,签到环节靠喊名字,裁判分配靠经验“谁有空谁上”,计分靠纸质单子,赛后统计成绩又要人工录入。十几个人的小型赛事尚可撑住,一旦报名人数超过一百,或者分多个项目组别并行开赛,这套手工流程立刻就会崩。
更麻烦的是裁判管理。电竞裁判不像传统体育那样有严格的职业认证体系,但一样需要“懂规则、能执裁、无利益冲突”。同一个战队关系紧密的人不能当该队比赛的裁判,同一所高校或同一家网吧战队的人也要回避。这种事光靠主办方脑袋记不住,必须让系统来处理冲突关系。
所以这套“电竞赛事报名裁判管理系统”的定位就很清楚:它不是一个简单的报名表,而是把“报名—审核—编组—分配裁判—执裁计分—成绩汇总”这条完整链路全部接管。核心价值不是替代Excel,而是让多角色(选手、裁判、运营)在同一套数据上协作,避免信息错乱。
1.2 系统解决的几类核心问题
第一类问题是报名数据标准化。选手通过网页填写队伍信息、成员身份、联系方式,后端统一校验身份证格式、年龄范围、项目组别是否开放,重复报名在数据库唯一约束层面就被拦住。第二类问题是裁判工作的数字化。裁判登录后能看到自己被分配的场次、选手名单、需要回避的战队,评分直接在线录入。第三类问题是运营的数据可视化。所有报名进度、审核状态、赛程安排、成绩排名都在一个后台里呈现,随时可以导出报表。
从我实际跑完的赛程来看,这套系统最让我满意的一点是把“例外情况”的处理放进来了。比如比赛当天有人弃权,运营可以在后台一键调整对阵,把后续场次状态重新激活;裁判临时请假,可以重新分配裁判,系统会自动检查新的裁判是否与参赛队伍存在回避关系。这些“意外处理”能力才是赛事系统区别于普通报名系统的关键。
2. 技术选型:Django与Flask为什么同时存在
2.1 两种框架的分工逻辑不是“炫技”
先说结论:这个项目的管理主站用Django,面向赛场的高频接口用Flask,前面放一层Nginx按路径把请求分发到两个服务。Django负责重逻辑,包括数据建模、管理员后台、用户认证、报名审核、权限控制。Flask负责轻逻辑,比如选手检录时更新到场状态、裁判提交评分、前端轮询当前场次状态这些请求量不大但要求响应快、逻辑独立的接口。
为什么不让Flask也干全部,或者全部交给Django?因为Django的Admin后台和ORM生态太成熟,报名审核、裁判管理这类功能几乎可以靠框架自带能力快速搭建。但Django的“重”也体现在中间件链、CSRF校验、表单处理这些环节上,对于高频的JSON读写接口,有时候显得路径太长。Flask轻巧灵活,写一个纯API端点几行代码就能搞定,也不容易被Django的全局配置影响。两者配合,各取所长。
注意,两个框架不是部署在同一个进程里。它们各自用Gunicorn启动,Nginx做反向代理。
/manage、/admin、/register这些路径给Django,/api/venue、/api/score这些路径给Flask。对外看起来是一个系统,对内是两套服务。
2.2 数据层如何保持统一
框架可以分开,数据库绝对不能分家,否则数据同步会变成灾难。我的做法是:核心业务表全部由Django的ORM管理,使用Django Migration做版本控制。Flask服务只负责读取和写入这些表中的特定字段,通过SQLAlchemy连接同一个MySQL数据库。为了避免两个框架同时修改同一张表结构导致迁移冲突,Flask侧约定“只读写不建表”,所有结构变更都从Django侧发起。
有些独立的小表,比如“比赛日检录流水”“裁判评分暂存”,可以由Flask侧用SQLAlchemy建表。这些表的生命周期短、结构简单、与主业务表关联弱,不会引发迁移冲突。我建议把这类独立表统一加前缀,比如score_tmp_、checkin_log_,一眼就能看出归属权。
跨服务的写操作要特别注意事务边界。比如选手检录,Flask侧应该只更新registrations表里的checked_in字段,然后返回JSON给前端,至于这个报名对应的赛事是否已经锁定、比赛是否已经开始,状态校验逻辑放在Django侧做。如果两边的校验逻辑混在一起,出了问题很难排查。
2.3 核心数据模型怎么拆
这张表是整套系统的主干,设计好以后所有功能都不需要大改。
| 表名 | 关键字段 | 职责说明 |
|---|---|---|
| events | id, name, category, start_time, max_players, status | 赛事主表,一个赛事可包含多个项目组别 |
| players | id, user_id, real_name, id_card, phone, team_name | 选手基础资料,一张表关联用户体系 |
| registrations | id, event_id, player_id, status, apply_time, checked_in | 报名与检录状态表,核心流转都在这 |
| matches | id, event_id, round, player_a_id, player_b_id, referee_id, status, match_time | 比赛场次表,同时记录对阵双方与裁判 |
| referees | id, user_id, real_name, level, game_types, current_team | 裁判资料与擅长项目,用于自动分配 |
| scores | id, match_id, referee_id, player_id, score, submit_time | 裁判评分明细表,一场比赛可有多条记录 |
这里最关键的设计是matchs表里同时存了referee_id和两个选手的ID。这样“裁判与选手是否冲突”的检查就变成一条简单的SQL查询,你总是能从当前这场匹配里直接拿到三方关系。成绩统计时也不用去关联报名表再追溯选手,直接读player_id就好,性能非常友好。
数据库索引一定不要省。registrations.event_id + status这个联合索引几乎是日常查询的主入口,报名列表中所有运营操作都会先按赛事筛选状态。matches.event_id + round联合索引同理。这些索引在数据量到几千条时感觉不出来,但比赛日并发查询一上来,没有索引的查询会被锁得怀疑人生。
3. 核心功能落地:报名、编排、评分怎么做
3.1 报名模块:截团时间、人数上限与并发控制
报名接口看起来简单,无非是插入一条记录,但高并发下最容易出“超卖”问题。如果赛事设置了最多32支队伍,前端按钮显示“已满”,但最后几毫秒里有20个人同时点击提交,数据库层面不做约束,就可能插入超过32条记录。
我在Django里的做法是使用select_for_update配合事务。报名开始时先锁定赛事总名额行,判断当前已报名人数是否小于上限,再插入报名记录,整个操作放在一个transaction.atomic()块里。这样并发请求会被行锁串行化,不会出现名额超卖。
from django.db import transaction from django.db.models import F @transaction.atomic def apply_event(event_id, player_id): event = Event.objects.select_for_update().get(pk=event_id) cur_count = Registration.objects.filter( event=event, status__in=['pending', 'approved'] ).count() if cur_count >= event.max_players: raise APIError('名额已满') registration = Registration.objects.create( event=event, player=player_id, status='pending' ) return registration报名状态流转我设计了三条路径:pending(待审核)、approved(已通过)、rejected(已拒绝)。线下赛通常有两种极端,一种是运营希望先收钱再确认资格,另一种是纯免费赛事直接自动通过。我建议在赛事表上加一个auto_approve布尔字段,True时报名成功后状态直接置为approved,否则进入待审核列表。这个开关能让运营在赛前轻松切换审核策略。
报名编号生成也值得注意。不要用自增ID直接暴露给选手,我在生成报名记录时额外生成一个短编号,规则是赛事ID + 日期 + 四位随机数,比如EV202406130018。这个编号用于现场检录核验,选手报出编号运营就可以在后台直接定位记录,比翻名字精准得多。
3.2 裁判分配:自动匹配与回避规则
裁判分配不能简单“随机派单”。每个裁判有自己的擅长游戏项目,比如有人主修MOBA类,有人主修FPS类,把FPS裁判派到MOBA比赛上,等于让评审看不懂比赛。我的分配策略分两步走:先按referees.game_types过滤出具备该赛事项目资质的裁判,再检查当前裁判当天已经分配了多少场比赛,优先选择场次最少的那位,实现简单的负载均衡。
回避规则是实现时最容易遗漏的部分。我的冲突检测逻辑包含三层:第一层是裁判当前所属战队与某选手所在战队同名,直接判冲突;第二层是裁判与其选手来自同一个注册单位,比如同一所高校;第三层是运营手动维护的“黑名单”关系,比如某裁判和历史上有纠纷的战队不能同场。三者只要命中任意一个,系统就不会把这位裁判分配进该场次。
def is_conflict(referee, player): if referee.current_team and referee.current_team == player.team_name: return True if referee.unit_name and referee.unit_name == player.unit_name: return True if ConflictBlacklist.objects.filter( referee=referee, player=player ).exists(): return True return False比例冲突检查也可以在保存前做,但真正稳的做法是把它放进数据完整性层面。我在matches表里没有用数据库约束,因为这种情况无法用简单的唯一键约束,所以我在分配接口的业务逻辑里调用is_conflict,同时在赛前跑一个批量扫描任务,把历史场次的冲突情况拉出来人工复查一遍。这算是“系统自动检查+人工兜底”的双保险。
3.3 赛程编排与状态流转
赛程编排我从简处理:小组赛阶段采用随机分组,选手报名时可以勾选“种子选手”,运营在后台标记几位往届四强作为种子,编排时种子选手优先分散到不同小组,避免强队第一轮就碰头。淘汰赛阶段根据小组赛排名生成对阵,规则就是标准的“A组第一对B组第二”。
每场比赛的状态我用matches.status字段表达:not_started、ongoing、finished、cancelled。运营可以手动把not_started改为ongoing,比赛结束时由裁判提交成绩,系统再把状态置为finished。这个字段表面简单,实际上它驱动了前端的展示逻辑——只有ongoing状态的场次才允许裁判录入分数,只有finished的场次才进入成绩计算。
编排接口我用Django实现,逻辑本身不复杂,关键在于手动调整的兜底。比赛日经常出现选手迟到、弃权等突发情况,我加了一个reschedule接口,运营可以指定某个场次的对阵双方、开赛时间、比赛场地,系统清除场次原有的评分记录,并把后续场次依赖关系重新计算一遍。这个接口的权限必须严格限制在超级管理员级别,否则容易出现误操作。
3.4 评分录入与成绩计算
评分流程是这套系统的高频操作。每场比赛有三位裁判评分,每位裁判进入比赛详情页,看到参选双方各三位选手(以5V5团队赛为例),按照选手ID录入个人评分,也可以直接给战队整体打分。评分提交后,系统会校验几个条件:场次状态必须是ongoing、裁判必须属于本场次的referee_id、当前时间不能晚于赛事设定的最晚录入时间。三个条件任何一个不满足,接口直接拒绝。
成绩计算我放在裁判全部提交后进行,不采用边录边算。因为三人评分可能存在某位裁判尚未提交的情况,中途计算结果会误导大屏展示。当三位裁判都提交后,系统自动触发计算:队伍最终得分是每位裁判给该队打分的总和,去掉一个最高分去掉一个最低分,取中间分之和。如果只有两位裁判,就直接求和。计算完成后生成一条match_results记录,同时把队伍排名写入赛事排行榜。
这里有一个经验分享:评分提交接口一定要做幂等。裁判可能因为网络原因点了两次提交,如果接口不做判断就会生成两条重复评分。我在score_tmp_表里加了match_id + referee_id + player_id的唯一索引,第二次提交时触发IntegrityError,捕获后直接返回“已提交”而不是报错,既保证数据唯一又不影响用户体验。
4. 关键实现细节:权限、上传与联调
4.1 三角色权限体系实现
我使用Django自带的User模型,在Profile表里扩展一个role字段,取值包括player(选手)、referee(裁判)、admin(运营管理员)。选手端登录可以看到自己的报名记录和赛程;裁判端只能看到分配给自己的场次;管理端拥有一切权限。整个系统的权限控制围绕role字段做装饰器分发。
Django视图里我写了一个role_required装饰器,接受一个或多个角色名,在请求进入视图前判断当前用户的role。Flask侧的接口因为主要给裁判现场使用,我额外签发了短期token,裁判在Flask接口请求头里带这个token,Flask侧校验token有效后直接从Redis里读取用户ID和角色,不再重复查数据库。这个token的有效期我设置成8小时,刚好覆盖一个比赛日的完整时段。
这里要特别提醒:Django和Flask两个服务的session是完全独立的。Django的session存在Django默认的表里,Flask的session默认是客户端cookie,两边用同一套登录态必然失败。我最终没有强行桥接两个session体系,而是让前端在请求Flask接口时单独携带token,这套设计虽增加了前端一点工作量,但两个服务的职责边界非常清晰,排查问题也容易。
4.2 头像与证件上传的处理
报名阶段选手需要上传身份证照片或学生证照片,裁判需要上传资格证书。直接存在media目录下固然简单,但如果文件名用原始上传名,高并发时容易出现重名覆盖。我采用重新命名策略:uuid4().hex加上原文件后缀,存储路径按日期分目录,比如media/uploads/2024/06/13/xxxx.jpg。
文件类型校验不能只检查后缀,我用Pillow库打开图片并检查image.verify(),能通过说明确实是图片数据。大小限制用request.FILES的size属性判断,超过2MB直接拒绝。Django侧配置MEDIA_ROOT和MEDIA_URL,生产环境由Nginx托管/media/路径,否则静态图片请求会全部打到Python进程上,比赛日并发一上来就卡。
from PIL import Image from django.core.exceptions import ValidationError def validate_image(upload): try: img = Image.open(upload) img.verify() except Exception: raise ValidationError('不是有效的图片文件') if upload.size > 2 * 1024 * 1024: raise ValidationError('文件大小不能超过2MB')图片上传后,如果需要生成缩略图(比如选手列表头像),我在保存时用Pillow再生成一张160×160的缩略图,存到thumbnails/目录,列表页只读取缩略图,避免列表加载原始大图导致页面缓慢。
4.3 Django与Flask联调的接口约定
两个服务之间不直接通过HTTP调用,而是通过前端浏览器作为中转。比如裁判检录的页面,前端先调用Django接口拿到赛场信息和报名列表,然后调用Flask接口提交检录状态,最后再刷新Django接口获取最新状态。这样做的好处是不需要在服务端维护复杂的内部调用链,坏处是前端需要处理两次请求的时序。
但如果确实需要服务端之间同步数据,比如Flask侧把裁判评分写入后要通知Django侧刷新比赛状态,我采用数据库事件监听方案。Flask在score_tmp_表插入记录后,通过消息队列发送一个事件到Django侧的消费者,Django收到事件后再计算比赛结果并更新状态。消息队列用Redis的Stream即可,不需要引入重型MQ中间件,部署成本低,故障排查也简单。
接口返回格式必须前后端共同约定好。我的统一格式是{'code': 0, 'msg': 'ok', 'data': {...}},异常时code非0,msg给出人类可读的错误信息。这样前端只需要写一个统一的请求封装层,不需要在每个接口里单独做错误处理。联调阶段最耗时的不是逻辑错误,而是字段命名不一致,所以接口文档要在开发前定好,后补文档基本都会被吐槽。
5. 部署与日常运行:从本机到比赛日
5.1 本地环境搭建步骤
克隆代码后,先用虚拟环境安装依赖。requirements.txt里核心依赖有Django、Flask、mysqlclient、djangorestframework、pillow、requests、gunicorn、redis。Django侧先改settings.py的数据库连接,然后依次执行:
python manage.py makemigrations python manage.py migrate python manage.py createsuperuserFlask侧不需要迁移主表,但如果用它管理检录流水和评分暂存,需要单独运行它自己的建表逻辑。我习惯在Flask服务的启动函数里用Base.metadata.create_all()自动建表,见下表,开发环境方便,生产环境改为脚本执行,避免服务启动时因为权限问题卡住。
启动分两个终端,一个跑Django开发服务,一个跑Flask服务:
python manage.py runserver 0.0.0.0:8000 flask --app flask_app run --port 5200开发环境不要在同一端口跑两个服务,前端联调时在本地配置代理即可。我建议用django-cors-headers开启跨域许可,让前端开发服务器可以直接请求两个后端端口。
5.2 生产环境部署:Nginx + Gunicorn双服务
生产环境我用两台Gunicorn分别托管Django应用和Flask应用。Django侧跑在8000端口,命令类似gunicorn config.wsgi:application -b 0.0.0.0:8000 -w 4,Flask侧跑在5200端口,命令是gunicorn -w 2 -b 0.0.0.0:5200 flask_app:app。Nginx配置的关键是按照路径分发:
location / { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location /api/ { proxy_pass http://127.0.0.1:5200; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location /media/ { alias /data/project/media/; }这里有个细节容易被忽略:如果是按前缀/api/转发给Flask,Flask路由定义时需要关闭默认的静态目录逻辑,同时考虑前缀剥离。我的Flask接口统一不带版本号前缀,直接/api/checkin就在前端配置路径里约定/api对应Flask服务,Nginx的location匹配后会把完整URI传给Flask,所以Flask侧路由要写/api/checkin而不是/checkin,否则404。
比赛日之前,我建议做一次模拟压测。用locust或者简单的脚本并发请求报名接口,观察数据库连接数是否被打满。MySQL默认最大连接数往往不够,我调整到200,同时把Gunicorn的worker数设为CPU核数的2倍,再往上加只会增加上下文切换,不会有性能提升。
5.3 赛前备份与赛后数据归档
赛事数据是一个时间敏感型资产,赛前一天所有报名记录、裁判分配、赛程编排都必须备份。我用mysqldump定时任务,每天凌晨备份一次,赛前2小时再手动执行一次。备份文件通过脚本压缩后传到独立的备份磁盘或者对象存储,本地留一份,异地留一份。
赛后数据不能立即删除,要保留至少一个赛季。成绩数据可能会被选手投诉复查,也可能会被赛事主办方用于下一季的种子排名。我设计了一个归档命令,把赛季结束超过30天的赛事数据导出为JSON并压缩存储,数据库里只保留赛事元信息,报名明细和评分详情从主表清走,这样能让常用查询表的体积一直保持在一个可控范围内。
注意:线上环境的DEBUG必须改为False,ALLOWED_HOSTS必须配置实际域名,否则Django会拒绝所有请求。这两项看似基础,我见过不下三个团队在部署时因为漏掉它们而排查半天。
6. 常见问题与避坑实录
6.1 并发报名导致“名额超卖”
这个坑几乎每个做报名系统的人都会踩。我最早版代码是先count()再create(),没有加事务锁,赛前开放报名的那一秒钟,后端同时收到几十个请求,最终报名人数比设定上限多了7个。修复方案就是前面提到的select_for_update。它的问题是要提前知道锁哪一行,如果赛事主表恰好不存在,锁就无从谈起,所以我会在Event表里保证所有赛事记录都存在,关闭报名的赛事也要留着一行,只是status改为closed。
6.2 两套框架的Session与登录态冲突
刚开始联调时,前端登录Django后跳转到Flask接口,结果Flask认为用户未登录,整个流程直接卡死。这是因为两边session机制不互通。我的最终解法是让Flask侧完全独立校验,通过一个短期token识别裁判身份。前端登录接口从Django拿到token,存到localStorage,之后每次请求/api/路径都带上Authorization头,Flask侧解析token对应的用户ID和角色。
这个方案的问题是最坏情况下token泄露后8小时内都能被冒用,所以我在签发token时绑定来源IP,前端IP变化频繁的场地网络环境会出现误杀。我的取舍是放宽IP绑定,只校验签名有效性和有效期,因为这是内部赛事系统,风险可控。如果做面向公网的大规模系统,建议启用严格IP绑定。
6.3 图片上传成功但页面打不开
上传接口返回200,但浏览器里访问图片地址总是404。原因是开发环境直接访问Django的媒体服务没问题,生产环境Nginx配置/media/路径的alias写错了,导致真实目录和URL对不上。排查方式很简单,直接在服务器上curl -I访问一张已知图片的URL,看返回的是404还是200,200就说明是路径映射问题,404则要检查文件是否真的上传成功。部署完后一定要主动测试一次完整的图片上传链路,不然比赛日选手传不了证件会让现场非常被动。
6.4 比赛时间显示错乱
赛事表里存储的start_time是全0时区的时间,但系统设置用了本地时区,结果管理员后台看到的比赛时间比实际晚了8小时。Django默认USE_TZ = True,数据库里存UTC时间,模板渲染时如果没做时区转换,就会显示UTC时间。解决办法是保持USE_TZ = True,把TIME_ZONE设置为赛事所在时区,模板渲染时Django会自动转换。Flask侧读取时间字段时必须先转换为本地时间再返回前端,否则同样会差8小时。这个坑特别隐蔽,因为小规模测试数据通常都是当天创建,差8小时可能刚好落在同一天,只有安排跨零点比赛时才暴露。
6.5 评分提交接口重复请求
比赛现场网络很不稳定,裁判提交评分时经常点一次没反应又点一次,重复数据随之而来。除了在数据库加唯一约束外,前端也要做提交锁,提交按钮在请求返回前禁用。两个层级的防重叠加,数据才真正安全。我的经验是数据库唯一约束是兜底,不能单纯依赖前端。
| 问题现象 | 根因 | 处理方案 |
|---|---|---|
| 报名人数超过上限 | 缺少行级锁 | 使用select_for_update + 事务 |
| 裁判无法提交评分 | 场次状态不是ongoing | 检查前端是否先调用开赛接口 |
| 图片404 | Nginx媒体路径映射错误 | 检查alias与MEDIA_ROOT路径 |
| 比赛时间差8小时 | 时区转换未生效 | 设置TIME_ZONE,Flask侧手动转换 |
| 评分重复提交 | 缺少幂等处理 | 数据库唯一索引 + 前端提交锁 |
7. 这类系统后续还能怎么延伸
报名和裁判管理跑通之后,我发现一个很有意思的趋势:赛事的数字化价值远不止于“省掉一张Excel表”。同一套数据往后端延伸,可以自动生成本赛季的积分排名体系;往前端延伸,可以做一个选手专属的“我的赛事档案”,记录每次比赛战绩、晋级截图、历史评分。如果再接入直播平台的比赛结果推送,系统的覆盖面可以从赛前一直延伸到赛后传播。
我个人最推荐的下一个模块是电子证书自动生成。赛事结束后,系统根据match_results表和报名信息自动生成获奖证书PDF,选手在个人中心直接下载。这个功能实现成本不高,但对赛事主办方而言,它的宣传价值和选手留存价值远超预期。技术方案也不复杂,用Python的ReportLab库渲染模板,队列生成后存到对象存储,前端展示下载按钮即可。
最后分享一个我赛后才想明白的体会:一套赛事系统的成功,不在于功能做了多少,而在于比赛日当天运营团队能不能在30秒之内处理掉一个意外。所以开发的时候,与其花大量时间做花哨的可视化大屏,不如把“运营手动改状态”的接口做得顺手一点。把简单可靠的流程管理好,比堆积功能重要得多。这套系统上线后经历了两轮比赛日实战,整体稳定,但每次赛后复盘都还会发现新的小需求。技术选型始终服务于业务逻辑,Django和Flask的共存,也只是服务于这个目标的一种手段而已。