我最近刚把一个典型的“讲师+学员”学习类小程序完整跑通了。后端是Python Flask写的数据接口,前端是微信小程序,核心功能就两块:一块是学习视频课程,一块是知识题库。讲师能上传视频、维护题目和看学员数据,学员可以看课、刷题、练错题,整体就是一个面向培训、教育和内部考核场景的轻量SaaS工具。
这个项目适合谁参考?如果你正准备做教育类小程序,或者你是后端工程师想用Flask快速给小程序提供API,又或者你手上正好有一个“课程+题库”的业务需求不知道怎么拆模块,那这篇整理值得看完。我会把方案选型、数据库设计、核心接口、部署踩坑全部按实操顺序写出来,代码都是可以直接抄走改用的。
1. 项目整体设计与方案选型
1.1 为什么后端选Flask而不是Django或Node
先说结论:这个项目用Flask是非常合适的,原因有三个。第一,业务逻辑不算重,主要就是课程管理、题库管理、答题判分、学习记录这几条链路,不需要Django自带的Admin后台、ORM迁移体系、用户认证体系全套上来,杀鸡不用牛刀。第二,Python生态里做算法、做数据处理、做爬虫的同事很多,Flask的门槛比Spring Boot低,一个后端同学半天就能把骨架搭起来。第三,Flask轻量意味着可控,路由、蓝图、请求钩子都是显式的,出问题好排查。
我见过很多团队一上来就上Django REST Framework,结果model、serializer、viewset层层封装,等到小程序端需要一些特殊联调接口时,反而被框架的写法绑架。Flask没这个毛病。当然,如果项目后来要上复杂后台管理、自动化权限、工作流引擎,那再迁Django也不迟,前期用Flask快速验证业务才是正确的节奏。
1.2 为什么学员端用小程序而不是App或H5
这个项目选微信小程序,核心理由是分发成本极低。学员扫码就能进,不用装App,不用注册账号,微信授权一键登录,对企业培训这种场景非常友好。讲师端我建议做成小程序内的一个角色切换页面,不要单独再开发一个App,否则维护成本翻倍。管理后台可以用Web,因为讲师上传视频、批量导入题库这些操作需要大屏和文件上传,web端配合Flask后台更顺手。
这里有个架构细节:前端分两端,学员小程序和讲师管理页,但后端API只维护一套。小程序端通过wx.request调用Flask接口,管理页通过axios调用同一套接口,只是登录接口区分角色,再在服务端做权限校验。这样模块边界清晰,也方便后期把管理页换成小程序里的讲师Tab。
1.3 整体功能模块怎么拆
我当时把项目拆成了六个模块:用户认证、课程管理、视频学习、题库练习、答题记录、统计面板。每个模块对应一组Flask蓝图,避免所有路由堆在app.py里。小程序端则对应TabBar:首页课程列表、学习中心、我的。讲师在个人中心里进入讲师模式,能看到“我上传的课程”“我出的题”“学员答题统计”。
这种结构的优势是:每一块都可以独立开发、独立自测。比如先把用户认证跑通,再接课程列表,再接视频播放,再接题库练习,最后做统计。联调时如果某一环出错,能快速定位是前端传参问题、后端逻辑问题还是数据库数据问题。
2. 核心细节与数据模型设计
2.1 三张核心表:用户、课程、题目
后端数据模型是整个项目的地基,设计得好,后面开发效率翻倍。我最终落地的表结构大致如下:
用户表(user)核心字段:id、openid、nickname、avatar、role(学员/讲师/管理员)、created_at。openid是微信小程序用户的唯一标识,role字段控制权限。
课程表(course)核心字段:id、title、cover_url、intro、price、category、lecturer_id、status、created_at。课程表存的是课程元信息,不存视频本体。
题目表(question)核心字段:id、course_id(可空,表示通用题还是绑定课程)、type(单选/多选/判断)、content、options_json、answer、analysis、difficulty、created_by、created_at。
我用options_json存选项,格式是JSON数组,比如["A", "B", "C", "D"]对应的文本,answer字段单独存正确答案。为什么不拆成选项表?因为选择题的选项基本不会单独被查询和修改,存JSON字段实现起来最简单,查询也不用JOIN。
2.2 视频不直接存MySQL,只存URL和时长
如果直接把MP4文件传送到Flask服务器,再让小程序播放,那带宽很快被打满,而且Flask同步服务器天然不适合处理大文件流。我采用的是:视频上传到OSS对象存储,数据库里只存object_key、cover_url、video_url、duration三列。
前端拿到video_url后,直接用video组件播放。这里有个注意点:小程序video组件的src支持HTTPS的mp4地址,但如果你用OSS的私有Bucket,URL会带签名参数,签名过期后播放会失败。我的做法是视频上传完成后生成一个长期有效的公开读URL,或使用CDN加速域名,避免学员学习中途视频加载失败。
2.3 题库字段设计与防刷机制
题库除了基础字段,还要考虑答题场景。我的方案是增加answer_index字段,用数字存正确选项下标;分析字段analysis存答案解析;difficulty字段分1到3级,方便按难度抽题。提交答案的接口不能返回答案明文,只能在判分后返回对错和解析,否则学员用抓包工具看一眼接口就全抄了。
防刷方面做了两个限制:同一用户对同一道题提交答案后,服务端记录答题日志;单用户每分钟答题次数超过阈值就暂时限流。这个限流逻辑用Flask的before_request钩子加内存字典就能实现,不需要上Redis,单机演示完全够用。
2.4 讲师权限与普通学员权限的边界
用户角色我用role字段区分,接口层用一个自定义装饰器来判断。比如@lecturer_required装饰器,内部逻辑是解析token,取出user_id,查库拿到role,如果是2(讲师)或3(管理员)就放行,否则返回403。这个装饰器统一放在utils/auth.py里,所有涉及课程创建、题目编辑的接口都挂上,有效防止越权。
权限边界一定要尽早做,别等接口写完了再补。因为小程序端虽然也能用按钮显隐控制操作入口,但懂技术的人直接改请求参数就能调接口,服务端校验才是真正的安全边界。
3. 实操过程与核心环节实现
3.1 环境准备与Flask工程结构
开发环境建议用Python 3.8以上版本。先创建虚拟环境:
python -m venv venv source venv/bin/activate # Windows下用 venv\Scripts\activate pip install flask flask-sqlalchemy flask-cors flask-jwt-extended pymysql工程结构上,不要把路由全写在app.py,我用蓝图的目录划分如下:
project/ ├── app.py # 应用入口,注册所有蓝图 ├── config.py # 数据库、密钥等配置 ├── models/ │ ├── __init__.py │ ├── user.py │ ├── course.py │ └── question.py ├── blueprints/ │ ├── auth.py │ ├── course.py │ └── question.py ├── utils/ │ ├── auth.py # JWT校验装饰器 │ └── response.py # 统一返回格式 └── requirements.txtapp.py里最关键的启动代码就十几行:
from flask import Flask from flask_cors import CORS from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() def create_app(): app = Flask(__name__) app.config.from_object("config.Config") CORS(app) db.init_app(app) from blueprints.auth import auth_bp from blueprints.course import course_bp from blueprints.question import question_bp app.register_blueprint(auth_bp, url_prefix="/api/auth") app.register_blueprint(course_bp, url_prefix="/api/course") app.register_blueprint(question_bp, url_prefix="/api/question") return app用工厂函数创建应用,好处是方便写单元测试,也方便多个环境切换配置。数据库连接建议用MySQL,生产环境不要用SQLite,虽然小程序轻量用户少,但SQLite在高并发写入时容易锁库报错。我一开始图省事用了SQLite,结果答题记录一多就出现database is locked,后来老老实实换成MySQL。
3.2 核心接口一:微信登录换取Token
小程序端通过wx.login拿到code,传给Flask后端,后端再调用微信接口换取openid。开发环境可以直接用mock数据,但生产环境必须走真实接口。关键代码:
@auth_bp.route("/wxlogin", methods=["POST"]) def wxlogin(): code = request.json.get("code") url = "https://api.weixin.qq.com/sns/jscode2session" params = { "appid": appid, "secret": secret, "js_code": code, "grant_type": "authorization_code" } resp = requests.get(url, params=params).json() openid = resp.get("openid") user = User.query.filter_by(openid=openid).first() if not user: user = User(openid=openid, nickname="微信用户", role=1) db.session.add(user) db.session.commit() token = create_access_token(identity=str(user.id)) return jsonify({"code": 0, "token": token, "role": user.role})这里的create_access_token来自flask_jwt_extended,默认生成的token有效期可以配置,建议设成7天,用户频繁打开小程序时不用每次重新登录。
3.3 核心接口二:课程列表分页
小程序端首页是课程列表,一定要做分页,不然视频课程一多,一次返回几十条数据不仅加载慢,小程序渲染也会卡。接口设计如下:
@course_bp.route("/list", methods=["GET"]) def course_list(): page = request.args.get("page", 1, type=int) per_page = request.args.get("per_page", 10, type=int) pagination = Course.query.filter_by(status=1) \ .order_by(Course.created_at.desc()) \ .paginate(page=page, per_page=per_page, error_out=False) items = [c.to_dict() for c in pagination.items] return jsonify({ "code": 0, "data": { "items": items, "has_more": pagination.has_next } })has_more这个字段很关键,小程序端通过它判断是否还能上拉加载更多。如果只返回总数,前端还要自己算,不如后端直接给状态字段省事。
3.4 核心接口三:提交答案判分
这是题库模块最核心的接口,逻辑并不复杂:拿到题目ID和用户答案,查题目,比对,写答题记录,返回对错和解析。下面是我实际用的判分代码片段:
@question_bp.route("/submit", methods=["POST"]) @jwt_required() def submit_answer(): user_id = get_jwt_identity() question_id = request.json.get("question_id") user_answer = request.json.get("answer") # 例如 "A" 或 ["A","C"] q = Question.query.get(question_id) if not q: return jsonify({"code": 1, "msg": "题目不存在"}) if q.type == "multi": correct = set(q.answer_index_list()) == set(user_answer) else: correct = (q.answer == user_answer) record = AnswerRecord(user_id=user_id, question_id=question_id, user_answer=str(user_answer), is_correct=correct) db.session.add(record) db.session.commit() return jsonify({ "code": 0, "data": { "is_correct": correct, "correct_answer": q.answer if not correct else None, "analysis": q.analysis if not correct else "" } })细节上注意两点:多选判分必须动态判断,因为岗位和机构的题型规则不同,有的多选题漏选也给一半分,这需要扩展一个score字段;写答案记录一定要先查库再比对,不能拿前端传的正确答案比对,否则别人可以直接传正确值。
3.5 小程序端对接实操
小程序前端我是用原生微信开发者工具写的,不用uni-app的理由也很简单:项目只针对微信端,没必要引入一套跨端框架增加调试复杂度。但如果你是那种同时要上支付宝小程序、抖音小程序的团队,那用uni-app打包会省事很多。
请求封装是必须的,不能一个页面写一个wx.request。我封装了一个request.js:
const request = (url, method, data) => { return new Promise((resolve, reject) => { wx.request({ url: `https://api.example.com${url}`, method: method || 'GET', data: data || {}, header: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${wx.getStorageSync('token')}` }, success: (res) => { if (res.data.code === 0) { resolve(res.data.data) } else if (res.data.code === 401) { wx.navigateTo({ url: '/pages/login/login' }) } else { wx.showToast({ title: res.data.msg, icon: 'none' }) reject(res.data) } }, fail: reject }) }) }这里有个容易踩的坑:请求超时时间默认60秒,视频相关的接口还好,但如果是讲师上传视频到客户端,不建议通过这个封装走,因为大文件上传要用wx.uploadFile,而且上传到对象存储时请求耗时可能非常长。
首页课程列表上拉加载更多,WXML里onReachBottom绑定方法,然后分页请求,把新数据concat到旧数据后面。很多新手会犯一个错误:没有用loading状态锁住请求,导致用户快速滚动时连续触发好几次相同页的请求,数据重复。我在onReachBottom里加一个this.data.isLoading判断来防止重复请求。
3.6 Flask部署到服务器:gunicorn + nginx
开发环境跑通之后,部署生产环境有几个必要步骤。首先不能用Flask自带的开发服务器对外服务,并发能力太弱。我用gunicorn启动:
gunicorn -w 4 -b 127.0.0.1:5000 app:app4个worker是比较合适的值,太多反而会因为MySQL连接数过高出问题。nginx配置反向代理,把80端口转发到5000端口:
server { listen 80; server_name api.example.com; location / { proxy_pass http://127.0.0.1:5000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }小程序正式环境要求所有请求域名必须HTTPS且ICP备案,所以服务器上还需要配置SSL证书。部署完后第一件事就是用curl测试接口通不通,不要急着在小程序里调试,我每次部署完都是一条条测接口,等全部返回正常了再打开小程序测试。
4. 常见问题与排查技巧实录
4.1 小程序请求失败:合法域名和HTTPS证书
小程序开发工具默认勾选了“不校验合法域名”,所以开发阶段什么都通。但真机预览或正式版时,如果请求域名不在小程序后台配置的downloadFile合法域名列表里,就会直接报request:fail url not in domain list。这不是代码问题,而是配置问题。
解决办法是:登录小程序公众平台,在小程序后台的“开发管理-开发设置-服务器域名”里,把API域名加到request合法域名,把视频和图片的CDN域名加到downloadFile合法域名。注意,域名只支持HTTPS,且不能带端口。如果你调试时直接填了http://127.0.0.1:5000,正式环境肯定不行。
还有一个隐蔽问题:SSL证书过期。我遇到过一次接口突然全部超时,排查了半天才发现是证书到期,curl都会提示证书验证失败。所以建议在服务器上写个定时任务监控证书有效期,或者直接使用自动续期的免费证书方案。
4.2 抓包调试:Charles如何看小程序的请求
小程序报错的时候,光靠开发者工具的console还不够,建议用专业抓包工具看完整的请求体和响应体。我用的是Charles,它可以查看HTTPS加密请求的具体内容。
操作步骤是:电脑和手机连同一个WiFi,手机HTTP代理指向电脑IP的8888端口,然后在Charles里安装并信任SSL证书。具体流程是Help菜单里选择SSL Proxying,找到Install Charles Root Certificate,先装到电脑上,再用手机浏览器访问chls.pro/ssl下载证书并安装。最后在Proxy-SSL Proxying Settings里添加域名和端口号,这样小程序发出的HTTPS请求就能在Charles里看到明文。
这里强调一下,抓包只用于调试自己开发的小程序、定位自己服务的接口问题,不要拿它去抓别人的线上商业应用,这是基本边界。我在调试登录接口时,正是通过抓包发现小程序前端把wx.login的code传给后端后,后端返回的token没有在header里带Authorization前缀,导致后续接口全部401,查得快很多。
4.3 数据库连接超时导致接口偶发报错
Flask + SQLAlchemy部署到线上后,会有一个非常经典的坑:MySQL的wait_timeout默认是8小时,如果连接空闲超过这个时间,MySQL会主动断开连接,但SQLAlchemy连接池里还留着这个死连接,下次请求时就会报OperationalError: Lost connection to MySQL server during query。
解决办法是配置SQLAlchemy的pool_pre_ping,确保每次请求前先探测一下连接是否有效:
SQLALCHEMY_ENGINE_OPTIONS = { "pool_size": 10, "pool_recycle": 3600, "pool_pre_ping": True, }另外在部署脚本里也顺手设置MySQL的wait_timeout,两边都处理,这个坑基本就消失了。这类偶发问题最烦人,因为它不是必现的,很多人会误以为是服务器配置问题,最后其实是连接池生命周期管理的问题。
4.4 视频加载不了或播放卡顿
视频服务常见的坑有三个。第一,域名问题:视频URL的域名必须加到小程序后台downloadFile合法域名,否则video组件直接加载失败。第二,防盗链问题:如果你的视频存放在支持防盗链的OSS/CDN上,默认会校验Referer,小程序的请求Referer是服务商域名,如果没配置白名单就会403。第三,MIME类型问题:有些对象存储默认Content-Type设置不对,返回的是application/octet-stream,小程序video组件也可能无法播放。
处理思路是:视频URL统一走CDN加速域名,上传时强制设置Content-Type为video/mp4,并在CDN侧关闭Referer校验或放行小程序域名。课程封面图也存在同样的问题,图片加载空白时先看后台返回的URL能不能在浏览器里直接打开,大多数问题都是域名白名单或防盗链导致的,代码本身没毛病。
4.5 常见问题速查表
| 现象 | 可能原因 | 排查手段 |
|---|---|---|
| 真机请求失败,开发者工具正常 | 合法域名未配置 | 检查小程序后台request合法域名 |
| 接口报401 | token过期或未传Authorization | 检查请求头token字段和JWT有效期 |
| 数据库偶发报错Lost connection | 连接池死连接 | 配置pool_pre_ping和pool_recycle |
| 视频播放白屏 | 域名未加downloadFile白名单 | 添加视频域名到合法域名 |
| 图片加载404 | 防盗链或URL签名过期 | 使用公开读Bucket或CDN签名 |
| 课程列表重复数据 | 上拉加载缺少loading锁 | onReachBottom里加防重复请求判断 |
| Flask启动报Address already in use | 端口被占用 | 换端口或kill占用进程 |
5. 后续还能怎么扩展
5.1 从单机演示到生产级系统还需要什么
现在这套Flask代码从功能角度已经能跑通完整业务流程,但距离生产级系统还差几块拼图。第一是对象存储,视频文件一定不要放在服务器本地磁盘,改成OSS或COS,网络传输和存储容量都有保障;第二是日志和监控,Flask的每个请求都应该记录request_id、耗时、状态码,接口出错时能快速定位;第三是数据库备份,我建议每天用mysqldump增量备份业务数据,防止误删或服务器故障导致数据丢失;第四是HTTPS证书自动化续期,如果手动续签,每年总有几个月会担心它过期。
如果用户量上来,还要考虑把Flask的同步worker换成异步架构,或者直接用Flask + Celery处理一些耗时操作,比如批量导入题库Excel、批量生成学习报告。这些功能现阶段用同步接口能撑住,但等并发上来了再重构,代价会成倍增加。
5.2 根据个人经验,给新手的几条实操建议
第一,不要一开始就把所有功能都写完,我建议按这个顺序迭代:登录授权 -> 课程列表 -> 课程详情和视频播放 -> 题库练习 -> 答题记录 -> 讲师上传功能 -> 统计面板。每完成一个阶段就同步一次小程序端,保证随时有一个可演示的版本,这个习惯对项目推进特别重要。
第二,接口返回格式一定要统一。我的格式是{"code": 0, "msg": "success", "data": ...},成功code为0,失败code非0。前端封装的request会检查这个code,不用每次写一遍错误处理。如果团队里有人把成功code定义为200,有人定义为1,前端联调时就是灾难现场。
第三,权限控制必须在后端做。小程序前端隐藏讲师管理入口只是用户体验,不是安全边界。所有“讲师才能操作”的功能,后端必须校验token对应的用户角色,否则你辛苦做的课程管理接口就是摆设。
第四,题目答案不要明文下发给前端。有些开发者图省事,课程详情里直接把题目列表和答案一起返回,学员抓个包就能全抄了。标准做法是题库接口只返回题目和选项,提交答案后服务端判断并返回结果,这样才能保证刷题的真实性。
最后再分享一个我自己用得很顺手的技巧:为了降低联调成本,我在Flask后端加了一个demo模式,env设为demo时,wxlogin接口不调用微信服务,直接根据前端传的mockCode生成固定openid。这样不用每次都用真机扫码登录,小程序开发工具里点一下就能进入调试状态,速度比走微信登录流程快很多。这个小技巧看起来不起眼,但对开发体验的提升非常明显,建议有同样需求的朋友直接抄走。