
做这个系统其实是个偶然。当时朋友所在的设计培训机构需要一套给学员用的自主学习平台要求能放图文课程、记录学习进度、支持学员提交作品还要让老师能看出谁真正学完了。前前后后对比了一圈现成方案要么太重要么不好改最后决定用Flask把这套设计课程自主学习系统后端从零搭起来。本文就把整个开发过程中的选型思考、数据库设计、认证方案、跨域处理和部署实测完整记录下来给准备用Flask做类似教学类后端的同学一个可直接参考的样本。1. 这个学习系统后端到底要解决什么问题任何项目在动手前先想清楚边界。设计类课程自主学习系统和常见的视频课程平台不同它的内容形态更丰富不仅有多章节图文教程还有大量的参考图、设计稿源文件、作业提交以及按阶段推进的学习任务。从后端的职责来看核心要解决四件事。第一是课程内容的结构化存取。一门设计课通常被拆成多个模块每个模块下有若干章节章节里包含正文内容、图片素材、附件下载。有些课程还有版本更新的需求比如讲师觉得第三章不够好改了之后不希望影响已经学过的人的进度记录。这就要求课程内容与学习进度解耦内容可以变但每个人的学习状态是独立的。第二是用户体系的建立与权限控制。系统里至少有两类角色学员和教师或者管理员。学员能看到自己选了什么课、学到哪里、交过哪些作业教师能创建课程、更新章节、查看学员的完成情况。不能用一张users表一个is_admin字段糊弄过去因为教学系统里还需要区分课程作者和平台管理员权限维度比普通CMS复杂。第三是学习进度的实时记录。这看起来简单实际上是个很容易想当然的功能。很多新手会直接在课程表里加一个is_finished字段或者把进度存在用户表里。但只要课程章节数超过十个就知道这种设计有多难维护。进度必须是一张独立的关联表记录哪个用户、在哪个课程、完成了哪个章节、完成时间是什么这样才能支持断点续学、课程完成率统计、教师端的学情查看。第四是作品与作业的提交管理。设计课程的输出物通常是图片或者工程文件大小不一格式多样。后端需要提供稳定的上传接口、合理的文件命名策略、访问权限控制还要防止上传路径被恶意构造。这一块我在开发时踩过坑后面专门用一章来写。规模上不用过分担心。对于这种面向特定培训机构或院校的自主学习系统几千名学员、几百门课程的数据量Flask加关系型数据库单机部署完全扛得住。只要不设计出烂到离谱的慢查询瓶颈几乎不会出现在后端语言层面。2. Flask与FastAPI的选择学习系统场景下的权衡最近两年一提Python后端FastAPI的呼声很高异步高性能、自动生成OpenAPI文档、Pydantic校验确实香。我也认真考虑过要不要用FastAPI来写但最终选择了Flask这个决定在开发中期和部署阶段被证明是正确的。先说结论Flask的成熟生态与渐进式复杂度更适合中小型业务系统的快速落地和长期维护。做个直观对比对比维度FlaskFastAPI异步支持原生WSGI同步为主配合gunicorn多worker原生ASGI异步高并发IO场景占优数据库生态SQLAlchemy集成资料多Flask-SQLAlchemy开箱即用SQLAlchemy同样可用但异步ORM搭配需要额外学习认证授权Flask-JWT-Extended、Flask-Login方案成熟python-jose等方案同样可行但样板代码偏多学习成本文档通俗周边教程丰富遇到问题搜得到答案上手快但异步思维和依赖注入需要适应部署资料nginxgunicornFlask教程一大把宝塔也有一键流程uvicorn部署简单但生产调优资料相对少适合场景传统业务系统、CMS、教学平台、中小型API高并发API服务、实时数据接口、AI模型服务对于学习系统这个具体场景有个很关键的现实因素团队的协作成本。如果后续接手的人不熟悉异步编程FastAPI 的 async def 用得不好反而会在 IO 密集型操作上写出阻塞代码。Flask 的同步模型简单直接一个视图函数处理一个请求逻辑清清楚楚任何有 Python 基础的开发都能快速上手。另外一个让我坚定选 Flask 的点是Flask-SQLAlchemy 与 Flask-Migrate 的组合。教学系统的数据库结构在开发期会频繁变动——今天加一个课程封面字段明天给作业表加一个评分字段。Flask-Migrate 基于 Alembic可以平滑地做数据库迁移不会因为改表结构导致数据丢失。FastAPI 里虽然也能用 Alembic但需要手动初始化配置样板代码多一层。还有一个细节值得提Flask 的路由和蓝图机制在组织这类多模块系统时非常舒服。我会把用户认证、课程内容、学习进度、作品上传拆成独立的蓝图每个蓝图对应一个 Python 模块。FastAPI 用 APIRouter 也能做类似的事但 Flask 蓝图的理念更贴近传统 MVC 项目的组织习惯目录结构一看就懂。如果你要做一个面向公众、预期并发极高的 API 服务我推荐 FastAPI。但如果你要做的是内部教学平台或培训机构使用的业务系统Flask 是更务实的选择。两者没有绝对的好坏只有场景匹配度的问题。3. 数据库模型设计课程、用户、学习进度是三个核心数据库是整个后端系统的地基模型设计得好不好直接决定后面写接口是费劲还是顺畅。我在这个项目里最终落地的模型结构是在推翻两版设计之后定下来的。这里分享最终的方案并且说明每个关键设计的理由。3.1 用户模型不止是账号密码用户表不能只存用户名和密码。因为我需要区分平台管理员、教师、学员三类角色而且教师还可能是某几门课程的作者。最终我用的是角色字段加关联表的方式而不是单独建三张用户表。class User(db.Model): __tablename__ users id db.Column(db.Integer, primary_keyTrue) username db.Column(db.String(64), uniqueTrue, nullableFalse, indexTrue) password_hash db.Column(db.String(128), nullableFalse) email db.Column(db.String(120), uniqueTrue, nullableFalse) role db.Column(db.String(20), nullableFalse, defaultstudent) # admin / teacher / student avatar_url db.Column(db.String(256)) created_at db.Column(db.DateTime, defaultdatetime.utcnow) updated_at db.Column(db.DateTime, defaultdatetime.utcnow, onupdatedatetime.utcnow) def set_password(self, password): self.password_hash generate_password_hash(password) def check_password(self, password): return check_password_hash(self.password_hash, password)密码加密用的是 Werkzeug 的generate_password_hash默认的 pbkdf2 算法已经足够安全不要自己去写什么加盐算法没有意义标准库帮你把该考虑的事情都考虑好了。用户表的role字段用字符串而不是数字是考虑到可读性。写接口的时候if user.role ! teacher比if user.role ! 2直观得多也不会出现2到底是老师还是管理员的困惑。3.2 课程与章节内容结构要有序设计类课程和普通文档课程的最大区别是内容形态多。一个章节里可能有正文、图片、附件、甚至一个小的测验。所以课程和章节需要分成两张表并且章节必须有一个sort_order字段来维护顺序。class Course(db.Model): __tablename__ courses id db.Column(db.Integer, primary_keyTrue) title db.Column(db.String(128), nullableFalse) subtitle db.Column(db.String(256)) cover_image db.Column(db.String(256)) description db.Column(db.Text) teacher_id db.Column(db.Integer, db.ForeignKey(users.id)) is_published db.Column(db.Boolean, defaultFalse) created_at db.Column(db.DateTime, defaultdatetime.utcnow) updated_at db.Column(db.DateTime, defaultdatetime.utcnow, onupdatedatetime.utcnow) class Chapter(db.Model): __tablename__ chapters id db.Column(db.Integer, primary_keyTrue) course_id db.Column(db.Integer, db.ForeignKey(courses.id), indexTrue) title db.Column(db.String(128), nullableFalse) content db.Column(db.Text) # 正文用 Markdown 或富文本 HTML video_url db.Column(db.String(256)) # 可选的视频链接 attachment_url db.Column(db.String(256)) # 可选的附件下载地址 sort_order db.Column(db.Integer, default0, indexTrue) is_free db.Column(db.Boolean, defaultFalse) # 是否允许试读 created_at db.Column(db.DateTime, defaultdatetime.utcnow)有个细节容易被忽略is_free字段。很多教学平台的课程会开放前几章让游客试读以吸引注册。这个字段放在章节级别而不是课程级别是因为试读策略通常是前两章能读后面要报名放在章节上才能灵活控制。课程与教师的关系为什么要用外键而不是简单地冗余一个教师名字因为后期做学情统计时经常要根据教师 ID 聚合查询这个老师名下所有课程的学员完成率有了外键和索引一条 SQL 就能算出来不用在业务层做内存级联筛选。3.3 学习进度表这是整个系统的灵魂学习进度的设计经历了三个版本。V1 是在 users 表里加一个current_course_id和progress_json字段用 JSON 存一个映射表比如{course_1: {chapter_5: True}}。这种方案看着简单字符串拼接就能搞定但完全没法做 SQL 查询比如找出所有学完第三章的人就只能全表扫描再解析 JSON数据量大了必炸。V2 是在课程表加一个learners的 JSON 数组记录每个学完课程的人。这更糟糕因为用户和课程是多对多关系一个用户可以学多门课这样设计是在重复造一张表。V3 就是最终方案建一张独立的关联表既是学习进度记录也是用户选课关系表class Enrollment(db.Model): __tablename__ enrollments id db.Column(db.Integer, primary_keyTrue) user_id db.Column(db.Integer, db.ForeignKey(users.id), indexTrue) course_id db.Column(db.Integer, db.ForeignKey(courses.id), indexTrue) progress db.Column(db.Integer, default0) # 已学章节数方便列表页展示 is_completed db.Column(db.Boolean, defaultFalse) completed_chapters db.Column(db.Text, default) # 逗号分隔的章节ID集合 created_at db.Column(db.DateTime, defaultdatetime.utcnow) completed_at db.Column(db.DateTime, defaultNone) __table_args__ ( db.UniqueConstraint(user_id, course_id, nameuq_user_course), )这里有两个关键设计点。第一progress是一个冗余的整数字段直接存已经学完了几个章节。虽然可以通过completed_chapters拆开算出这个数字但课程列表页要显示学习进度条如果每次都要把字符串解析成列表再数元素性能就会成为问题。用一个整数字段一个前端接口直接返回一条 SQL 都不用多查。第二completed_chapters用逗号分隔的 ID 字符串而不是单独的关联表。这里我要坦白说这是一个妥协方案。严格的关系型设计会建一张chapter_completions表每完成一章插一条记录这样能精确查询每个章节被多少人完成过。但实际上这个系统里我的需求只是从整体上判断学到了哪里精确到某个章节已完成就足够了。一张关联表会让接口逻辑冗长插入操作多好几倍。字符串方案在千级章节数以下完全够用而且用SELECT FIND_IN_SET(某章节ID, completed_chapters)这种 MySQL 函数或者 Python 端的简单判断都能处理。进度更新的逻辑也很关键。前端在用户学完一章后调用进度更新接口后端需要做增量处理而不能是全量覆盖。代码思路是先把旧的completed_chapters解析成集合把新完成的章节 ID 加进去再写回字符串。同时更新progress字段判断progress是否等于该课程的总章节数如果是就标记is_completedTrue并写completed_at时间。def update_progress(user_id, course_id, chapter_id): enrollment Enrollment.query.filter_by( user_iduser_id, course_idcourse_id ).first() if not enrollment: # 首次学习则自动选课 enrollment Enrollment(user_iduser_id, course_idcourse_id) db.session.add(enrollment) completed_set set( int(x) for x in enrollment.completed_chapters.split(,) if x ) completed_set.add(chapter_id) enrollment.completed_chapters ,.join(str(x) for x in sorted(completed_set)) enrollment.progress len(completed_set) chapter_count Chapter.query.filter_by(course_idcourse_id).count() if enrollment.progress chapter_count: enrollment.is_completed True enrollment.completed_at datetime.utcnow() db.session.commit()3.4 作业提交表设计课程特有的一块因为教学设计课程的特殊性我增加了作业提交功能。模型也分为两个角色视角学员提交作品教师给反馈。class Assignment(db.Model): __tablename__ assignments id db.Column(db.Integer, primary_keyTrue) course_id db.Column(db.Integer, db.ForeignKey(courses.id)) chapter_id db.Column(db.Integer, db.ForeignKey(chapters.id)) user_id db.Column(db.Integer, db.ForeignKey(users.id)) file_url db.Column(db.String(256), nullableFalse) thumbnail_url db.Column(db.String(256)) submission_note db.Column(db.Text) # 学员的自述说明 feedback db.Column(db.Text) # 教师评语 score db.Column(db.Integer) # 评分 0-100 reviewed_at db.Column(db.DateTime) created_at db.Column(db.DateTime, defaultdatetime.utcnow)thumbnail_url是我在初版设计时漏掉的字段后来补上的。设计课的作品提交通常是图片列表页如果直接加载原图页面大小会很夸张。我在上传时用 Pillow 库生成一张压缩过的缩略图列表页加载又快又省流量。这个细节强烈建议所有涉及图片上传的开发者都考虑进去。4. 认证与权限JWT方案在前后端分离下的落地这个系统的前端是 Vue 做的与后端完全分离部署。Session 认证在这种架构下体验很差——跨域要处理 Cookie移动端还要兼容 Cookie 存储。所以认证方案我直接用了 JWT具体是flask-jwt-extended这个扩展。4.1 登录接口与 Token 设计登录接口很简单接收用户名和密码校验通过后返回一个 access_token 和 refresh_token。access_token有效期我设置为2 小时refresh_token有效期设置为7 天。为什么分开因为如果只有一个 token过期了用户就要重新登录对于一个学习平台来说让人家学到一半跳出去登录是很糟糕的体验。双 token 机制下前端检测到 access_token 过期后自动用 refresh_token 换取新的用户基本无感知。from flask_jwt_extended import create_access_token, create_refresh_token app.post(/api/auth/login) def login(): data request.get_json() user User.query.filter_by(usernamedata.get(username)).first() if not user or not user.check_password(data.get(password)): return jsonify(code400, message用户名或密码错误), 400 access_token create_access_token(identitystr(user.id)) refresh_token create_refresh_token(identitystr(user.id)) return jsonify( code0, data{ access_token: access_token, refresh_token: refresh_token, user_info: { id: user.id, username: user.username, role: user.role, avatar_url: user.avatar_url, } } )这里有个值得分享的坑create_access_token的 identity 参数只接受字符串如果传 int 会直接报错。我第一次写的时候传了user.id的整数跑测试才发现这个问题。建议习惯性用str(user.id)。4.2 角色权限校验的装饰器设计jwt_required()只能保证你有登录身份不能保证你是教师。Flask 中可以通过自定义装饰器做角色控制from functools import wraps from flask_jwt_extended import get_jwt_identity def role_required(*roles): def wrapper(fn): wraps(fn) jwt_required() def decorator(*args, **kwargs): user_id get_jwt_identity() user db.session.get(User, int(user_id)) if user.role not in roles: return jsonify(code403, message没有权限访问), 403 return fn(*args, **kwargs) return decorator return wrapper使用的时候就很灵活了app.get(/api/teacher/courses) role_required(teacher, admin) def teacher_courses(): courses Course.query.filter_by(teacher_idget_jwt_identity_as_int()).all() return jsonify(code0, data[c.to_dict() for c in courses])权限控制的原则是能在装饰器层拦住的绝不在业务代码里用 if 判断。这样每个接口的权限模型一目了然review 代码的时候扫一眼装饰器就知道谁能访问。另外提一下get_jwt_identity()返回的是字符串因为前面创建 token 时传入的是str(user.id)。每次要从 token 拿到用户 ID 再去数据库查用户这确实会多一次查询。不过学习系统这个量级完全无所谓请放心用。如果真要优化到极致可以考虑用get_jwt()拿到 claims 里的自定义字段但别为了这点性能牺牲了可维护性。4.3 token 过期的前端配合前端那边配合这套 JWT 方案也踩了些坑。我让前端在 axios 拦截器里统一处理 401 响应收到 401 后判断本地是否有 refresh_token有就静默调用刷新接口刷新成功后重放原请求失败就跳转登录页。后端只需要保证刷新接口本身返回语义清晰的状态码就好。刷新接口我限制为只能使用 refresh_token不允许 access_token 调用刷新接口否则会带来安全隐患。用jwt_required(refreshTrue)装饰器做区分这是 flask-jwt-extended 提供的标准能力。5. 跨域与API设计前后端对接时踩过的坑前后端分离的项目跨域问题是绕不开的。开发时前端在localhost:5173后端在localhost:5000浏览器的同源策略立刻就会给你颜色看。我第一次前后端联调时前端请求后端接口直接报blocked by CORS policy实际上就是响应头里少了Access-Control-Allow-Origin。5.1 CORS 的正确配置方式最简单可靠的是用flask-cors这个库。我的配置如下from flask_cors import CORS CORS(app, resources{ r/api/*: { origins: [http://localhost:5173, https://learn.example.com], methods: [GET, POST, PUT, DELETE, OPTIONS], allow_headers: [Content-Type, Authorization], supports_credentials: True } })resources这个参数很关键我一开始图省事写的是CORS(app)直接全部放开。后来发现生产环境什么来源都能访问才意识到这是个安全隐患。正确的做法是只允许前端的域名并且在部署到不同环境时用环境变量来控制。如果前端要携带Authorization请求头必须把allow_headers里加上同时 CORS 请求会先发一个 OPTIONS 的预检请求preflightFlask 的路由通常不会自动处理这个请求flask-cors 库会在背后自动拦截并返回正确的响应头这里不需要自己写 OPTIONS 路由处理函数。5.2 统一响应结构避免前后端各写一套解析逻辑API 返回结构我定为三层成功返回code0业务失败返回非 0 的 code遇到鉴权问题返回 401 或 403 的 HTTP 状态码并带上code字段。def success(dataNone, messageok): return jsonify(code0, messagemessage, datadata) def fail(messageerror, code1, http_status400): return jsonify(codecode, messagemessage, dataNone), http_status为什么 HTTP 状态码之外还要一个业务 code因为有些场景不需要 HTTP 状态码变化比如密码校验失败返回 400 是合理的但订单状态不能重复提交这类业务冲突用 200 加上业务 code 更容易让前端全局拦截器区分逻辑。每个团队有自己的约定关键是前后端要一致。我在设计接口文档时给的示例都是统一会用code/message/data的结构避免前端每个页面写一套判断逻辑。5.3 RESTful 资源的命名与嵌套粒度学习系统的接口路径我遵循这么几个原则资源名用复数/api/courses、/api/users子资源用嵌套/api/courses/3/chapters动作不放在 URL 上用 HTTP 方法区分不搞/api/course/getDetailById有一个现实中容易引起争论的点是该用整型 ID 还是 UUID 做主键这个系统我最终选择了整型自增主键。理由很简单内网教学系统的 ID 暴露不涉及安全风险自增 ID 的查询性能更好联表查询的代码也更简洁。如果你做的是面向公网且用户可互相查看他人信息的系统才需要认真考虑用 UUID 代替自增 ID防止被别人通过遍历 ID 爬取数据。6. 文件上传与静态资源管理设计课程内容的核心前面反复提到设计类课程的附件和作业上传这一章单独展开说说。上传是教学系统里最容易出安全事故和 bug 的地方必须小心处理。6.1 上传接口的实现与文件命名策略我做了两个上传接口一个是教师上传课程资源图片、附件一个是学员提交作业。两者的存储目录不同权限也不同。UPLOAD_BASE_DIR os.path.join(os.getcwd(), uploads) ALLOWED_EXTENSIONS {png, jpg, jpeg, gif, webp, pdf, zip, rar} def allowed_file(filename): return . in filename and filename.rsplit(., 1)[1].lower() in ALLOWED_EXTENSIONS app.post(/api/upload) role_required(teacher, admin) def upload_file(): file request.files.get(file) if file is None or file.filename : return fail(未选择文件) if not allowed_file(file.filename): return fail(文件类型不允许上传, http_status415) current_date datetime.now().strftime(%Y/%m/%d) sub_dir os.path.join(UPLOAD_BASE_DIR, current_date) os.makedirs(sub_dir, exist_okTrue) ext file.filename.rsplit(., 1)[1].lower() uuid_name f{uuid.uuid4().hex}.{ext} file_path os.path.join(sub_dir, uuid_name) file.save(file_path) url_path f/uploads/{current_date}/{uuid_name} return success(data{url: url_path})这里有一个我踩过的坑前端传过来的 filename 可能是中文如果直接用原来的文件名保存既可能触发编码问题又可能导致目录出现不可控的字符。正确做法就是上面代码里那样用自己的uuid4().hex生成随机文件名扩展名从原文件名里提取并白名单校验。这样既避免了文件名冲突也防了一手路径遍历攻击比如文件名里带../。6.2 图片压缩与缩略图生成设计课程的展示位置需要图片作业缩略图也需要压缩。我用 Pillow 做图片处理上传时如果发现是图片类型就生成一份宽度不超过 800px 的压缩版本作为内容展示图再生成一份宽度 200px 的版本作为缩略图。from PIL import Image def generate_thumbnails(file_path, url_base_path): img Image.open(file_path) img.thumbnail((200, 200)) thumb_path file_path.replace(., _thumb.) img.save(thumb_path, quality85) return f{url_base_path.replace(., _thumb.)}这段代码虽然简单但要注意不同格式的图片在 Pillow 中可能要显式处理 EXIF 旋转问题。手机上传的照片常常带了旋转信息直接缩略会导致图片方向不对。解决方案是读取exif_transpose之后再缩略。这个细节我是在测试学员用手机传图时发现的当时还以为是前端 CSS 的问题。6.3 静态资源的访问控制把文件放在uploads目录后Flask 需要配置静态文件路由来访问app.route(/uploads/path:filename) def uploaded_file(filename): # 不强制登录的公开资源课程封面、公开章节的图片 return send_from_directory(uploads, filename)但作业提交的文件需要设置权限不能谁都能下载别人的作业。我的做法是把作业上传到uploads/assignments/子目录然后给这个路由加上访问校验app.route(/uploads/assignments/path:filename) jwt_required() def assignment_file(filename): user_id get_jwt_identity() assignment Assignment.query.filter_by(file_urlf/uploads/assignments/{filename}).first() if assignment is None: return fail(资源不存在, http_status404) # 学员只能访问自己的作业教师可以访问所有 if assignment.user_id ! int(user_id) and current_user_role() not in (teacher, admin): return fail(无权访问, http_status403) return send_from_directory(uploads/assignments, filename)这一块是多数教学系统做不好的地方图省事直接开放了静态目录结果学员的作业可以被随便遍历下载。给静态资源加一层访问控制在 Flask 里其实很轻量关键是有没有这个意识。6.4 后续存储扩展思路当前方案是存本地磁盘对教学系统来说够用。如果以后用户量上来文件量变大有两种演进路径一是接入对象存储服务把 URL 改为云存储提供的地址二是用分布式存储并挂载到服务器磁盘上。我现在的建议是开发期用本地磁盘上生产后如果预算充足第一时间把 uploads 目录迁到对象存储因为对象存储在带宽、冗余备份、访问加速上都有优势。迁移的改造成本很小只要把上传接口的存储逻辑替换成 SDK 调用即可对外暴露的 URL 结构和 API 参数可以保持不变。7. 部署阶段的实测心得nginx gunicorn Flask 的组合系统开发完成后部署我选了经典的nginx gunicorn Flask组合服务器用的是宝塔面板管理。这一节把部署配置和遇到的实际问题完整记录下来。7.1 gunicorn 配置与进程数选择我使用的是 gunicorn 作为 WSGI 服务器没有用 Flask 自带的 dev server——那个是真的只能开发用并发能力差而且会暴露调试信息。gunicorn 的启动命令如下gunicorn -w 4 -b 127.0.0.1:8000 -k gthread --threads 4 --timeout 60 app:app参数说明-w 4启动 4 个 worker 进程-k gthread --threads 4每个 worker 启 4 个线程--timeout 60请求超时时间默认 30 秒worker 数和线程数的确定依据是服务器的 CPU 核数。一台 2 核 4G 的云服务器我会配置-w 4 --threads 4这样系统能同时处理 16 个并发请求对于学习系统完全够用。这里不要盲目设置大数值worker 太多反而会因为内存占用过高导致服务器卡死。7.2 nginx 反向代理配置nginx 负责对外接收请求、做静态文件缓存、负载到后端。关键的配置段server { listen 80; server_name learn.example.com; client_max_body_size 50m; location / { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } location /uploads/ { alias /www/wwwroot/learn/uploads/; expires 7d; add_header Cache-Control public; } }client_max_body_size 50m这一行不能漏否则上传超过默认 1M 的文件时nginx 会直接给 413 错误前端完全猜不到是什么情况。我最初就是没配这行学员传作业图传不上去排查了半小时。/uploads/的静态文件由 nginx 直接处理不经过 Flask这样资源加载速度快很多也减轻了 gunicorn 的压力。但这里有个坑要注意如果/uploads/assignments/需要权限校验就不能全量交给 nginx 直接返回静态文件。我的处理是有两套公开的课程封面、章节图片走 nginx作业文件走 Flask 的权限校验路由所以 nginx 里只把公开上传目录的路径交给 alias作业目录则全部反代到后端。7.3 进程守护与自动重启gunicorn 进程如果挂了服务就断了。生产上我用 supervisor 来守护进程。配置如下[program:learn-backend] directory/www/wwwroot/learn commandgunicorn -w 4 -b 127.0.0.1:8000 -k gthread --threads 4 --timeout 60 app:app autostarttrue autorestarttrue stderr_logfile/www/wwwroot/learn/logs/gunicorn.err.log stdout_logfile/www/wwwroot/learn/logs/gunicorn.out.logsupervisor 的autorestarttrue会在进程意外退出时自动拉起。我实测过几次gunicorn 内存异常退出后几秒钟内就会被 supervisor 重新启动服务基本不中断。不要裸跑 gunicorn生产环境没有守护进程就是给自己埋雷。宝塔面板本身也有 Python 项目管理器可以用它来托管 gunicorn但我个人更习惯直接改 supervisor 配置因为命令行下操作可控性更高日志路径也能自己指定。7.4 部署时踩过的真实问题第一gunicorn worker 频繁超时重启。高峰期会出现Worker timed out的日志原因是某个接口执行时间超过 60 秒主要是批量导入课程数据时没用异步任务直接在请求里同步处理了。解决方式把导入操作拆成小批次每次只处理 20 条前端的交互改为分批轮询结果。到目前为止没有再出现长时间占用的请求。第二SQLite 到 MySQL 的迁移。开发时我图方便用了 SQLite生产环境改成了 MySQL。连接配置从sqlite:///改成mysqlpymysql://user:passlocalhost/learn_db?charsetutf8mb4。这次迁移还算顺但要注意 SQLAlchemy 模型里面如果用了 SQLite 特有的字段类型迁移起来会很痛苦建议一开始就用 MySQL 开发或者至少在开发环境就用和线上一致的数据库。第三gunicorn 的 worker 数量与内存的关系。默认每个 worker 会加载完整的应用内存我的应用大概占 150M4 个 worker 就是 600M 左右还没算 MySQL 的内存占用。如果服务器的内存只有 2G建议 worker 数量降到 2。可以在 gunicorn 配置里加--max-requests 1000 --max-requests-jitter 100让 worker 在处理一定量请求后自动重启能有效缓解内存泄漏问题。第四跨域在生产环境又出问题。开发环境配的origins是http://localhost:5173上线后前端域名变成了https://learn.example.com结果所有接口又被 CORS 拦截了。所以前面我特意强调要把允许的来源放到环境变量里部署时改配置而不是改代码。这也是我这次实训的教训希望大家别走弯路。第五日志监控的必要性。gunicorn 的错误日志要定期看很多问题不是用户报出来的而是日志里先出现的。我在服务器上挂了一个定时任务每天检查日志文件大小和错误关键字配合 supervisor 的日志切分基本能做到问题早发现。7.5 数据库连接池与并发Flask-SQLAlchemy 会自动管理数据库连接池默认pool_size5max_overflow10。对于学习系统这个配置一般够用。但如果你发现数据库连接数经常打满可以显式调大app.config[SQLALCHEMY_ENGINE_OPTIONS] { pool_size: 10, pool_recycle: 3600, pool_pre_ping: True, }pool_pre_pingTrue这行很关键它会在每次取连接前先 ping 一下防止拿到失效的连接池连接。MySQL 默认的wait_timeout是 8 小时如果 MySQL 主动断开了空闲连接而连接池里还握着这个失效连接查询就会报错加了pool_pre_ping就能自动规避。8. 接口文档与前后端协作的实操建议作为一个自己独立开发的系统接口文档好像可有可无但正因为这套系统未来可能要交给别人维护文档就成了必须品。我用的是 Apifox把接口按模块分组每个接口标好请求参数、返回示例、错误码含义。这样做的直接好处是前端对接时有据可查不会跑来问你这个接口返回什么字段。接口文档我是边写代码边维护的而不是等全部写完再补——补文档这件事一旦拖就永远不会去做了。在设计接口字段时我统一了命名风格为下划线命名法如course_id、user_name前端在适配层做驼峰转换。一个小技巧是大多数前端网络库都支持响应拦截器在拦截器里做 key 转换比在每个组件里单独处理高效得多。错误码的设计也需要提早约定。比如 401 表示未登录403 表示无权限404 表示资源不存在这些语义和 HTTP 状态码保持一致。业务层面的自定义 code 我会从 1001 开始分配1001 表示“课程未发布”1002 表示“不能重复选课”1003 表示“作业内容为空”等等。错误码的意义在于让前端能根据 code 做精确的提示或跳转而不只是弹一个笼统的错误。我想额外提醒的是即使是两个人合作也必须在数据库表结构定型后第一时间把模型说明文档写出来。数据字典可以用 Excel 或在线表格维护字段名、类型、是否必填、默认值、备注都要写清楚。这个系统开发到第三个月时我回头再看当初建的表有些字段的用途已经需要翻代码才能想起来了文档化确实是省心的事。9. 开发顺序与功能优先级我的实际操作路径最后分享一个偏管理层面的经验这套系统从零开始我的开发顺序不是按模块一个一个来而是按下图这条主线先打通最小闭环再横向扩展功能。第一步是做“用户注册登录 课程列表 课程详情章节”这条链路。虽然这看起来非常简单但它验证的是前后端数据交互的整个通道是否畅通包括跨域、认证、数据库读写、接口响应格式。一个能跑起来的最小闭环比一堆写了一半的功能强得多。第二步加“选课与学习进度记录”让系统具备学习平台的雏形。这时每多一个接口前端都能立刻联调而不是等你把所有功能写完。第三步加入“作业上传与教师评价”把教学设计课程特有的闭环补全。第四步做管理后台的接口比如课程发布、章节内容管理、学员列表与进度查看。考虑到 CMS 功能其实就是基础的增删改查我把它放到了后面不给前期核心功能拖后腿。第五步才是性能优化和装饰性功能比如图片压缩、封面裁剪、接口响应速度优化、日志记录等。这个顺序看似很基础但它保证了项目在任何阶段都是可用的而不是在最后一个星期才把所有模块拼在一起然后疯狂 debug。我见过太多从后端框架搭建开始就想着一步到位把权限、多角色、消息通知全部设计好结果写了两个月连一个完整的课程详情页还没跑通的案例。另外开发时要善用脚手架工具。我用了flask-blueprint和flask-restx的辅助方法但并没有引入全套重型框架。对于这种规模的后端直接写清楚路由函数比引入太多抽象层更有价值核心是让代码可读、可维护。最后说一点个人体会整套系统从设计到上线最有价值的经验其实不是某个具体技术细节而是始终把真实业务场景放在第一位。设计课程学习系统这种业务后端的技术难点不在并发或者分布式而在于数据模型是否贴合真实的教学流程、权限设计是否能覆盖多种师生互动场景、文件管理是否能兼顾安全性和易用性。Flask 恰好是这样的框架它不强求你用什么模式但也绝不限制你做出规整的项目结构。如果你的项目也是中小型的教学系统按照我上面写的模型设计和接口划分来做开发周期大约在四到六周可以完成核心功能。后面需要扩展时优先考虑按模块新增蓝图而不是修改旧逻辑。Flask 这个技术栈可能不够新潮但在业务系统这个领域它依旧是那个最可靠的选择之一。