
简介本资源是软件工程课程设计实践项目——《运动会管理系统》的完整源码包面向计算机专业本科生、Python初学者及Web全栈入门学习者解决学校或单位运动会报名、赛程编排、成绩管理与报表生成等全流程信息化需求。压缩包共326个文件大小855KB涵盖162个Python后端逻辑文件含Flask/Django风格接口、20个Vue组件文件与27个JavaScript脚本实现响应式前端交互辅以HTML/CSS页面结构和5类配置文件如gunicorn.conf、nginx_flask.conf等体现前后端分离架构设计另有.dockerfile、.editorconfig、.gitignore等工程化配置凸显课设中对部署规范与协作开发的实践。目前已有344人学习下载。读者可直接运行调试获得完整可执行系统、清晰分层的目录结构含webapi接口模块、典型配置模板及基础文档readme.txt是理解Python Web开发、Vue轻量集成与软件工程全流程落地的优质参考案例。1. 运动会管理系统不是“学生作业级”Demo它要扛住300人同时报名、5类项目并发录入、裁判现场扫码核验——Python能行但必须绕开这3个典型翻车点运动会管理系统常被误认为是课程设计级别的练手项目但真实场景远比想象复杂某高校春季田径运动会需支持28个院系、近300名运动员在线报名涵盖田赛跳高、铅球、径赛100米、4×100米接力、趣味项目袋鼠跳、两人三足三类规则迥异的赛事报名截止后需自动生成分组表、检录单、成绩录入模板比赛当天裁判用手机扫描二维码快速核验选手身份并录入实时成绩赛后还要导出带院系排名、破纪录标记、PDF版奖状的综合报表。这些需求让纯前端表单SQLite的“玩具方案”在压力测试阶段就集体崩溃——报名页响应延迟超8秒、并发提交导致成绩错乱、PDF导出中文乱码频发。本方案不依赖任何商业低代码平台全程基于Python生态构建核心采用Flask轻量框架SQLAlchemy ORMWeasyPrint生成PDFqrcode库实现扫码核验所有模块均经模拟300并发压测验证。适合有Python基础、熟悉HTTP请求与数据库操作的开发者目标不是“跑起来”而是“稳得住、改得动、扩得开”。2. 从零搭建可运行骨架用FlaskSQLAlchemy初始化最小可用系统2.1 创建项目结构与依赖管理拒绝“pip install -r requirements.txt”式玄学项目目录需严格分层避免后期模块耦合导致重构成本飙升。我一般采用以下结构sports_system/ ├── app/ │ ├── __init__.py # Flask应用工厂入口 │ ├── models.py # SQLAlchemy模型定义运动员、项目、成绩等 │ ├── routes.py # 路由逻辑报名、检录、成绩录入 │ └── utils/ # 工具函数二维码生成、PDF模板渲染 ├── migrations/ # Alembic数据库迁移文件必须启用 ├── templates/ # Jinja2模板报名页、成绩录入页、PDF模板 ├── static/ │ └── qr_codes/ # 动态生成的二维码图片存储路径 ├── config.py # 配置文件开发/生产环境分离 └── run.py # 启动入口创建虚拟环境并安装确定版本的依赖关键避免因版本漂移导致部署失败python -m venv venv source venv/bin/activate # Windows用 venv\Scripts\activate pip install Flask2.3.3 Flask-SQLAlchemy6.3.2 Flask-Migrate4.0.5 WeasyPrint62.2 qrcode[pil]10.1 Pillow10.2.0提示WeasyPrint对系统级依赖敏感Linux需提前安装libpango-1.0-0 libcairo2 libjpeg-dev libpng-dev libgif-devmacOS用brew install pango cairo jpeg libpng giflibWindows用户建议直接使用WSL2环境避免字体渲染异常。2.2 定义核心数据模型用SQLAlchemy描述运动会业务实体关系运动会的核心矛盾在于“人-项目-成绩”的多对多动态绑定。一名运动员可报多个项目如张三报100米和跳远一个项目有多名运动员参赛100米预赛32人而成绩记录需关联具体场次、轮次、裁判ID。模型设计必须预留扩展性避免为加一个“是否破纪录”字段就全表重构。# app/models.py from flask_sqlalchemy import SQLAlchemy from datetime import datetime db SQLAlchemy() class Athlete(db.Model): id db.Column(db.Integer, primary_keyTrue) name db.Column(db.String(50), nullableFalse) student_id db.Column(db.String(20), uniqueTrue, nullableFalse) # 学号作为唯一标识 college db.Column(db.String(50), nullableFalse) phone db.Column(db.String(20)) created_at db.Column(db.DateTime, defaultdatetime.utcnow) class Event(db.Model): id db.Column(db.Integer, primary_keyTrue) name db.Column(db.String(100), nullableFalse) # 如男子100米决赛 category db.Column(db.String(20), nullableFalse) # 田赛/径赛/趣味 max_participants db.Column(db.Integer, default0) # 0表示不限 is_team_event db.Column(db.Boolean, defaultFalse) # 是否团体项目 start_time db.Column(db.DateTime) class Registration(db.Model): id db.Column(db.Integer, primary_keyTrue) athlete_id db.Column(db.Integer, db.ForeignKey(athlete.id), nullableFalse) event_id db.Column(db.Integer, db.ForeignKey(event.id), nullableFalse) status db.Column(db.String(20), defaultpending) # pending/confirmed/cancelled registered_at db.Column(db.DateTime, defaultdatetime.utcnow) # 复合唯一约束同一运动员不可重复报同一项目 __table_args__ (db.UniqueConstraint(athlete_id, event_id),) class Result(db.Model): id db.Column(db.Integer, primary_keyTrue) registration_id db.Column(db.Integer, db.ForeignKey(registration.id), nullableFalse) score db.Column(db.String(50)) # 通用字段时间/距离/名次/得分 is_record db.Column(db.Boolean, defaultFalse) # 是否破校纪录 judge_id db.Column(db.String(20)) # 裁判工号 verified_at db.Column(db.DateTime) # 关联Registration确保成绩必属有效报名 registration db.relationship(Registration, backrefdb.backref(results, lazyTrue))参数说明与设计理由student_id设为uniqueTrue而非主键因学号是业务唯一标识且未来可能对接教务系统API避免主键冲突Registration表不直接存athlete.name和event.name而是通过外键关联保证数据一致性修改运动员姓名时无需遍历所有报名记录score字段用String而非Float因田赛成绩为“5.82米”径赛为“12.34秒”趣味项目为“第2名”统一字符串类型降低前端处理复杂度is_team_event布尔字段为后续团体项目如4×100米的分组逻辑埋点避免硬编码判断。2.3 初始化数据库与迁移脚本用Alembic管理表结构演进手动写CREATE TABLE语句是自毁前程。必须用Alembic生成可版本化、可回滚的迁移脚本# 初始化迁移仓库只执行一次 flask db init # 生成首次迁移脚本基于models.py中定义的模型 flask db migrate -m Initial tables: Athlete, Event, Registration, Result # 执行迁移创建实际数据库表 flask db upgrade生成的迁移脚本位于migrations/versions/xxx_initial_tables.py内容包含upgrade()和downgrade()函数。血泪经验每次修改模型后必须先flask db migrate再flask db upgrade切勿手动改数据库结构——否则下次迁移会因checksum不匹配而失败。3. 报名与检录闭环实现扫码核验与状态实时同步3.1 生成运动员专属二维码用qrcode库动态创建可验证凭证报名成功后系统需为每位运动员生成含唯一标识的二维码供检录时扫码核验。关键要求二维码内容不可预测防伪造、包含足够信息学号项目ID时间戳、离线可用不依赖网络请求。# app/utils/qr_generator.py import qrcode from io import BytesIO from PIL import Image, ImageDraw, ImageFont import os def generate_athlete_qr(athlete_id, event_id, output_path): 生成运动员-项目绑定二维码 编码格式ATHLETE:{athlete_id}:EVENT:{event_id}:TS:{timestamp} 例ATHLETE:123:EVENT:45:TS:1715678901 import time timestamp int(time.time()) data fATHLETE:{athlete_id}:EVENT:{event_id}:TS:{timestamp} # 生成基础二维码 qr qrcode.QRCode( version1, error_correctionqrcode.constants.ERROR_CORRECT_H, # H级纠错即使破损30%仍可读 box_size10, border4, ) qr.add_data(data) qr.make(fitTrue) img qr.make_image(fill_colorblack, back_colorwhite) # 添加底部文字说明提升现场识别效率 draw ImageDraw.Draw(img) try: # 尝试加载系统字体失败则用默认字体 font ImageFont.truetype(arial.ttf, 12) except: font ImageFont.load_default() text f学号:{athlete_id} | 项目:{event_id} draw.text((10, img.size[1]-20), text, fillblack, fontfont) # 保存到static/qr_codes/目录 os.makedirs(os.path.dirname(output_path), exist_okTrue) img.save(output_path) return output_path # 在报名成功后的路由中调用 # generate_athlete_qr(athlete.id, event.id, fstatic/qr_codes/{athlete.student_id}_{event.id}.png)逻辑说明二维码内容采用固定前缀ATHLETE:EVENT:TS:便于后端解析时做模式匹配避免与其他业务二维码混淆error_correctionqrcode.constants.ERROR_CORRECT_H启用最高纠错等级实测打印后轻微折痕、污渍不影响扫码底部添加学号与项目ID文字解决现场工作人员不熟悉二维码技术时的“盲扫”问题——看到文字就能确认是否找对人。3.2 检录端扫码核验接口用Flask接收扫码POST请求并返回结构化响应裁判手机扫码后浏览器自动打开系统检录页如/checkin?codeATHLETE:123:EVENT:45:TS:1715678901前端通过AJAX调用后端核验接口。该接口必须做到毫秒级响应、幂等性重复扫码不重复计数、状态实时更新。# app/routes.py 中添加 from flask import request, jsonify, render_template from app.models import Athlete, Event, Registration, db import re app.route(/api/checkin, methods[POST]) def api_checkin(): data request.get_json() if not data or code not in data: return jsonify({success: False, message: 缺少code参数}), 400 code data[code] # 正则提取athlete_id和event_id match re.match(r^ATHLETE:(\d):EVENT:(\d):TS:(\d)$, code) if not match: return jsonify({success: False, message: 无效二维码格式}), 400 athlete_id, event_id, ts match.groups() try: # 查询报名记录且状态为pending或confirmed防止已取消的再核验 reg Registration.query.filter_by( athlete_idint(athlete_id), event_idint(event_id) ).filter(Registration.status.in_([pending, confirmed])).first() if not reg: return jsonify({success: False, message: 未找到对应报名记录}), 404 # 幂等更新仅当原状态为pending时才改为confirmed if reg.status pending: reg.status confirmed db.session.commit() # 返回运动员和项目详情供前端展示 athlete Athlete.query.get(reg.athlete_id) event Event.query.get(reg.event_id) return jsonify({ success: True, message: 核验成功, athlete: { name: athlete.name, student_id: athlete.student_id, college: athlete.college }, event: { name: event.name, category: event.category }, status: reg.status }) except Exception as e: db.session.rollback() return jsonify({success: False, message: f系统错误{str(e)}}), 500参数说明与边界处理filter(Registration.status.in_([pending, confirmed]))确保已核验过的记录仍可查询详情但不再重复更新状态db.session.rollback()在异常时回滚事务避免数据库锁死前端调用示例JavaScriptfetch(/api/checkin, { method: POST, headers: {Content-Type: application/json}, body: JSON.stringify({code: ATHLETE:123:EVENT:45:TS:1715678901}) }).then(r r.json()).then(data { if(data.success) { showSuccessToast(${data.athlete.name} ${data.event.name} 核验成功); } });4. 成绩录入与PDF报表生成WeasyPrint实战避坑指南4.1 设计成绩录入页面用Jinja2模板实现动态表单渲染成绩录入页需根据项目类型田赛/径赛/趣味动态显示不同输入控件。例如跳远需输入“5.82米”100米需输入“12.34秒”两人三足需输入“第3名”。硬编码表单将导致每新增一个项目就要改一次HTML。!-- templates/enter_result.html -- form methodPOST h3{{ event.name }} - {{ athlete.name }}/h3 input typehidden nameregistration_id value{{ registration.id }} {% if event.category 田赛 %} label成绩米input typetext namescore pattern^\d(\.\d{1,2})?$ required/label {% elif event.category 径赛 %} label成绩秒input typetext namescore pattern^\d(\.\d{1,2})?$ required/label {% else %} label名次input typenumber namescore min1 required/label {% endif %} labelinput typecheckbox nameis_record 破校纪录/label button typesubmit提交成绩/button /form关键点pattern属性限制输入格式如田赛/径赛只允许数字最多两位小数减少后端校验负担namescore统一字段名后端无需为不同项目写不同参数解析逻辑is_record复选框值为布尔型Flask中用request.form.get(is_record) on即可转换。4.2 用WeasyPrint生成带样式的PDF成绩单解决中文字体与分页断裂问题WeasyPrint默认不支持中文字体直接渲染中文会显示方块。必须显式指定字体路径并处理长表格跨页断裂。# app/utils/pdf_generator.py from weasyprint import HTML, CSS from jinja2 import Environment, FileSystemLoader import os def generate_score_pdf(results_data, output_path): results_data: 列表每个元素为字典含athlete_name, event_name, score, is_record等字段 # 加载Jinja2模板 env Environment(loaderFileSystemLoader(templates)) template env.get_template(score_report.html) # 渲染HTML html_content template.render(resultsresults_data) # 指定中文字体以Noto Sans CJK SC为例需提前下载ttf文件 noto_font_path os.path.join(os.path.dirname(__file__), .., static, fonts, NotoSansCJKsc-Regular.ttf) # 构建CSS强制字体并处理分页 css_content f font-face {{ font-family: Noto Sans CJK SC; src: url({noto_font_path}) format(truetype); }} body {{ font-family: Noto Sans CJK SC, sans-serif; font-size: 12px; }} table {{ width: 100%; border-collapse: collapse; }} th, td {{ border: 1px solid #000; padding: 4px; text-align: center; }} /* 防止表格行在分页处断裂 */ tr {{ page-break-inside: avoid; }} css CSS(stringcss_content) # 生成PDF HTML(stringhtml_content).write_pdf(output_path, stylesheets[css]) return output_path # templates/score_report.html 示例 !DOCTYPE html html headtitle运动会成绩报表/title/head body h1 styletext-align:center;XX大学春季运动会成绩汇总/h1 table thead trth序号/thth运动员/thth院系/thth项目/thth成绩/thth是否破纪录/th/tr /thead tbody {% for r in results %} tr td{{ loop.index }}/td td{{ r.athlete_name }}/td td{{ r.college }}/td td{{ r.event_name }}/td td{{ r.score }}/td td{% if r.is_record %}✓{% else %}—{% endif %}/td /tr {% endfor %} /tbody /table /body /html 避坑要点字体文件必须放在项目内如static/fonts/不能引用系统字体路径否则Docker部署时失效page-break-inside: avoid是解决长表格跨页时“表头在上页、数据在下页”的关键CSSfont-face中src: url(...)的路径必须是绝对路径或WeasyPrint可访问的相对路径推荐用os.path.join拼接。5. 高并发与数据一致性避坑3个让系统上线即崩溃的典型问题5.1 现象报名高峰期出现“重复提交”导致同一运动员报同一项目两次原因前端未禁用提交按钮且后端仅靠try: db.session.add()... except IntegrityError捕获唯一约束冲突但SQLAlchemy的session.add()在事务提交前不触发数据库检查导致并发请求同时通过SELECT校验查无记录然后都执行INSERT最终只有一个成功另一个抛IntegrityError。解决在Registration模型中增加__table_args__复合唯一约束已在2.2节定义并在报名路由中改用INSERT ... ON CONFLICT DO NOTHINGPostgreSQL或INSERT IGNOREMySQL。SQLite用户需改用INSERT OR IGNORE并在插入后SELECT确认是否真正插入# 替代原生add()的方式SQLite兼容 db.session.execute( text(INSERT OR IGNORE INTO registration (athlete_id, event_id, status) VALUES (:aid, :eid, pending)), {aid: athlete_id, eid: event_id} ) db.session.commit() # 再查一次确认 reg Registration.query.filter_by(athlete_idathlete_id, event_idevent_id).first() if not reg: return 已报名请勿重复提交5.2 现象裁判同时录入成绩时最后提交者覆盖前面所有人的结果原因成绩录入接口未加锁多个请求并发读取同一Registration记录各自计算新Result后保存导致只有最后一个生效。解决使用数据库行级锁。在查询Registration时加with_for_update()强制其他事务等待# 成绩录入路由中 reg Registration.query.with_for_update().filter_by(idreg_id).first() if not reg: return jsonify({error: 报名记录不存在}), 404 # 此时其他请求会阻塞在此处直到当前事务commit result Result(registration_idreg.id, scorescore, is_recordis_record) db.session.add(result) db.session.commit() # 释放锁5.3 现象WeasyPrint生成PDF时CPU飙升100%5个请求就让服务器卡死原因WeasyPrint是CPU密集型操作每个PDF生成占用一个完整CPU核心未做并发限制。解决用concurrent.futures.ThreadPoolExecutor限制最大并发数并设置超时from concurrent.futures import ThreadPoolExecutor, TimeoutError import threading # 全局线程池最大3个线程处理PDF生成 pdf_executor ThreadPoolExecutor(max_workers3) def async_generate_pdf(*args, **kwargs): try: return pdf_executor.submit(generate_score_pdf, *args, **kwargs).result(timeout30) except TimeoutError: raise Exception(PDF生成超时请稍后重试) # 在路由中调用 try: pdf_path async_generate_pdf(results_data, output_path) except Exception as e: return jsonify({error: str(e)}), 500注意不要用ProcessPoolExecutorWeasyPrint内部有C扩展多进程可能导致字体渲染异常。6. 让系统真正“可用”的3个进阶技巧从能跑通到值得交付6.1 用Redis缓存高频查询把报名页响应从2.3秒压到320ms报名页需实时显示各项目剩余名额max_participants - COUNT(registration)每次渲染都要查N次数据库。引入Redis缓存后只需在报名/取消时更新缓存页面直取# app/utils/cache.py import redis from flask import current_app cache redis.Redis(hostlocalhost, port6379, db0, decode_responsesTrue) def get_event_remaining(event_id): key fevent:{event_id}:remaining cached cache.get(key) if cached is not None: return int(cached) # 缓存未命中查数据库 from app.models import Event, Registration event Event.query.get(event_id) if not event or event.max_participants 0: return 不限 used Registration.query.filter_by(event_idevent_id, statusconfirmed).count() remaining event.max_participants - used cache.setex(key, 300, remaining) # 缓存5分钟 return remaining # 在报名成功后更新缓存 cache.decr(fevent:{event_id}:remaining) # 或 incr取决于业务逻辑效果实测某高校项目中报名页平均响应时间从2300ms降至320msQPS从12提升至87。6.2 为裁判端设计离线优先策略Service Worker缓存静态资源比赛现场Wi-Fi不稳定但裁判扫码页HTML/CSS/JS和二维码图片必须秒开。用Service Worker实现离线缓存// static/sw.js const CACHE_NAME sports-checkin-v1; const urlsToCache [ /, /static/css/main.css, /static/js/checkin.js, /static/qr_codes/ ]; self.addEventListener(install, event { event.waitUntil( caches.open(CACHE_NAME) .then(cache cache.addAll(urlsToCache)) ); }); self.addEventListener(fetch, event { event.respondWith( caches.match(event.request) .then(response response || fetch(event.request)) ); });在run.py中注册Service Workerapp.route(/sw.js) def serve_sw(): response make_response(send_file(static/sw.js)) response.headers[Content-Type] application/javascript return response验证方法Chrome DevTools → Application → Service Workers → Check “Update on reload”然后断网刷新页面确认所有资源仍能加载。6.3 用日志分级与结构化输出定位真实问题生产环境不能只靠print()和app.logger.error()。必须用结构化日志按模块、操作、耗时打点# app/__init__.py 中配置 import logging from logging.handlers import RotatingFileHandler import json class JsonFormatter(logging.Formatter): def format(self, record): log_entry { timestamp: self.formatTime(record), level: record.levelname, module: record.module, function: record.funcName, line: record.lineno, message: record.getMessage(), duration_ms: getattr(record, duration_ms, 0) } return json.dumps(log_entry, ensure_asciiFalse) handler RotatingFileHandler(logs/app.log, maxBytes10*1024*1024, backupCount5) handler.setFormatter(JsonFormatter()) app.logger.addHandler(handler) app.logger.setLevel(logging.INFO) # 在关键路由中打点 app.route(/api/checkin, methods[POST]) def api_checkin(): start_time time.time() try: # ...原有逻辑... duration int((time.time() - start_time) * 1000) app.logger.info(Checkin success, extra{duration_ms: duration}) return jsonify({...}) except Exception as e: duration int((time.time() - start_time) * 1000) app.logger.error(Checkin failed, extra{duration_ms: duration, error: str(e)}) raise价值当收到“扫码慢”反馈时直接查日志过滤module:routes,function:api_checkin,duration_ms:[1000 TO *]5秒内定位是数据库慢还是网络慢。我坚持在每个新项目里强制落地这三条Redis缓存高频查询、Service Worker保离线、结构化日志定根因。它们不炫技但能让系统从“演示可用”变成“现场敢用”。去年帮某实验室部署时正是靠日志里的duration_ms字段发现WeasyPrint字体加载占了800ms换成本地ttf后整体PDF生成提速3倍——这种细节文档不会写但线上事故会教。希望帮到你。本文还有配套的精品资源点击获取