做篮球联赛信息管理系统这个题,是在去年帮朋友做课程设计时临时接的活儿。完整题目是“Django-Flask基于Python的CBA联赛信息管理系统”,说人话就是用Python那套技术栈,给职业篮球联赛做一个统一管球队、球员、赛程、比分、积分榜和新闻的平台。接之前我也觉得这玩意儿就是个增删改查,真正动手才发现,业务关系怎么梳理、排名怎么按联赛规则算、Django和Flask两套框架怎么在一个项目里和谐共存,每一块都够写一大篇。
这个系统能解决的问题很明确:联赛运营方要维护球队和球员档案,每场比赛结束后要录比分、算胜负、刷新积分榜,运营编辑要发新闻公告,管理员要管账号和权限。全靠Excel表格登记的话,数据散落、重复录入不说,打了一轮比赛要手动核对胜场、胜率、净胜分,非常容易出错。做成信息系统以后,录一场比赛,比分、胜负关系、胜率、排名一次性自动算好更新,前台给球迷看赛程和积分榜,后台给运营人员做数据维护,一套数据两头用。如果你正在为课程设计、毕业设计选题发愁,或者想从零掌握Django业务开发、顺便搞明白Flask怎么当轻量服务,这篇总结基本可以照着抄。
1. 系统定位与整体设计思路
1.1 它表面上是个CRUD,实际是套业务系统
很多人在项目答辩时最喜欢说“我做了一个管理系统”,但老师第一个问题就是:你这个系统到底管什么、给谁用、数据从哪来。这套篮球联赛信息管理系统也一样,如果只把“球队表”“球员表”“比赛表”建好就算完,那和用Excel没什么区别。真正的价值在“业务闭环”:
- 球队、球员档案是基础数据,球员必须归属于某支球队,位置、号码、身高体重这些字段直接决定之后能不能按条件筛选和统计。
- 赛程编排是核心流程,一场比赛要把主队、客队、时间、场馆、轮次关联起来,状态要区分未开始、进行中、已结束,因为只有“已结束”的比赛才能参与积分榜计算。
- 比分录入是触发点,录完比分后,胜场、负场、胜率、积分、排名全部联动更新,不是按一下刷新按钮临时去查几场比赛再手算。
- 新闻公告是内容补充,比赛完了要有战报,伤病要有通知,这部分对权限要求比较低,内容编辑就能处理。
- 账号权限是底线,录入员、编辑、管理员各自能做什么不能做什么,必须分清楚。
这样拆下来,系统的目标用户就很清晰:前台面向普通球迷浏览数据,后台面向联赛运营工作人员。围绕这两个使用场景,我再决定技术方案。
1.2 为什么用Django加Flask两套框架而不只选一个
这是我被问得最多的问题,印象里好像所有人都默认“一个项目只用一个Web框架”。其实双框架方案在真实业务里不少见,关键在于职责切分。
Django在这个项目里当“主力部队”。原因很简单:管理后端需要用户认证、权限分组、ORM、Admin后台、表单处理,这些恰好是Django的看家本领。特别是Django自带的Admin,把模型注册进去就能直接增删改查,对于联赛运营这种内部管理系统来说,能省下大量开发时间。球队要加个临时外援、新闻要撤稿、场次要改时间,管理员直接进后台就能操作,不用我单独写一堆管理页面。
Flask在项目里当“轻骑兵”。比赛数据接口(比如现场比分推送、积分榜实时查询)调用频率高、逻辑相对简单,用Flask起一个轻量服务挂在另一个端口,接口返回JSON,比在Django里写一堆序列化代码要快得多,部署时也能独立重启、独立扛压力。
方案拆成两半之后,两个服务共享同一个数据库。Django负责写业务数据和日常管理,Flask负责对外提供数据接口和计算类服务,比如给前台页面提供最新的积分榜JSON。这样做的另一个半隐藏好处是,一个项目里同时覆盖了Django和Flask两种框架,简历上和答辩时都有东西讲,课程设计“技术亮点”这一栏也自然有了内容。
1.3 功能模块与角色权限划分
整个系统在代码层面分成下面几个Django应用,对应关系非常直白:
| 应用名 | 主要功能 | 负责角色 |
|---|---|---|
| users | 登录、注册、权限管理 | 系统管理员 |
| teams | 球队档案、主场球馆、教练信息 | 信息录入员 |
| players | 球员档案,归属球队、位置、号码 | 信息录入员 |
| matches | 赛程、比分、比赛状态管理 | 信息录入员 |
| standings | 积分榜排名计算与展示 | 系统自动计算 |
| news | 战报、公告、新闻发布 | 内容编辑 |
权限这块我最初没想太细,后来做后台才发现必须分。录入员只能改球队、球员、比赛数据,不能改管理员账号;内容编辑只能发布和管理新闻;只有超级管理员能进Django Admin做系统级配置。这套权限用Django自带auth就能实现,User表加一个role字段,页面上按角色判断显示按钮就够了。
2. 核心数据表设计与关键技术点
2.1 球队、球员、比赛三张主表的字段设计
数据表是整个系统的地基,表设计错了,后边所有查询都别扭。我实际建表时用的是Django的模型定义,核心代码如下:
# teams/models.py from django.db import models class Team(models.Model): name = models.CharField('球队名称', max_length=50, unique=True) short_name = models.CharField('简称', max_length=20) city = models.CharField('所在城市', max_length=30) home_arena = models.CharField('主场球馆', max_length=80) coach = models.CharField('主教练', max_length=30) founded_year = models.IntegerField('成立年份') logo = models.ImageField('队标', upload_to='teams/', blank=True) is_active = models.BooleanField('是否参赛', default=True) class Meta: ordering = ['name'] def __str__(self): return self.name# players/models.py from django.db import models from teams.models import Team class Player(models.Model): POSITION_CHOICES = [ ('PG', '控球后卫'), ('SG', '得分后卫'), ('SF', '小前锋'), ('PF', '大前锋'), ('C', '中锋'), ] team = models.ForeignKey(Team, on_delete=models.CASCADE, related_name='players', verbose_name='所属球队') name = models.CharField('球员姓名', max_length=30) jersey_number = models.IntegerField('球衣号码') position = models.CharField('场上位置', max_length=10, choices=POSITION_CHOICES) height = models.DecimalField('身高(米)', max_digits=3, decimal_places=2) weight = models.DecimalField('体重(千克)', max_digits=5, decimal_places=1) birth_date = models.DateField('出生日期') photo = models.ImageField('球员照片', upload_to='players/', blank=True) is_active = models.BooleanField('是否在队', default=True) class Meta: ordering = ['team', 'jersey_number'] unique_together = ('team', 'jersey_number') def __str__(self): return f'{self.team.short_name} {self.jersey_number}号 {self.name}'Team表里我特意加了is_active字段。为什么不直接删球队?因为比赛表里有历史外键,直接删会破坏历史赛程,更稳妥的做法是标记“不再参赛”。Player表也是一样的思路,球员退役或转会了就置为False,不会影响以前比赛的技术统计。这个“逻辑删除优先于物理删除”的数据库设计习惯,在管理系统里比什么花哨功能都重要。
比赛表是整个系统的枢纽:
# matches/models.py from django.db import models from teams.models import Team class Match(models.Model): STATUS_CHOICES = [ ('scheduled', '未开始'), ('live', '进行中'), ('finished', '已结束'), ('postponed', '延期'), ] season = models.CharField('赛季', max_length=20, default='2024-2025') round_no = models.IntegerField('轮次') home_team = models.ForeignKey(Team, on_delete=models.CASCADE, related_name='home_matches', verbose_name='主队') away_team = models.ForeignKey(Team, on_delete=models.CASCADE, related_name='away_matches', verbose_name='客队') match_time = models.DateTimeField('比赛时间') venue = models.CharField('比赛场馆', max_length=80) home_score = models.IntegerField('主队得分', default=0) away_score = models.IntegerField('客队得分', default=0) status = models.CharField('比赛状态', max_length=10, choices=STATUS_CHOICES, default='scheduled') class Meta: ordering = ['match_time', 'round_no'] unique_together = ('season', 'round_no', 'home_team', 'away_team') def __str__(self): return f'{self.home_team.short_name} vs {self.away_team.short_name}'unique_together那一行容易被忽略,实际很关键:它保证同一个赛季同一轮次里,同样的主客队对阵组合只能存在一次,防止录入员手滑重复建赛程。比赛时间用DateTimeField而不是分开存日期和时刻,因为后面要按照时间排序、判断进行中的场次,一个字段最省事。
2.2 积分榜为什么不用单独存一张表
积分榜这个模块我推翻过一次。最初我建了一张Standing表,球队名、胜场、负场、胜率全存进去,每场比赛结束就UPDATE。结果发现两个问题:一是录入员改了历史比分会造成积分榜数据不同步;二是计算逻辑散落在代码里,很难维护。
后来我干脆不存积分榜表,改成“计算快照”。每场状态变为finished的比赛,触发一个函数重新计算当前赛季所有球队的排名,然后把结果以JSON格式缓存到一张轻量表里供前台读取。计算规则完全按篮球联赛常见规则实现:
# standings/services.py from collections import defaultdict from django.db.models import Q from matches.models import Match def calculate_standings(season): stats = defaultdict(lambda: {'win': 0, 'lose': 0, 'points_for': 0, 'points_against': 0}) finished_matches = Match.objects.filter(season=season, status='finished') for m in finished_matches: stats[m.home_team_id]['points_for'] += m.home_score stats[m.home_team_id]['points_against'] += m.away_score stats[m.away_team_id]['points_for'] += m.away_score stats[m.away_team_id]['points_against'] += m.home_score if m.home_score > m.away_score: stats[m.home_team_id]['win'] += 1 stats[m.away_team_id]['lose'] += 1 else: stats[m.away_team_id]['win'] += 1 stats[m.home_team_id]['lose'] += 1 ranking = [] for team_id, s in stats.items(): total = s['win'] + s['lose'] win_rate = s['win'] / total if total else 0 net_points = s['points_for'] - s['points_against'] ranking.append({ 'team_id': team_id, 'win': s['win'], 'lose': s['lose'], 'win_rate': round(win_rate, 3), 'net_points': net_points, 'points': s['win'] * 2 + s['lose'] * 1, }) ranking.sort(key=lambda x: (-x['points'], -x['win_rate'], -x['net_points'])) for index, item in enumerate(ranking, start=1): item['rank'] = index return ranking排序用的三个条件依次是积分、胜率、净胜分,这是比较通用的篮球联赛排名规则。注意程序里不能用真实比赛比分直接比较是否相等,因为篮球比赛常规时间没有平局,但万一出现加时赛绝杀之外的录入错误,用大于小于判断是安全的。
2.3 关联查询的性能处理
开发初期数据量小,看不出问题。但在联赛打了十几轮、一场比赛关联主客两队时,如果按最直接的方式在模板里写{{ match.home_team.name }},每显示一个比赛都会额外发一次SQL查询,页面卡顿很明显。解决办法就两个函数:select_related和prefetch_related。
# matches/views.py from django.views.generic import ListView from matches.models import Match class MatchListView(ListView): model = Match template_name = 'matches/match_list.html' context_object_name = 'matches' def get_queryset(self): return Match.objects.select_related('home_team', 'away_team') \ .filter(status='scheduled') \ .order_by('match_time')select_related用于外键字段,会生成一条带JOIN的SQL,把主队和客队信息一次性查出来;prefetch_related用于反向关联,比如渲染球队详情页时一次性把该队所有球员查出来。这一招是最简单也最有效的性能优化,答辩时如果老师问“数据多了怎么办”,你就把这个讲清楚,比背一堆八股强得多。
3. 从零到能跑的完整实操过程
3.1 环境准备与项目初始化
环境这块别纠结版本,稳定就行。我用的是Python 3.10、Django 4.2、Flask 2.3,MySQL 8.0。操作步骤:
python -m venv venv source venv/bin/activate # Windows下是 venv\Scripts\activate pip install django==4.2.* flask==2.3.* pymysql django-cors-headers pillow django-admin startproject cba_system . python manage.py startapp users python manage.py startapp teams python manage.py startapp players python manage.py startapp matches python manage.py startapp standings python manage.py startapp news一个容易踩的细节是:startproject后面那个点别漏了,漏了会在项目根目录里再套一层目录,后面所有命令的路径都会对不上。application目录建好以后,先不要急着写业务代码,把app全部注册到settings.py的INSTALLED_APPS里,再去建表,否则后面makemigrations会提示找不到模型。
3.2 Django配置与数据库迁移
连接MySQL前,先去数据库里建好库,注意字符集:
CREATE DATABASE cba_system DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;然后修改settings.py:
DATABASES = { 'default': { 'ENGINE': 'django.db.backends.mysql', 'NAME': 'cba_system', 'USER': 'root', 'PASSWORD': '你的密码', 'HOST': '127.0.0.1', 'PORT': '3306', 'OPTIONS': {'charset': 'utf8mb4'}, } }字符集必须用utf8mb4,不是utf8。因为球员可能来自不同地区,名字里如果有生僻字,utf8的老字符集会直接报错或者存成乱码。这一点我在第4部分还会细说。
接着运行迁移:
python manage.py makemigrations python manage.py migrate python manage.py createsuperusercreatesuperuser创建的是管理员账号,用于登录Django Admin后台。我习惯把Admin后台先配置好再写前端页面,因为管理后台能把基础数据录进去,后面调试列表页、详情页都有真实数据可用。
3.3 业务页面与URL路由实现
前台页面我用的是Django的通用视图加原生模板,没有额外引入重型前端框架。以赛程列表为例:
# matches/urls.py from django.urls import path from matches import views urlpatterns = [ path('matches/', views.MatchListView.as_view(), name='match_list'), path('match/<int:pk>/', views.MatchDetailView.as_view(), name='match_detail'), ]模板里写一个简单的卡片布局,显示对阵双方、时间和状态。我的建议是前期不要花太多时间在页面上,先把数据展示正确,再套Bootstrap样式。你完全可以用一套现成的后台模板改改布局,管理系统这类项目,评委看的核心永远是功能完整度和数据流程是否正确。
对于比赛录比分这个操作,我直接用Django Admin来解决,不给录入员单独写表单页面。这是因为比分录入场景太简单,Admin自带表单校验、历史记录和权限控制,够用且不容易写错。等到需要自定义业务规则了再重写页面,这是很多初级开发者不懂的道理:能用现成方案时,别给自己造轮子。
3.4 Flask服务模块的落地与联调
Flask服务的目录我单独放在项目根目录下的service文件夹里,和Django代码区分开,避免两个框架的代码混在一起不好维护。它的职责有两个:对外提供积分榜JSON接口,接收比分推送请求。
# service/score_api.py from flask import Flask, jsonify, request import pymysql app = Flask(__name__) DB_CONFIG = { 'host': '127.0.0.1', 'user': 'root', 'password': '你的密码', 'database': 'cba_system', 'charset': 'utf8mb4', } @app.route('/api/standings/<season>', methods=['GET']) def standings(season): conn = pymysql.connect(**DB_CONFIG) cur = conn.cursor() # 实际项目里从缓存表读取计算快照,避免每次实时算全量数据 cur.execute("SELECT * FROM standings_cache WHERE season=%s", (season,)) rows = cur.fetchall() cur.close() conn.close() return jsonify({'code': 0, 'data': rows}) if __name__ == '__main__': app.run(host='127.0.0.1', port=5001, debug=True)Django子和Flask子是两套进程,共享同一个数据库。前台页面通过Ajax调用Flask接口获取积分榜,Django只管渲染页面骨架和赛程数据。这里有一个特别需要注意的点:跨端口访问会触发浏览器的CORS限制,需要在Flask端配置CORS或者让Django的接口做反向代理转发。我在Flask里用django-cors-headers的兄弟方案,直接用Flask-CORS解决,配置三行代码就好。
数据一致性问题靠数据库兜底。Flask从缓存表读排名快照,Django在每场比赛结束时把最新排名写入缓存表。两块服务各自专注自己的事,如果接口挂了不影响Django后台正常工作,这就是拆开部署的最大好处。
4. 常见问题与排查技巧实录
4.1 开发期最容易踩的八个坑
这个系统从开发到调试,我踩了一堆坑,整理成速查表,照着排查能省很多时间:
| 现象 | 根本原因 | 解决办法 |
|---|---|---|
| 图片上传后页面显示404 | MEDIA_ROOT和MEDIA_URL没配置或没加路由 | settings配置MEDIA路径,urls里加static()转发 |
| Admin提交报CSRF验证失败 | 外站POST请求或浏览器禁用Cookie | 模板form里加{% csrf_token %},API接口按需豁免 |
| 中文数据写入数据库变问号 | 表或连接字符集不是utf8mb4 | 建库时指定utf8mb4,连接参数加charset |
| 比赛时间差了8小时 | USE_TZ=True且TIME_ZONE没设 | 设置TIME_ZONE=‘Asia/Shanghai’,按需管理USE_TZ |
| SQLite并发写入报database is locked | 开发期用了SQLite,多人同时录入 | 换成MySQL;临时用SQLite就要接受单写限制 |
| 积分榜刷新后排名和手工对不上 | 缓存表没在事务里更新 | 用Django的transaction.atomic包住重算逻辑 |
| Flask接口中文JSON返回乱码 | Flask默认JSON编码问题 | app.config[‘JSON_AS_ASCII’]=False |
| 修改历史比分后积分不变 | 没触发重算函数 | 在Model的save方法或信号里统一调用重算 |
第四行的时区问题几乎每个人都会碰到。Django开启USE_TZ后,存进数据库的时间是UTC,页面展示如果不做本地化,就会出现前台显示的晚上八点变成了凌晨四点。我的做法是TIME_ZONE设置成Asia/Shanghai,模板显示时间时用timezone.localtime()转换,前后台展示全部统一成北京时间。
4.2 比分录入的“事务”教训
积分榜重算函数第一次上线时,出现过一次非常尴尬的事:录完一场比分,页面刷新后主队的胜利多了1分,客队的失败却没变。排查了半天,发现是重算函数里好几条SQL语句没有放在同一个事务里,前两条UPDATE成功了,第三条因为外键约束报错回滚,结果数据只改了一半。
后来我果断把重算逻辑包进transaction.atomic里。加上之后,要么全部更新成功,要么全部回滚,数据库永远不会出现“赢了没输”这种半完成状态。这个教训对做信息管理系统的同学特别有参考价值:任何涉及多张表联动的写操作,都应该考虑事务,不要想当然认为代码顺序执行就万事大吉。
还有一种情况是录比分时主客队填反了。我特意在比赛详情页加了一个“比分校验”的提示:如果主队得分加客队得分超过一个约定阈值,或者两队分差超过平时见过的极大值,就弹一个确认框。这个小功能在答辩演示时能加不少分,因为它体现的是“从业务场景出发考虑异常”,不是从代码角度堆功能。
5. 部署上线与扩展方向
5.1 用Gunicorn加Nginx把系统真正跑起来
开发环境用runserver够用,但真要让系统稳定跑,必须用生产级服务器。Django部分我用Gunicorn作为WSGI服务器,Nginx负责静态文件、反向代理和负载均衡。核心操作如下:
pip install gunicorn gunicorn cba_system.wsgi:application -b 127.0.0.1:8000 --workers=3Nginx配置核心就三点:把80端口请求转发给8000端口;把/static/和/media/路径直接指向静态文件目录;把/api/路径反向代理到Flask的5001端口。为了让Django的静态文件能被Nginx直接伺服,需要先执行collectstatic。
python manage.py collectstatic --noinputsettings.py里要调整的地方包括DEBUG=False、ALLOWED_HOSTS里加上服务器域名或IP,还有STATIC_ROOT和MEDIA_ROOT的绝对路径。上线前最好把manage.py check --deploy跑一遍,它会自动提示部署相关的安全风险。
5.2 这个系统还能怎么扩展
如果只是交作业,做到这里已经够了。但把眼界放大一点,这个系统的扩展空间其实很大,而且很多方向都有现成思路可以参考。
一是数据可视化。当前积分榜是表格形式,可以引入ECharts,把球队胜率、球员得分走势做成折线图和柱状图,对球迷用户来说展示效果直接上升一个档次。
二是实时比赛推送。Flask服务可以加WebSocket支持,比赛进行中向前端页面实时推送比分变化,这就把“赛后录比分”升级成“赛中看直播数据”了。
三是Excel导入导出。球队名单、赛程表这种批量数据,靠手工一条条录入效率太低。用Django的第三方库实现Excel批量导入,运营人员按模板填好表直接上传,能省下大量录入时间。导出功能则可以直接在Admin里加按钮,一键导出当前赛季完整积分榜。
四是用信号机制替代手动调用重算。Django的post_save信号可以在比赛保存后自动触发积分榜重算,避免漏掉调用,让模块之间更解耦。
我在实际做这个项目时,最深的感受是:系统难的不是某一项技术,而是把这么多环节组织成一条顺畅的数据流。每场比赛从编排、录入比分、重算排名到前台展示,中间任何一环断了,用户都会觉得系统“有问题”。如果你也在做类似的信息管理系统,先把数据库设计和业务流程图画清楚再动手写代码,后面能少推翻三四次。建议你先从球队、球员、比赛三张主表做起,跑通一条“建队-录人-排赛-录比分-看排名”的完整链路,再往外围扩展新闻和权限,这样看着进度慢,实际上是最稳的路线。