高校学科竞赛报名这件事,在校期间我作为参赛选手被折腾过,后来在教务处帮过忙,再后来直接接手开发这个管理系统。在很多高校里,学科竞赛的报名、审核、作品提交、成绩录入还停留在“Excel接力赛”的阶段:学生填表发给老师,老师汇总成一个大表,再挨个核对格式,还要防着漏交、重复提交、命名不规范。这套流程能跑,但很痛苦,而且一到比赛高峰期,各种信息错乱是家常便饭。
我当时接到这个需求——做一个面向高校学科竞赛的参赛申请管理系统,技术栈定的是Python + Flask + uniapp + 微信小程序。后端用Flask提供REST API,前端通过uniapp编译成微信小程序,覆盖学生报名、指导老师审核、管理员配置竞赛、作品上传、成绩公布几个核心闭环。这篇文章就围绕这个项目,聊聊我从零搭建这套系统的完整思路、核心细节、实现过程,以及真正跑起来之后踩过的那些坑。
1. 项目整体设计与思路拆解
1.1 场景痛点与核心需求解析
做系统之前,我先梳理了一下用户到底是谁、他们想要什么。
这个系统里有三类核心角色:
- 学生:需要在线填写报名信息、选择竞赛项目、上传报名材料、提交参赛作品、查看审核状态和最终成绩。他们的核心诉求是“别让我跑来跑去交纸质表”。
- 指导老师/学院负责人:需要审核学生提交的报名数据和作品,填写推荐意见,确认参赛资格。
- 管理员(校赛/院系管理员):维护竞赛目录、设置报名时间窗口、分组评审、录入成绩、导出统计报表。
除了角色,业务上还有几个硬性要求:同一个学生同一场竞赛只能报一次名(防止重复);超过截止时间不能再提交;一个团队赛允许多人组队,队长统一提交;作品上传有大小限制;审核流转的状态要清晰可追溯。
这个需求的本质其实就是一个带状态流转的表单收集与审核系统。概念的抽象很重要,因为这决定了你选型时是找现成的低代码平台,还是自己从零写。这次选择从零开发,核心原因有两个:一是学校网络环境相对封闭,第三方SaaS平台数据出校不便、政策上有顾虑;二是业务规则往后肯定要加(比如按学院限额、成员资格校验、评审分组等),自研系统扩展起来更主动。
1.2 技术选型:为什么是Flask + uniapp
这套组合不是随便拍的,每个组件都有它的理由。
先看后端。Flask在Python后端框架里属于“小而灵活”的路线。相比Django的全家桶风格,Flask只保留路由、请求上下文、模板渲染这些最小核心,数据库、表单、认证全部交给扩展组件自己拼装。我这次用的是Flask + Flask-SQLAlchemy + Flask-Migrate + PyMySQL + Flask-CORS,数据库选MySQL。这个组合的好处是,一旦项目跑起来,每一层你都能完全掌控,排错也不用在框架的复杂机制里绕来绕去。关键是,Flask的灵活性能让我们把接口设计得和业务一一对应,不会出现“框架帮你做了太多事,反而绑手绑脚”的尴尬局面。
再往前看,为什么不用Node.js或者Java?选择Python另一个现实因素是团队里做数据处理和模型训练的同事都用Python,让后端和后续的数据分析能力(比如评奖统计、作品查重)共用一套语言生态,沟通成本最低。如果你是一个人单干,Python入门曲线也相对平缓,遇到问题社区资料极其丰富。
再看前端。uniapp是Vue语法衍生出来的跨端框架,一套代码可以编译到微信小程序、App、H5等多个平台。这次目标是微信小程序,所以uniapp是我觉得最合适的方案。直接用微信原生小程序开发也可以,但对团队里不熟悉小程序的成员来说,学习成本高一点,而且代码没法复用到后续可能的App版本。uniapp保留了Vue单文件组件的开发习惯,同时能调用微信小程序的API,比如uni.login获取登录凭证、uni.uploadFile上传文件、uni.request发起请求,过渡是很平滑的。
这里要特别提一下热搜里那句“uniapp封装H5如何指向2个域名”。我们在实践中确实遇到过类似的场景:同一个跨端包,在微信小程序里走的是后端A的域名,而在H5里因为要跨域,后端地址是另一个网关。处理方式很简单,在uniapp项目根目录的config.js里配置两套环境变量:
// config.js export const ENV = { // 小程序编译环境 MP: { BASE_URL: 'https://api.school.edu.cn' }, // H5编译环境 H5: { BASE_URL: 'https://h5api.school.edu.cn' } }然后根据编译平台动态读取:
// request.js import { ENV } from '@/config.js' // #ifdef MP-WEIXIN const BASE_URL = ENV.MP.BASE_URL // #endif // #ifdef H5 const BASE_URL = ENV.H5.BASE_URL // #endif#ifdef是uniapp的条件编译注释,编译成小程序时只保留小程序段,编译成H5时只保留H5段,实际测试下来非常稳,这也算是uniapp替代多套原生代码的一个显著优势。
1.3 系统架构与数据流转设计
系统整体分三层:前端小程序层、Flask API层、MySQL数据层。
小程序层负责一切用户交互:报名表单、竞赛列表、审核进度、作品上传。它不直接读写数据库,所有操作都通过HTTP请求调用后端的REST API。Flask API层是系统的中枢,承担身份认证、业务逻辑、文件存储、数据校验。MySQL数据层存储用户、竞赛、报名单、作品文件路径和成绩记录等结构化数据。
数据流大概是这样的:
- 学生打开小程序,微信授权登录,后端通过微信的
code2session接口换取openid,并生成自定义token返回前端。 - 学生浏览竞赛列表,选择某一项赛事,点击“我要报名”,前端向后端提交报名表单数据。
- Flask校验报名时间窗口是否开放、该用户是否已经报名,校验通过后写入报名表并生成一个报名单号。
- 学生上传作品文件,Flask接收文件后保存到服务器指定目录(这里存本地磁盘,有条件可以上OSS),并把文件路径写入数据库。
- 指导老师登录后查看待审核列表,点击“通过”或“驳回”,Flask更新状态字段并记录操作时间。
- 管理员在后台管理系统配置竞赛信息,学生端小程序实时同步可见。
整套系统的核心就是这张报名表的状态机:
草稿 → 已提交 → 指导老师审核中 → 已通过/已驳回 → 作品已提交 → 评审中 → 已出成绩数据库里用status字段标识当前状态,每个状态的迁移都对应一组后端接口的幂等操作。这个设计让整个业务线索非常清晰,后续做报表统计、状态追溯都很方便。
2. 核心细节解析与实操要点
2.1 Flask后端的数据模型设计
数据库表设计是决定这个项目后期好不好扩展的关键。我建了6张核心表:user(用户)、competition(竞赛)、entry(报名单)、entry_member(团队成员)、work(作品)、review_record(审核记录)。
先说user表。这张表不只是存用户名密码,由于对接了微信小程序登录,还需要存openid、unionid、昵称、头像、学号、学院、专业、年级、角色。这里的角色我用的是role字段,取值范围是student、teacher、admin,基于整型值做存储性能更好,但直接用字符串可读性强,项目规模不大,优先选可读性。
class User(db.Model): __tablename__ = 'user' id = db.Column(db.Integer, primary_key=True) openid = db.Column(db.String(64), unique=True, index=True) nickname = db.Column(db.String(64)) avatar = db.Column(db.String(255)) student_id = db.Column(db.String(32)) college = db.Column(db.String(64)) major = db.Column(db.String(64)) grade = db.Column(db.String(16)) role = db.Column(db.String(16), default='student') created_at = db.Column(db.DateTime, default=datetime.now)competition表存竞赛的基本信息、报名开始时间、截止时间、竞赛等级(国家级/省级/校级)、允许的最大团队人数、竞赛说明文档链接。这里有一个非常容易踩坑的点:时间字段必须统一用DateTime类型,并且所有的时间比较都放在后端做,不要信任前端传来的时间。前端传过来的时间是用户本地的,和服务器时间有偏差,一旦有人把手机时间调快,就有了钻空子的机会。
entry表表示一次报名。字段包括competition_id、leader_id、team_name、status、teacher_id(指导老师)、submitted_at等。entry_member表则是团队成员关联表,记录哪些user在哪个报名单里。
work表存作品相关信息。作品文件路径file_path、文件大小file_size、作品名称work_name、提交时间submit_time、版本号version。为什么要版本号?因为学生很可能需要覆盖提交作品,尤其是在截止日前反复修改。一次报名对应多个作品版本,取最新一版用于评审,这样既保留轨迹又不会让评审拿到错误版本。
review_record表做审核流水。字段包括entry_id、reviewer_id、action(通过/驳回)、comment(意见)、created_at。这张表在出现纠纷的时候作用巨大:谁审核的、什么时候审核的、写了什么意见,全部留痕。高校场景下这种审计能力是刚需。
2.2 微信小程序登录与Flask侧身份认证
微信小程序登录是整套系统的门外汉第一道关。流程上非常标准:小程序端调用uni.login拿到临时code,前端把code发给后端;后端拿着code去微信接口服务换openid和session_key;拿到openid后在数据库里查用户是否存在,不存在就自动注册一个;随后生成自己的登录凭证返回给小程序端。
这里有两个容易被忽略的细节。
第一,wx.login返回的code有效期只有5分钟,且只能用一次。所以后端接口拿到code后必须立即调用微信接口,不要做任何耗时的中间操作。我一开始图省事,在微信换openid前先往本地日志表里写了一行记录,结果在高并发下偶尔会出现“code无效”的错误,排查了半天定位到这个问题。日志写操作虽然只有几十毫秒,但在密集报名时段仍然可能挤压code有效期窗口。
第二,不要用自己的token去替代微信登录态而完全抛弃session_key。登录成功之后,后端可以生成一个随机token返回前端,后续请求通过Authorization头携带。这个token在服务端映射到用户ID。session_key则留着解密手机号和微信运动等敏感数据用,短期内不需要。
最终实现放在一个装饰器里,统一做身份校验:
from functools import wraps import jwt def login_required(f): @wraps(f) def decorated_function(*args, **kwargs): auth_header = request.headers.get('Authorization') if not auth_header: return api_response(code=401, message='未登录或登录已过期') try: payload = jwt.decode(auth_header, SECRET_KEY, algorithms=['HS256']) current_user = User.query.get(payload['user_id']) if not current_user: return api_response(code=401, message='用户不存在') request.current_user = current_user except jwt.ExpiredSignatureError: return api_response(code=401, message='登录已过期') except jwt.InvalidTokenError: return api_response(code=401, message='无效的登录凭证') return f(*args, **kwargs) return decorated_function登录态有效期我设的是7天,配合请求拦截器实现每分钟自动续期。小程序端一旦收到后端401响应,统一跳转到登录页重新授权,这个过程用户无感,体验流畅。
2.3 报名与作品上传的接口设计
接口设计遵循RESTful风格,POST表示创建,PUT/PATCH表示更新,GET表示查询。
报名模块的核心接口是:
POST /api/entry/create创建报名单POST /api/entry/<id>/submit提交报名(状态从草稿→已提交)POST /api/entry/<id>/upload上传作品GET /api/entry/<id>查询报名详情GET /api/entry/my/list我参与的报名列表
创建报名单的参数里,除了基本信息,还会传is_team字段区分个人赛和团队赛。如果是团队赛,后续会调用成员邀请接口来增删队友。这里有个业务约束:个人不能同时参与同一竞赛的两支队伍。所以里创建一个逻辑:创建报名单时检查一下数据库,如果当前用户在同一个competition_id下已存在status != 'cancelled'的记录,就拒绝创建。
上传作品是重头戏,接口用uni.uploadFile配合Flask的request.files接收文件。核心校验有四个维度:文件扩展名白名单、文件大小上限(我设为200MB,可配置)、文件类型检查(不只是扩展名,还读文件头,防止有人改了扩展名传不可执行文件)、以及并发覆盖控制。
ALLOWED_EXTENSIONS = {'zip', 'rar', 'pdf', 'doc', 'docx', 'ppt', 'pptx', 'mp4', 'jpg', 'png'} def allowed_file(filename): return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS @app.route('/api/entry/<int:entry_id>/upload', methods=['POST']) @login_required def upload_work(entry_id): entry = Entry.query.get_or_404(entry_id) if entry.status != 'approved': return api_response(code=403, message='当前状态不允许上传作品') file = request.files.get('file') if not file: return api_response(code=400, message='未接收到文件') if not allowed_file(file.filename): return api_response(code=400, message='不支持的文件类型') # 实际文件类型校验 ... file_size = 0 # 流式写入服务器磁盘 ...文件命名这一条特别注意:永远不要用前端传来的原始文件名直接落盘,会有两个问题,一个是中文名或特殊字符可能导致服务器存储异常,另一个是同名文件相互覆盖。我的做法是在后端生成一个UUID + 时间戳的新文件名,把原始文件名记录到数据库字段里,下载的时候再响应给客户端。
2.4 uniapp小程序的页面结构与状态管理
小程序端页面结构按角色划分:
- 学生端首页:展示竞赛列表(Banner轮播、倒计时组件、报名入口),我的报名(列表、状态标签、详情),个人信息(头像、学号、学院)。
- 报名流程页:竞赛详情 → 填写表单 → 添加成员 → 提交成功。表单用
uni-forms做校验,比如手机号11位、学号8位、邮箱格式。 - 作品上传页:选择文件、上传进度条、上传成功/失败状态、历史版本列表。
- 管理端(教师/管理员):审核列表、报名详情、审核通过/驳回按钮、成绩录入弹窗、统计报表页。
页面之间传参,用uni.navigateTo的url带参数,但有一个红线:不要在URL里传大段对象或者用户敏感数据。正确的姿势是URL只传ID,页面加载后再通过uni.request从后端拉详情。这既是安全考虑,也能避免页面回退时数据陈旧。
状态管理这一层,我用Vuex配合uni.setStorageSync做了一个简单的持久化token管理。具体实现是:登录成功后把token和用户基本信息写入Vuex,同时存一份到Storage。每次uni.request的请求头动态绑定token。一个关键点是token失效的全局处理:在响应拦截器里判断HTTP状态码,如果是401,调用一个全局的logout方法,清掉本地缓存并跳转登录页,避免用户看到一堆报错弹窗。
// request.js 拦截器 export function request(options) { return new Promise((resolve, reject) => { uni.request({ url: BASE_URL + options.url, method: options.method || 'GET', data: options.data || {}, header: { 'Authorization': uni.getStorageSync('token'), 'Content-Type': 'application/json' }, success: (res) => { if (res.data.code === 401) { uni.removeStorageSync('token') uni.navigateTo({ url: '/pages/login/login' }) return } resolve(res.data) }, fail: (err) => reject(err) }) }) }2.5 审核流程与状态机实现
审核流程是整个系统业务逻辑上最容易出错的部分,因为它涉及状态的并发修改。我在实现时把状态机的所有合法迁移定义成了一个字典:
ALLOWED_TRANSITIONS = { 'draft': ['submitted'], 'submitted': ['teacher_reviewing'], 'teacher_reviewing': ['approved', 'rejected'], 'approved': ['work_uploaded', 'cancelled'], 'work_uploaded': ['under_review', 'work_uploaded'], # 允许覆盖上传,状态不变 'rejected': ['submitted'], # 驳回后允许重新提交 'under_review': ['final_approved', 'final_rejected'], 'final_approved': ['score_published'], }每次状态变更先校验是否在ALLOWED_TRANSITIONS里,不在就返回错误。这一步看似繁琐,实际上是防呆的关键。有一次我临时为了赶进度,在审核通过接口里直接改了entry.status = 'approved',忘了走统一的状态变更函数,结果负责驳回的同事那边状态永远不同步,后来花了几个小时排查。自从引入状态机校验,这类问题基本绝迹。
还有一个细节:学生提交作品后,状态应该由谁来推进?我的设计是:学生上传作品后,状态从approved变成work_uploaded;等到管理员点击“开始评审”,批量把该竞赛下所有work_uploaded的记录变成under_review。评审完成后,管理员在后台录入每个学生或队伍的成绩,系统自动将状态改为score_published,学生端可见成绩。
这里引入了一个被热搜词提及的操作——“微信小程序设置缓存时间”。我们处理的方式是,前端拉取竞赛列表时设置一个5分钟的缓存,减少后端压力,但是审核状态、成绩这些关键数据不走缓存,永远实时拉取。否则学生会看到审核通过了但前端还显示“待审核”的诡异状态,那是很尴尬的。
3. 实操过程与核心环节实现
3.1 从零初始化Flask项目
新项目不用flask-bootstrap之类的脚手架,我习惯手动搭一个目录结构,好处是每一层都清楚,出了问题定位快。
project/ ├── app/ │ ├── __init__.py # 初始化Flask应用、注册蓝图 │ ├── config.py # 配置(数据库、文件目录、密钥) │ ├── models/ # SQLAlchemy模型 │ ├── api/ # API蓝图 │ │ ├── auth.py │ │ ├── competition.py │ │ ├── entry.py │ │ ├── work.py │ │ └── admin.py │ ├── utils/ # 装饰器、文件校验、微信工具 │ └── services/ # 核心业务逻辑层 ├── uploads/ # 作品文件存储目录 ├── requirements.txt └── run.py用工厂函数模式初始化Flask应用:
def create_app(): app = Flask(__name__) app.config.from_object(Config) db.init_app(app) migrate.init_app(app, db) CORS(app, supports_credentials=True) from app.api.auth import auth_bp from app.api.competition import competition_bp from app.api.entry import entry_bp from app.api.work import work_bp from app.api.admin import admin_bp app.register_blueprint(auth_bp, url_prefix='/api/auth') app.register_blueprint(competition_bp, url_prefix='/api/competition') app.register_blueprint(entry_bp, url_prefix='/api/entry') app.register_blueprint(work_bp, url_prefix='/api/work') app.register_blueprint(admin_bp, url_prefix='/api/admin') return app蓝图的划分,是为了让路由管理不混乱。系统功能变多以后最怕的是路由都堆在一个文件里,找接口要翻几百行,排查效率极低。
3.2 开发环境配置与依赖清单
requirements.txt我盯了一个固定清单:
Flask==2.2.5 Flask-SQLAlchemy==3.0.5 Flask-Migrate==4.0.4 PyMySQL==1.0.2 Flask-CORS==4.0.0 PyJWT==2.8.0 requests==2.31.0 python-dotenv==1.0.0 gunicorn==20.1.0版本一定要锁死。不止一次踩过“本地好端端的,服务器上装成另一个大版本直接跑崩”的坑,尤其是Flask-SQLAlchemy和SQLAlchemy的2.x大版本变动,API差异会直接导致ORM查询风格报错。
本地开发跑起来很简单:
python -m venv venv source venv/bin/activate # Windows是venv\Scripts\activate pip install -r requirements.txt flask --app run.py db init flask --app run.py db migrate -m "initial migration" flask --app run.py db upgrade python run.py数据库连接串配置放.env文件,因为里面有账号密码,绝不能提交到Git仓库:
DATABASE_URL=mysql+pymysql://root:your_password@localhost:3306/competition_db?charset=utf8mb4 SECRET_KEY=your-secret-key WX_APPID=your-wx-appid WX_SECRET=your-wx-secret这里有个不得不提的细节:连接串必须加charset=utf8mb4,否则用户昵称里带个Emoji表情直接写入报错。UTF-8在很多MySQL版本里实际上是utf8mb3,存不了四字节Emoji,这个问题极其隐蔽,用户输入一个表情就会触发数据库异常。
3.3 微信小程序前端开发:从HBuilderX到真机预览
前端这一侧我用的开发工具是HBuilderX,熟练的可以直接用Vue CLI创建uniapp项目,但对多数人来说HBuilderX的集成体验更省心。新建项目选择“uni-app”模板,然后引入项目所需的页面、组件和工具函数。页面结构大概是:
pages/ ├── index/index.vue # 首页(竞赛列表) ├── competition/detail.vue # 竞赛详情 ├── entry/form.vue # 报名表单 ├── entry/myList.vue # 我的报名 ├── work/upload.vue # 作品上传 ├── admin/reviewList.vue # 审核列表 ├── admin/reviewDetail.vue # 审核详情 ├── admin/scoreInput.vue # 成绩录入 └── login/login.vue # 登录页报名表单是最容易让前端写崩的页面。字段多、校验规则多、提交反馈要即时。我使用的是uni-forms配合rules校验对象:
const rules = { studentName: { rules: [{ required: true, errorMessage: '请输入姓名' }] }, studentId: { rules: [{ required: true, errorMessage: '请输入学号' }, { pattern: /^\d{8,12}$/, errorMessage: '学号格式不正确' }] }, phone: { rules: [{ required: true, errorMessage: '请输入手机号' }, { pattern: /^1[3-9]\d{9}$/, errorMessage: '手机号格式不正确' }] }, email: { rules: [{ required: true, errorMessage: '请输入邮箱' }, { format: 'email', errorMessage: '邮箱格式不正确' }] }, college: { rules: [{ required: true, errorMessage: '请选择学院' }] }, teacher: { rules: [{ required: true, errorMessage: '请输入指导老师姓名' }] } }每次用户提交,先走前端uniForms.validate(),通过后再发起请求。前端校验不能替代后端校验,但能省去大量无效请求,用户也不用等服务器往返就能发现填错。
小程序真机预览必须把“微信开发者工具”的本地服务端口打开,在HBuilderX里点“运行到微信开发者工具”。这一步卡过我不少时间,还有个小坑:用HBuilderX创建的uniapp项目,默认结构能正常编译,但如果你手动改了项目路径或者换了电脑,一定要在“manifest.json -> 小程序配置 -> 微信小程序AppID”里确认填的是自己的AppID而不是测试号。用测试号会导致无法调用部分需要企业认证的API,比如获取手机号。
3.4 数据表迁移与初始化数据
数据库迁移用Flask-Migrate,核心是处理好“模型改动的版本记录”。每次修改模型文件后,执行:
flask --app run.py db migrate -m "add work version field" flask --app run.py db upgrademigrate会自动对比模型和数据库的差异生成迁移脚本。这里有个提示:迁移脚本生成后一定要人工过一遍,尤其是字段重命名或删除时,自动脚本可能把数据弄丢。我们有一次给entry表加外键,自动生成的迁移脚本里先删表再重建,差点把已经录入的几十条测试数据冲掉,改成手动编写的ALTER TABLE才保住数据。
初始化数据的话,我在系统部署时写了一个seed.py脚本,用来插入管理员账号、默认学院列表、常见竞赛项目(如“全国大学生数学建模竞赛”“挑战杯”等)。这套种子数据在后端初始化时跑一次,可以节省很多手工录入时间。
3.5 文件上传:大文件分块与断点续传方案
作品文件动辄几十MB甚至上百MB(比如数学建模的论文PDF、程序设计赛的源码压缩包),在小程序环境里一次性上传风险比较高。uni.uploadFile原生接口虽然简单,但对弱网用户极不友好,经常传一半失败,然后整个文件作废重新传。
考虑到大部分校赛场景的网络环境还是校园WiFi或者流量,我采用的是Flask后端接收分块上传的方案。思路是前端把文件切成每块5MB大小,循环调用后端接口,后端把每个分块写到临时目录,最后一个分块上传完成后触发合并。这里贴一下后端合并文件的核心逻辑:
@app.route('/api/work/<int:entry_id>/upload_chunk', methods=['POST']) @login_required def upload_chunk(entry_id): chunk_index = request.form.get('chunkIndex', type=int) total_chunks = request.form.get('totalChunks', type=int) file_id = request.form.get('fileId') chunk = request.files.get('chunk') temp_dir = os.path.join(app.config['UPLOAD_TEMP_DIR'], str(entry_id), file_id) os.makedirs(temp_dir, exist_ok=True) chunk.save(os.path.join(temp_dir, f'chunk_{chunk_index}')) # 所有分块都传完了,合并 if chunk_index == total_chunks - 1: final_path = os.path.join(app.config['UPLOAD_DIR'], f'{uuid4().hex}.zip') with open(final_path, 'wb') as outfile: for i in range(total_chunks): chunk_path = os.path.join(temp_dir, f'chunk_{i}') if not os.path.exists(chunk_path): return api_response(code=500, message='分块缺失,请重传') with open(chunk_path, 'rb') as infile: outfile.write(infile.read()) # 清理临时目录 shutil.rmtree(temp_dir) # 数据库记录作品信息 ... return api_response(code=200, message='上传完成') return api_response(code=200, message='分块接收成功')前端配合做的是,每次上传前先向后端发一个“创建上传任务”请求,拿到fileId,然后循环分块。如果某个分块失败,可以单独重传该分块,不用整体重来。这个方案实测下来上传一个100MB的视频作品,在稳定校园网环境下大约几十秒完成,弱网环境下体验也远比整包上传好。
3.6 部署上线:从开发机到服务器
本地开发用的python run.py是Flask自带的开发服务器,只能用于调试。部署到服务器我用的方案是Gunicorn + Nginx。Gunicorn启动Flask应用:
gunicorn -w 4 -b 127.0.0.1:8000 "app:create_app()"用4个worker进程处理并发请求,Nginx做反向代理,静态文件(上传的作品文件)直接由Nginx提供,不走Python进程。这里有个性能痛点:千万不要把作品文件直接存在Flask的静态目录里并让Flask去读。文件下载走Python太慢,高并发下直接打满CPU。让Nginx直接alias指向uploads目录,下载效率提升了一个数量级。
server { listen 80; server_name api.school.edu.cn; location /api/ { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location /static/ { alias /var/www/competition/uploads/; } }HTTPS证书的配置用的是Let's Encrypt免费证书,设置自动续期。微信小程序后台要求所有请求域名必须是HTTPS且已在“request合法域名”里配置,所以HTTPS是硬需求,没有商量的余地。
4. 常见问题与排查技巧实录
4.1 微信小程序request请求报“域名不合法”
这个问题几乎每个人都会碰到。开发阶段你可以到微信开发者工具里勾选“不校验合法域名”,方便本地调试。但正式上线如果不处理好,所有请求都会失败。
解决方案分三步:第一,把生产环境的后端域名加进微信公众平台后台的“开发管理 -> 服务器域名 -> request合法域名”;第二,确保域名备案和HTTPS证书有效;第三,如果用了uni.uploadFile,还需要在uploadFile合法域名里同样配置一条。我之前只配了request域名,忘记配uploadFile域名,结果作品上传功能一到真机就失败,开发工具里却一切正常,折腾了一下午才定位到。
解决办法是把HTTP请求的401响应和Token失效统一封装,前端请求拦截器处理这类异常时调用uni.reLaunch跳转登录页,而不是原地弹窗提示。用户感知到的是“重新进场”,而不会看到一个刺眼的红色报错。
4.2 Flask跨域问题与CORS配置
会话保持之后,如果你用了from flask import session做登录态管理,那你必须把密码也配置好,同时前端必须允许携带Cookie。不过我在这个项目里用的是JWT,请求头Authorization传token,跨域问题比Cookie模式简单很多。开发阶段为了方便调试,我直接全开CORS:
from flask_cors import CORS CORS(app, supports_credentials=True)上线前收紧策略,只允许学校域名的请求。注意Flask-CORS的supports_credentials=True必须和前端请求的withCredentials/credentials: 'include'保持同步,否则浏览器会拦截响应。
4.3 数据库连接断掉:MySQL的8小时问题
部署之后出现过一次诡异故障:系统刚上线时一切正常,第二周早上有学生反馈报名失败,后端日志里全是Lost connection to MySQL server during query。原因很经典:MySQL默认的wait_timeout是8小时,连接池里的连接空闲超过这个时间就会被服务端关闭,但Flask-SQLAlchemy的连接池并不知道,继续拿着失效连接去查询,于是报错。
解决方案有两个:一是改MySQL配置把wait_timeout调大到一天;二是让连接池自动回收过期连接。我用的是后者:
SQLALCHEMY_ENGINE_OPTIONS = { 'pool_size': 10, 'pool_recycle': 3600, 'pool_timeout': 30, 'pool_pre_ping': True }pool_pre_ping是神器,每次从连接池拿连接前先ping一下,如果失效就重建。设置pool_recycle为3600秒,确保连接不会存活超过MySQL的回收时间。这两种手段双管齐下,之后再没出现过这个问题。
4.4 微信小程序视频下载与缓存控制
热搜词里有“微信小程序中的视频下载”和“微信小程序设置缓存时间”,我在作品展示模块里正好涉及。
我的作品展示页面需要支持视频播放和缓存。微信小程序的video组件默认不支持直接下载视频文件,它只负责播放。因此我在上传作品时,单独设计了一个资源列表接口,返回视频的URL以及授权凭证。同时,在播放器组件上配置custom-cache属性,让视频在后台合理缓存。这看起来简单,但要做对,关键是不要把视频文件的URL直接暴露给未登录用户,否则别人可以直接拿到链接到处转传。我的做法是在后端生成带签名的临时URL,有效期30分钟,过期自动失效:
def generate_signed_url(file_path): expire_at = int(time.time()) + 1800 token = hashlib.md5(f'{file_path}-{expire_at}-{SECRET_KEY}'.encode()).hexdigest() return f'/api/work/preview?path={file_path}&expire={expire_at}&sign={token}'这个设计有效限制了作品的传播范围,尤其是在校赛这种对知识产权比较敏感的场合。
4.5 前端列表性能优化:避免一次性拉全量数据
竞赛列表和报名列表随着使用时间推进会越来越长。一开始我的分页接口写得懒,直接Entry.query.all()返回所有数据,到了有几百条报名记录时,前端在小程序里渲染列表已经肉眼可见地卡顿。优化成Flask-SQLAlchemy分页查询后:
page = request.args.get('page', 1, type=int) per_page = request.args.get('per_page', 10, type=int) paginated = Entry.query.filter_by(...).paginate( page=page, per_page=per_page, error_out=False ) return api_response(code=200, data={ 'items': [entry.to_dict() for entry in paginated.items], 'total': paginated.total, 'page': page, 'pages': paginated.pages })配合前端的onReachBottom触底加载下一页,滚动加载的体验非常顺滑。如果数据量更大,还可以在后端加select * from entry where id < cursor order by id desc limit 10的游标分页,但在校赛这种量级(几千条以内)用传统分页就够了。
4.6 条件编译:小程序和H5行为差异化
热搜词里还有一条“uniapp 开发 微信小程序 vs android / ios / 鸿蒙”。uniapp的跨端优势不能神化,有些API在微信小程序里能用但在H5里没有,反之亦然。我在这个项目里深有体会的是uni.chooseMessageFile这个API——它在微信小程序中可以选取聊天文件,但在H5端根本没有这个API。
所以有条件地使用条件编译来隔离差异:
// 选择作品文件 chooseFile() { // #ifdef MP-WEIXIN uni.chooseMessageFile({ count: 1, extension: ['.zip', '.pdf', '.doc', '.docx', '.mp4'], success: (res) => { this.selectedFile = res.tempFiles[0] } }) // #endif // #ifdef H5 // H5端用input[type=file]即可 // #endif }条件编译处理不当会造成“编译到某个平台直接白屏”的问题。我的经验是关键平台的差异代码一定要真机验证,不要只在开发者工具上看效果。开发者工具模拟的API行为和真机有些细微差别,比如蓝牙、扫码、音视频播放这类能力,模拟器往往是“看起来能用,真机才暴露问题”。
4.7 审核死锁与并发修改问题
这个问题排查起来比较有意思。两位指导老师同时审核同一个学生报名时,后端的逻辑可能产生死锁。比如老师A先读取了这个报名单,老师B也读取了,A更新状态为“通过”,B紧接着把状态覆盖为“驳回”。最后的记录取决于谁后写,而不是谁先审核,这显然不合理。
解决方案有两层。第一层是乐观锁:在entry表加一个version字段,每次更新前检查当前版本号是否和读取时一致,不一致则提示“数据已被他人修改,请刷新后重试”。
entry = Entry.query.filter_by(id=entry_id, version=version).first() if not entry: return api_response(code=409, message='数据已被其他操作修改,请刷新') # 更新业务字段 entry.status = new_status entry.version += 1 db.session.commit()第二层是状态机前置校验。因为状态机的合法迁移集合已经限制了脏操作,即使并发到来,不符合迁移规则的状态更新也会被拦截。这两个策略配合下来,审核操作基本不会出现脏写。
5. 系统测试与上线后的体验反馈
5.1 功能测试重点清单
上线前我做了一套针对核心功能的测试清单,覆盖了每个可能出问题的场景:
- 学生首次微信授权登录,能自动注册并绑定学号。
- 同一个学生同一竞赛重复报名会被拦截。
- 团队报名中队长退出后,队员能看到报名单并继承队长权限。
- 截止时间到达后,报名按钮置灰,后端接口同样拒绝写入。
- 作品上传断网再恢复,分块能从断点继续传。
- 驳回的报名单允许学生修改后重新提交,状态重置为审核中。
- 管理员结束后台评审,学生端查询接口返回最终成绩和证书编号。
- 并发审核同一报名单时,后提交的一方收到版本冲突提示。
这套清单不需要写自动化脚本,手动过一遍耗时大半天,但对一个中小型校赛系统来说已经能覆盖绝大多数风险。
5.2 真实上线场景中的性能表现
系统上线后经历了学校一年一度的程序设计大赛,报名高峰在一个晚上集中爆发。学生宿舍晚间统一开放报名后,一分钟内大约有500个请求涌入,后端4个Gunicorn worker的配置扛住了这个量级,响应时间维持在200ms以内。数据库端的报名唯一约束在并发下也发挥了作用,没有出现一条重复报名。
高峰期之后我看了后端日志,发现真正的性能瓶颈不在API处理,而在文件上传时磁盘IO和静态文件下载的带宽占用。这也验证了为什么文件目录必须交给Nginx托管,而不是让Python进程来处理。
6. 经验总结与后续扩展建议
6.1 几个踩坑之后形成的习惯
这套系统做完以后,我给自己总结了几条铁律,写在这里对后来者应该也有用。
第一,永远让后端做最终的数据校验。前端校验再丰富也只是用户体验优化,不是数据安全的防线。有一次我临时把前端一个必填字段去掉了校验,结果后端接口也有个逻辑漏洞没拦住空值,数据库里就多了一条没有姓名只有学号的脏数据,最后靠写SQL清理。从那以后所有CRUD接口里都套了一个统一的参数校验器,绝不信任前端传来的任何字段。
第二,所有时间字段统一存UTC时间,展示层再转本地时区。这个习惯一开始觉得麻烦,但经历了一次服务器时区设置错误导致报名截止时间错乱的事故后,发现这个习惯是无价的。后端存储用datetime.utcnow(),返回给前端的ISO时间带时区偏移,前端拿到以后用dayjs转本地时间展示,逻辑无比清晰。
第三,写任何接口都先想清楚幂等性。用户在小程序里可能因为网络抖动重复点击提交按钮,如果你的接口没有幂等控制,数据库里就会插入多条重复记录。我的方案是前端在提交按钮点击后立即置灰,同时后端在事务里做唯一索引校验,双保险。
6.2 可扩展的方向:从校赛系统到竞赛全流程平台
这个系统目前的边界是“参赛申请与作品管理”,但高校学科竞赛的完整生命周期不止这些。后续可以扩展的方向包括:自动生成参赛成绩汇总报表(对接教务系统)、证书在线生成和下载、校内选拔赛的在线评委打分系统、优秀作品展示库。其中评委打分这块最值得做,评委进入系统后只能看到匿名化编号的作品,打分后自动汇总排名,全程留痕,能极大减轻学科竞赛组织者的工作负担。
还有一个实用的小功能——微信服务通知。学生报名成功、审核通过、作品被退回、成绩公布这四个节点,都通过订阅消息推送到学生微信。这门技术在小程序端只需要维护一个template_id,后端在学生操作对应事件时调用微信的订阅消息接口即可,学生端的提醒体验会显著优于小程序内小红点。
6.3 针对高校运维环境的两点贴心建议
最后补两个比较实际的部署建议。第一,高校的服务器可能在内网环境,外网访问需要通过防火墙策略,所以一定要提前跟信息化中心确认好:后端端口是否对外开放、HTTPS证书如何申请、是否需要走统一认证网关。这些在项目启动前沟通清楚,能省掉上线前的一大堆麻烦。
第二,定期备份数据库。校赛系统虽然是中小型应用,但学生数据和企业数据一样需要重视。我的方案是写一个crontab任务,每天凌晨把MySQL库dump成SQL文件,保留最近30天的备份。这一条建议无足轻重,但真到数据丢了的时候,你会庆幸自己有备份。
这套系统从开发到上线,前后用了一个半月,代码量不算大,但麻雀虽小五脏俱全:微信登录、角色权限、复杂表单、文件上传、状态机、条件编译、性能优化、部署调试,该经历的坑几乎都经历了一遍。如果让我重新做一遍,我大概率还是会选同样的技术栈,不是因为它是最潮的,而是因为它足够简单直接,能让我把精力集中在解决业务问题上。希望这篇拆解能帮到正在做类似高校管理系统的朋友,少走一点弯路。