1. 这个学习平台从需求到立项:为什么是"小程序+Flask"的组合
1.1 一个看起来简单但被反复低估的需求
先说这件项目最开始的来由。某高校的学生组织找到我,说想弄一个给社团内部使用的Python学习工具,成员从零基础到大三都有,最大的问题不是没有资料,而是学不下去。之前他们试过拉群分享PDF、丢视频链接、周末线下讲课,效果都不理想,开学三周后群里基本只剩管理员发言。
我听完需求后第一反应是很典型的"弱学习场景":用户在碎片时间里用手机,不想下载App,不想打开电脑,又希望学习进度能被记录下来。想在食堂排队时刷两道题、睡前看一个知识点,这种场景下微信小程序几乎是唯一合理的载体——用户不需要额外安装,点开就能用,用完关闭也不心疼。
后端的选型同样纠结过一轮。学生组织没有专职运维,开发也就我和另外一两位同学,项目体量撑死几千个用户。用Django当然可以,但它的ORM、Admin、App体系对这类项目来说偏重;用Node、Go又要引入新语言。最后选了Flask,理由是:轻量、路由自由、第三方扩展够用,而且Python写后端时能顺手处理算法判题、数据统计这些模块,不用切换语言。
1.2 技术选型对比:不是越重越好,而是够用就好
很多人一听到"学习平台"就下意识觉得要上微服务、要搞容器编排,这个思路对这个项目是错误的。我列了一张对比表,把当时认真比较过的几个方案摊开看:
| 方案 | 开发速度 | 学习曲线 | 运维成本 | 适不适合本项目 |
|---|---|---|---|---|
| Flask + 微信小程序 | 快 | 低 | 低 | 非常合适 |
| Django + 微信小程序 | 中 | 中 | 中 | 偏重,可以但没必要 |
| Spring Boot + App | 慢 | 高 | 高 | 不匹配团队能力 |
| uni-app + 云开发 | 快 | 中 | 低 | 后端可控性差,排序判题不好做 |
关键判断点在于:这个平台的核心功能不只是展示课程列表,还要有"在线做题、代码运行、学习记录、错题统计"这类强后端逻辑。云开发方案虽然省事,但在代码在线运行、自定义判题规则上会受到很多限制。Flask配合MySQL和Redis,我能在几天内把所有核心接口搭出来,并且每段代码的逻辑都完全可控。
我最后跟项目团队定下的原则是:第一版不追求功能多,追求"闭环成立",也就是从打开小程序、登录、看课、刷题、记录进度到查看学习报告,这一整条体验必须走通。这决定了我们后续所有数据库字段和接口设计都围绕"学习行为"展开,而不是围绕"内容管理"展开。
2. 数据表设计:从学习行为反推出来的模型
2.1 核心建模逻辑:记录"学了多少、对还是错、持续了多久"
这个项目的数据库建模,我们不是先从"有哪些页面"出发,而是先从"想看到哪些数据"出发。管理员希望看到用户的活跃度、答题正确率、各知识点的薄弱程度;用户希望看到自己的学习时长、连续打卡天数、错题回顾。围绕这些目标,只需要五类核心表就能撑起整体业务。
用户表存基础信息和OpenID的映射;课程表只存课程属性;章节表把课程拆成知识点级别的单元;题目表保存题干、选项、答案、解析和难度系数;学习记录表是整张设计的核心,它记录的是用户每一次浏览章节、提交每一道题的原始行为。
下面是我们最初版本的建表SQL片段,你会发现我没有过早设计"用户课程关系表",因为在这个场景里,用户和课程的关联完全可以通过学习记录推导出来:
CREATE TABLE user ( id INT AUTO_INCREMENT PRIMARY KEY, openid VARCHAR(64) NOT NULL UNIQUE, nickname VARCHAR(50) DEFAULT '', avatar_url VARCHAR(255) DEFAULT '', created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE course ( id INT AUTO_INCREMENT PRIMARY KEY, title VARCHAR(100) NOT NULL, summary VARCHAR(255) DEFAULT '', level TINYINT DEFAULT 1, cover_url VARCHAR(255) DEFAULT '' ); CREATE TABLE chapter ( id INT AUTO_INCREMENT PRIMARY KEY, course_id INT NOT NULL, title VARCHAR(100) NOT NULL, content LONGTEXT, sort_order INT DEFAULT 0 ); CREATE TABLE question ( id INT AUTO_INCREMENT PRIMARY KEY, chapter_id INT NOT NULL, type TINYINT DEFAULT 0, -- 0 单选题 1 多选题 2 判断 3 代码题 content TEXT NOT NULL, options TEXT, answer TEXT NOT NULL, analysis TEXT, difficulty TINYINT DEFAULT 1 ); CREATE TABLE study_record ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, chapter_id INT DEFAULT NULL, question_id INT DEFAULT NULL, is_correct TINYINT DEFAULT NULL, duration_seconds INT DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP );当时有一个争论点:要不要单独建一个"错题本表"?我的意见是不要。错题本质上是"答题记录中is_correct=0的数据",用一条带索引的查询就能筛选出来,单独建表反而增加数据冗余和同步成本。后来发现这个决定是对的,运营一段时间后我们要按知识点维度统计薄弱环节,直接对study_record做聚合查询就能出结果。
2.2 为什么学习记录表是整张设计的地基
学习记录表最初只有"用户、章节、题目、对错",后来实际使用中又加了两个字段:时长和错误次数。时长是从前端埋点传上来的,用户在某道题上停留的时间;错误次数则是判断一道题是否值得反复推敲的重要指标。
举一个真实发生的例子:上线一周后,我们发现数据库里很多题目的平均错误率高达60%以上,但用户满意度并不低。后来一查,发现题目难度分布不合理,简单题和中等题占比太少,大量用户一上来就碰到了困难题。如果没有学习记录表,这个问题管理者是感知不到的。
因为有这张表,我们可以很方便地算出每个知识点的掌握度:
SELECT c.title AS course_title, ch.title AS chapter_title, SUM(CASE WHEN q.id IS NOT NULL AND r.is_correct = 1 THEN 1 ELSE 0 END) AS correct_cnt, COUNT(r.id) AS total_cnt FROM study_record r LEFT JOIN question q ON r.question_id = q.id LEFT JOIN chapter ch ON q.chapter_id = ch.id LEFT JOIN course c ON ch.course_id = c.id WHERE r.user_id = %s GROUP BY q.chapter_id;这个查询看起来简单,但它直接支撑了"学习报告"这个小程序页面:红色表示薄弱知识点,绿色表示已掌握。用户看到报告后,会有意识地去补短板,学习粘性因此提高了一个台阶。我始终觉得,数据表设计不要想着一步到位,像这种平台类项目,先把"行为日志"存下来,后面的推荐、统计、画像全都有数据可挖。
3. Flask后端核心实现:蓝图、认证与接口规划
3.1 用Blueprint拆分模块,比把所有路由塞一个文件里舒服得多
Flask这种微框架的好处是自由,坏处也是自由——如果你不约束自己,项目很快就会变成一个堆满路由的单文件。我们这个平台后端一共分了四个Blueprint:auth负责登录;content负责课程和章节内容;quiz负责题目和提交判题;user负责个人中心和进度统计。
注册方式很简单,在工厂函数里逐个挂载:
from flask import Flask from app.routes.auth import auth_bp from app.routes.content import content_bp from app.routes.quiz import quiz_bp from app.routes.user import user_bp def create_app(): app = Flask(__name__) app.config.from_pyfile("config.py") app.register_blueprint(auth_bp, url_prefix="/api/auth") app.register_blueprint(content_bp, url_prefix="/api/content") app.register_blueprint(quiz_bp, url_prefix="/api/quiz") app.register_blueprint(user_bp, url_prefix="/api/user") return appBlueprint的价值不只是代码整洁,它还天然划出了模块边界。比如后来我们做在线代码运行,直接在quiz这个Blueprint下面加一个code子模块,既不影响其他接口,也方便单独做压力测试。如果你现在还把所有代码写在一个main.py里,我真的建议拆一下,哪怕项目只有几百行,拆完之后思路会清晰非常多。
3.2 微信登录与JWT认证的搭配
小程序端没有传统的账号密码,微信登录流程是:小程序调用wx.login拿到code,后端把code传给微信接口换取openid和session_key。因为session_key不应该下发到前端,所以我们只在后端把openid映射成平台自己的用户ID,然后签发JWT给小程序。
这里有一个关键细节:JWT的payload里不要存openid,而应该存自增的user_id。原因是后续所有业务查询都基于user_id,每次从数据库反查比较浪费;但JWT的secret要管理好,最好放到环境变量里:
import jwt from datetime import datetime, timedelta from flask import current_app def generate_token(user_id): payload = { "uid": user_id, "exp": datetime.utcnow() + timedelta(days=7) } return jwt.encode(payload, current_app.config["SECRET_KEY"], algorithm="HS256")认证部分做了一个装饰器,所有需要身份的接口统一使用。小程序每次请求都在Header里带Authorization: Bearer <token>,后端解析失败就返回401,前端收到401后自动跳转登录页。这个方案跑通之后,我们再也没碰过session,小程序端也因为这个机制做到了真正的"无状态"。
3.3 API返回结构统一,前端Gemini地方少很多(这里改成"前端少写很多兼容代码")
接口返回结构我在项目第一天就定了规范,所有接口统一为:
{ "code": 0, "message": "ok", "data": {} }code为0表示成功,非0表示业务错误,message用于前端提示。这样做的直接好处是小程序端可以封装一个统一的请求函数,只处理code为0的情况,其他一律走全局toast。实际开发中,我们甚至用这个结构统一处理了参数错误和权限不足,前端调试效率提升非常明显。
有人可能会问,为什么不直接用HTTP状态码?因为微信小程序里很多场景需要同时传业务错误码和用户提示文案,如果靠HTTP状态码区分,得在前端维护一张"状态码-文案"映射表,成本更高。现在这种结构,后端直接把提示语给前端展示,简单有效。
4. 微信小程序端的关键实现:从登录到页面状态管理
4.1 小程序登录链路:一个安静但必须跑通的过程
微信小程序端的登录流程,很多时候会被新手写复杂。正确做法是用wx.login拿code,然后把code发给自己的后端,后端换到openid后再返回自定义的JWT。
wx.login({ success: async (res) => { const { code } = res; const loginRes = await request.post('/auth/login', { code }); if (loginRes.code === 0) { wx.setStorageSync('token', loginRes.data.token); wx.setStorageSync('userInfo', loginRes.data.userInfo); } } });这里要非常注意:小程序端的wx.getUserProfile必须由用户点击事件触发,不能在页面加载时静默调用。很多同学把这两件事混在一起,导致审核被拒。我的做法是:先完成登录,用户进入"我的"页面并点击头像区域时才弹出授权框,这样既合规又不影响正常使用。
整个登录过程是小程序端最容易让用户感到卡顿的地方。因此我在登录成功后加了一步"静默刷新用户信息":后端返回用户最近一次的学习进度摘要,前端直接渲染在首页,用户一进来就能看到"上次学到哪了",这种细节对学习类产品的留存特别重要。
4.2 请求封装与本地缓存策略,少写一半冗余代码
小程序端如果不封装请求,每个页面都会出现一大堆wx.request回调。我封装了一个request模块,统一处理三件事:拼接baseUrl、携带token、统一错误提示。
const request = (url, method, data) => { return new Promise((resolve, reject) => { wx.request({ url: BASE_URL + url, method, data, header: { 'Authorization': 'Bearer ' + wx.getStorageSync('token') }, success: (res) => { if (res.data.code === 0) { resolve(res.data.data); } else { wx.showToast({ title: res.data.message, icon: 'none' }); reject(res.data); } }, fail: (err) => reject(err) }); }); };缓存策略我们用的是"两级缓存":课程列表、章节目录这类低频变化的数据,首次请求后写入本地storage,设置了24小时的过期时间,下次打开直接读缓存,只有用户主动下拉刷新时才走网络请求;学习记录、答题提交这类强实时数据,永远不走缓存。
这样设计的结果非常直观:小程序冷启动到首页可交互的时间从原来的接近2秒降到了1秒以内。用户感觉"秒开",其实前端什么都没变,就是少了两次网络请求。我认为这种体验优化比任何花哨动画都有价值,尤其是微信小程序在弱网环境下的表现,缓存策略是决定性因素。
5. 在线代码运行模块:整个项目最难啃也最值钱的一块
5.1 为什么不能直接在服务器上"裸跑"用户代码
学习平台如果只是看视频、做题,那跟普通图书没有本质区别。我们要做的是让用户在小程序里直接写一段Python代码并看到运行结果。这个想法听起来很酷,但实现起来有一个绕不开的问题:用户代码是危险的。
用户提交的代码在服务器上运行,它可能是while True死循环,可能是os.system("rm -rf /"),也可能只是单纯占满内存。如果直接在Flask进程里用subprocess跑,一个恶意请求就能拖垮整个后端。所以必须做资源隔离。
我采用的方案是Celery异步任务队列加子进程沙箱。用户提交代码后,不直接同步等待结果,而是先把任务扔进Redis队列,由Celery worker去执行。worker在执行时使用subprocess启动一个隔离的Python进程,并对运行施加三重限制:CPU时间限制、内存限制、输出长度限制。
import subprocess import resource import os def run_user_code(code, timeout_seconds=5): tmpfile = f"/tmp/user_code_{os.getpid()}.py" with open(tmpfile, "w") as f: f.write(code) def set_limits(): resource.setrlimit(resource.RLIMIT_CPU, (timeout_seconds, timeout_seconds + 1)) resource.setrlimit(resource.RLIMIT_AS, (256 * 1024 * 1024, 256 * 1024 * 1024)) try: result = subprocess.run( ["python3", tmpfile], capture_output=True, text=True, timeout=timeout_seconds + 2, preexec_fn=set_limits ) return {"stdout": result.stdout[-2000:], "stderr": result.stderr[-2000:], "code": result.returncode} except subprocess.TimeoutExpired: return {"stdout": "", "stderr": "运行超时,请检查是否存在死循环", "code": -1} finally: os.remove(tmpfile)这段代码看起来简单,但有一个很隐蔽的坑:RLIMIT_AS限制的是虚拟内存,而不是物理内存,对某些内存申请方式可能不起作用。我后来加了第二个保险:用preexec_fn每一步都检查时间,同时在外层用subprocess.run的timeout兜底。双保险之后,到目前为止还没有一次真正把worker拖挂的情况。
5.2 判题逻辑:不能只比对输出结果字符串
在线代码运行功能上线后,我们发现了一个新问题:用户真的写了代码,也真的运行成功了,但怎么判断他做得对不对?如果只比对stdout和标准答案,会有很多不严谨:比如用户只是print了结果,而不是真正写了一个函数。
为了解决这个问题,我们对代码题设计了两种判题模式:全等模式只比对标准输出;测试用例模式会把用户代码嵌入到一段测试代码中,调用预设的测试用例来验证。
第二种模式的实现思路是:
test_code = f""" {user_code} # 测试用例 assert add(2, 3) == 5 assert add(-1, 1) == 0 print("ALL_TEST_PASSED") """把用户代码和测试用例拼在一起,再交给沙箱去运行。这样用户写的是真正的函数,而不是面向输出结果"作弊"。这个改动上线后,代码题的完成率下降了,但从运营数据看,用户真正学会"写函数"的比例明显提升,我更倾向于认为这是一个正确的产品决策。
5.3 代码编辑器与结果展示的交互细节
小程序端没有一个开箱即用且体验很好的代码编辑器组件。我最终选择的是一个textarea加上简单的高亮文字叠加方案:textarea负责承载输入,透明的高亮层负责展示关键字颜色。这个方案有一些瑕疵,比如光标位置和高亮层的对齐在真机上偶尔会偏移几个像素,但整体可用性已经能接受。
每次代码运行的结果展示,我用了setData来更新页面数据,同时把最近三次运行记录缓存在本地。用户如果把同一道题运行超过三次,我们会在结果下方给一个提示"这道题你尝试了X次,需要看看知识点解析吗",并且自动带出章节内容中关联的知识点卡片。这个小设计成了整个平台被用户主动提及最多的功能之一,因为它让人觉得系统真的在关注你,而不是冷冰冰的判题机器。
6. 部署上线全过程与踩坑清单
6.1 Gunicorn、Nginx和小程序之间的"三角关系"
开发环境用Flask自带服务器没问题,但上线必须换Gunicorn。我们的部署架构是三台角色:Flask应用跑在Gunicorn后面,进程数为2-4个worker;Nginx负责反向代理和静态文件;Redis负责缓存和Celery任务队列。
Gunicorn的配置有一行我当时写错了,导致线上接口偶尔会卡死:workers设置过高,而且没配timeout。后来改成了:
workers = 4 worker_class = "gevent" timeout = 30这里有两个细节值得说明。第一,worker_class用的是gevent,因为Flask处理数据库查询时是同步阻塞的,如果并发上来了,多的worker只是把线程池填满,用gevent协程能更好地提高单worker的吞吐能力。第二,timeout必须设,否则某个接口因为外部依赖慢就一直挂着,nginx那边就会积累一堆上游连接。
Nginx配置主要有两个作用:一是把/api/前缀的请求代理到Gunicorn,二是托管小程序上传的课程封面图片。我特意加了一条对图片的缓存规则,因为小程序的图片请求非常频繁,没有缓存策略会白白消耗很多后端流量。
6.2 HTTPS、域名与小程序审核的三个容易翻车点
微信小程序要求所有请求域名必须是HTTPS,且需要在后台配置白名单。这两个约束听起来很简单,但实际操作中很容易出问题。
第一,HTTPS证书一定要买泛域名证书或者至少保证api.你的域名和static.你的域名两张证书都有。我们当时只给主域名配了证书,结果图片域名忘了,小程序真机调试时图片全部加载失败。第二,域名备案周期比预期长,而且小程序后台要求域名必须完成ICP备案才能配置,这个流程我是第一次走没有经验,整个项目上线时间被拖了两周。如果你在做类似项目,我建议先把域名和备案的流程跑起来,再开始写代码。
还有一个小程序审核的细节:代码题需要用户输入代码,textarea会唤起微信键盘,如果你的页面里没有做好键盘弹起时的布局适配,审核人员随便一点键盘就会看到页面被顶得乱七八糟,这个被拒理由我遇到过。后来给所有输入页面加了adjust-position处理,才顺利过审。
6.3 数据库安全与备份,别等出事才后悔
这个项目数据库用的是MySQL,流量不大,但安全问题不能不管。我做了三件事:第一,禁止用root连接应用,单独建了一个只有增删改查权限的账号;第二,定期用mysqldump导出数据,并且把备份文件同步到另外一个存储空间;第三,开启binlog,虽然我们还没到需要PITR恢复的程度,但至少心理上有底。
Celery worker也有一个踩坑经历。代码运行任务有时会因为用户提交恶意代码而卡住,如果大量任务堆积在Redis队列里,worker会变得越来越忙。我在Celery上加了一个任务超时和队列长度限制:如果队列超过500条,新的代码执行请求就直接返回"当前服务器繁忙,请稍后再试"。这个兜底机制看似粗暴,但在真实并发场景下救了我们好几次。
7. 上线后的数据调优与学习路径优化
7.1 从学习记录里发现的"放弃时刻"
平台跑了两周后,我们拿到了一些很有意思的数据。通过对学习记录表的分析,发现用户在"函数定义"和"列表推导式"这两个知识点的跳出率最高,大量用户学到这一章就再也没有回来过。这个数据说明一个很重要的问题:不是用户不想学,而是这两个知识点对初学者来说坡度太陡。
针对这个发现,我们在章节内容里增加了大量的"半成品代码补全题":系统给出一段有缺漏的代码,用户只需补上一行关键逻辑就能运行通过。这个改动上线后,函数章节的完成率提升了约20%。我认为这种基于真实行为数据的产品迭代,比任何经验判断都可靠得多。
7.2 排行榜与打卡,怎样避免变成形式主义
学习类产品都喜欢做打卡和排行榜,我们一开始也做了。上线后发现,打卡的热度在下降,但"每日一题"的参与率反而在涨。后来想明白了,用户要的不是仪式感,而是"有效的下一步"。
所以第二版我们做了个调整:把"连续打卡"弱化,把"完成每日一题并填写一句话笔记"作为每日目标。用户完成这个目标后,会解锁第二天的内部推荐题目,让学习像解密一样,每天都有一个小小的新鲜感。打卡天数不再是首页的主数字,而是变成了归档的位置。
这个改动的启示是:排行榜和打卡本身没有错,错在把它当作留住用户的唯一手段。真正留人的,是内容跟用户当前进度之间的耦合程度。我们做的事情,本质上就是给平台安了一个非常简单的"自适应学习路径"。
8. 复盘:如果再让我做一次,我会改哪几件事
8.1 有些设计坚持是对的
回头看,有几件事我做得比较漂亮。一是数据库的学习记录表,直接让后续所有统计和推荐都有了基础;二是统一接口返回结构,前后端联调几乎没有扯过皮;三是代码运行加了双层资源限制,到目前为止没有因为用户代码拖垮过服务。
另外微信小程序这套登录流程,从一开始就走的是"后端签发JWT"的方式,而不是在小程序端保存openid,这个边界卡得比较死,安全性才没出问题。这几个决策在项目刚起步时都是比较容易犯迷糊的地方,我很庆幸当时想清楚了。
8.2 如果再给我一次机会,我会推倒重来的三件事
第一,我不会再自己手写富文本编辑器相关的组件了,章节课件的内容格式一开始就应该统一成Markdown,用成熟的开源组件去渲染,而不是允许提交HTML然后自己处理样式。我们后来在上传章节内容时花了很多时间清理HTML标签。
第二,代码运行模块我会直接用更完整的隔离方案,而不是自己包了一层subprocess。虽然目前资源限制已经够用,但真要扛住更大并发和更恶意的攻击,系统级容器隔离会是更稳的路。这个改造优先级不高,因为现阶段用户规模有限,但架构上我心里一直有根弦。
第三,测试用例的编写流程应该给管理员配一个可视化调试工具,而不是让大家用SQL直接往题库表里插数据。我们用了一段时间才发现管理员上传一道代码题的平均时间是十五分钟,这个成本太高了,后来才补了一个简单的后台管理页面。
最后说一点个人体会。做这类学习平台,技术难度真的不高,难的是你愿不愿意站在用户"学不下去"的角度去思考功能。代码在线运行、学习记录、薄弱知识点分析,这些功能没有任何一个是很酷的炫技点,但它们合在一起,传递了一种"这个平台在乎我学得怎么样"的感觉。我后来再看到各种复杂架构的项目,反而越来越相信:一个好的学习产品,核心永远是学习,技术只是让学习体验变得不痛苦的工具而已。这个Flask加微信小程序的组合,就是为这种"不痛苦"而存在的。