一个ai写的教程
Flask 从零到一:自托管班级网站实战教程
适合人群 :会一点 Python 基础语法(变量、函数、类),没学过 Web 框架。学完你将得到 :一个部署在自己电脑上、全班内网可访问的班级网站 —— 完全自托管,不依赖任何云服务。建议节奏 :每天 1 章,边敲边学。每章末尾有 ✅ 检查点,全部通过再进入下一章。
📋 最终项目需求清单(先看清楚要造什么)
#
需求
对应章节
1
SQLite 数据库
第 6-9 章
2
只有登录界面,无公开注册(Kavita 式:首次启动创建管理员,之后账号由管理员创建)
第 11 章
3
管理员手动添加数据 或 Excel 表格批量导入
第 12 章
4
模块可自由开启/关闭:公告、作业、班级相册、班级博客、视频课堂
第 13 章
5
视频课堂:上传自己的教学视频 + 嵌入 B 站视频
第 18 章
6
整个项目自托管
第 20 章
第一部分:Flask 入门(第 1-5 章) 第 1 章 环境搭建 1 2 3 4 5 6 7 8 9 10 11 12 13 python3 --version mkdir myclass && cd myclasspython3 -m venv venv source venv/bin/activate pip install flask flask-sqlalchemy flask-login flask-wtf openpyxl pip freeze > requirements.txt
✅ 检查点 :pip list 能看到 Flask 和 openpyxl。
⚠️ 真实踩坑 #1 :如果你在 NAS/网盘挂载目录、或某些 Linux 发行版直接 pip install 报错externally-managed-environment,说明必须先激活虚拟环境再装;sqlite3.OperationalError: disk I/O error 则是数据库文件放在了不支持文件锁的网络盘上——把 instance/ 挪到本地磁盘即可。
第 2 章 第一个 Flask 应用:路由 新建 app.py:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 from flask import Flaskapp = Flask(__name__) app.config['SECRET_KEY' ] = '换成一串随机字符' @app.route('/' ) def index (): return '<h1>班级网站</h1>' @app.route('/hello/<name>' ) def hello (name ): return f'你好,{name} !' if __name__ == '__main__' : app.run(debug=True )
核心概念:
路由 = URL 与 Python 函数的映射,methods=['GET','POST'] 区分查看和提交
url_for('index') 生成 URL,永远不要硬编码路径
debug 模式 会暴露调试器密码,只能在内网开发用
✅ 检查点 :浏览器访问 /hello/张三 能看到问候。
第 3 章 模板 Jinja2 HTML 永远写在 templates/ 文件夹,不要塞在 Python 里:
1 2 3 4 5 myclass/ ├── app.py └── templates/ ├── base.html ← 母版:导航栏、页脚只写一次 └── index.html
templates/base.html:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 <!DOCTYPE html > <html lang ="zh-CN" > <head > <meta charset ="UTF-8" > <meta name ="viewport" content ="width=device-width, initial-scale=1" > <title > {% block title %}{% endblock %} - 班级网站</title > <link href ="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel ="stylesheet" > </head > <body style ="padding-top:56px" > <nav class ="navbar navbar-dark bg-dark fixed-top" > <div class ="container" > <a class ="navbar-brand" href ="{{ url_for('index') }}" > 🎓 班级网站</a > <div class ="d-flex gap-3" > {# 模块导航:第 13 章会根据开关动态生成 #} {% block nav %}{% endblock %} </div > </div > </nav > <div class ="container mt-3" > {% with msgs = get_flashed_messages(with_categories=true) %} {% for cat, m in msgs %} <div class ="alert alert-{{ cat }}" > {{ m }}</div > {% endfor %} {% endwith %} </div > {% block content %}{% endblock %} </body > </html >
templates/index.html:
1 2 3 4 5 6 7 8 9 10 {% extends "base.html" %} {% block title %}首页{% endblock %} {% block content %} <h1 > {{ welcome }}</h1 > {% for s in students %} <p > {{ loop.index }}. {{ s.name }}({{ s.student_no }})</p > {% else %} <p class ="text-muted" > 还没有学生数据</p > {% endfor %} {% endblock %}
三种语法背下来:{{ 变量 }}、{% 指令 %}、{# 注释 #}。
⚠️ 真实踩坑 #2 :Jinja2 模板里没有 Python 内置函数 。写 {% if any(list) %} 会报jinja_pass_arg ... 'any' is undefined。要么用 Jinja 过滤器(| length > 0),要么在路由里把布尔值算好再传给模板——推荐后者 ,模板保持笨。
第 4 章 静态文件 图片、CSS、JS 放 static/,模板里用 {{ url_for('static', filename='logo.png') }} 引用。班级相册的照片不放 static (那些是用户上传的,放 uploads/ 并用专门路由送出去,见第 16、18 章)。
第 5 章 表单:Flask-WTF 「GET 显示表单 → POST 验证处理 → 成功重定向」是 Web 表单的标准节奏(PRG 模式)。
1 2 3 4 5 6 7 8 9 from flask_wtf import FlaskFormfrom wtforms import StringField, PasswordField, SelectField, SubmitFieldfrom wtforms.validators import DataRequired, Length, Optional class StudentForm (FlaskForm ): name = StringField('姓名' , validators=[DataRequired('姓名不能空' )]) student_no = StringField('学号' , validators=[DataRequired(), Length(max =20 )]) role = SelectField('身份' , choices=[('student' ,'学生' ),('teacher' ,'教师' )]) submit = SubmitField('添加' )
模板里 {{ form.hidden_tag() }} 永远不能省 ——那是 CSRF 令牌。省了就是给用户的数据挖坑。
✅ 检查点:故意不写 hidden_tag 提交表单,观察 Flask 返回 400,理解它在保护什么。
第二部分:SQLite 深入(第 6-9 章) 第 6 章 先懂数据库:sqlite3 命令行 SQLite = 一个文件就是一个数据库 。不用装服务、不用开端口,班级网站几百人规模完全够用。先花 10 分钟摸一下底层,后面用 ORM 才不心虚:
1 2 3 4 5 6 7 8 9 10 11 12 $ sqlite3 class.db sqlite> CREATE TABLE students ( ...> id INTEGER PRIMARY KEY AUTOINCREMENT, ...> name TEXT NOT NULL, ...> student_no TEXT UNIQUE ...> ); sqlite> INSERT INTO students (name, student_no) VALUES ('张三' , '2024001' ); sqlite> SELECT * FROM students; 1|张三|2024001 sqlite> .tables -- 查看所有表 sqlite> .schema students -- 查看建表语句 sqlite> .quit
现在回头看 class.db——这个文件就是”数据库”本身,备份 = 复制文件 。这就是自托管的福利。
第 7 章 SQLAlchemy:用类操作表 ORM 映射关系 :类 → 表,对象 → 行,属性 → 列。
1 2 3 4 5 6 7 8 9 10 from datetime import datetimefrom flask_sqlalchemy import SQLAlchemydb = SQLAlchemy() class Student (db.Model): __tablename__ = 'students' id = db.Column(db.Integer, primary_key=True ) name = db.Column(db.String(50 ), nullable=False ) student_no = db.Column(db.String(20 ), unique=True , index=True ) created_at = db.Column(db.DateTime, default=datetime.utcnow)
常用查询 API(本项目 95% 的查询都出自这里):
1 2 3 4 5 6 7 8 9 10 Student.query.all () Student.query.get(1 ) Student.query.filter_by(name='张三' ) Student.query.filter (Student.score >= 90 ) Student.query.order_by(Student.created_at.desc()) Student.query.paginate(page=1 , per_page=20 ) Student.query.count() db.session.add(obj); db.session.commit() db.session.delete(obj); db.session.commit() db.session.rollback()
第 8 章 本项目的完整表设计 先画图再建表——这是 Mode 5 架构设计的核心习惯:
1 2 3 4 5 6 7 8 users ──┬──< announcements 公告(谁发的) ├──< assignments ──< submissions 作业 → 提交 ├──< photos 相册照片 ├──< blog_posts 博客文章 ├──< videos 视频(上传/ B站) └── student_profile(1:1) 学生档案 modules 模块开关表(key + enabled)
models.py 全部模型(实战阶段直接用,逐条都有注释):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 from datetime import datetimefrom flask_sqlalchemy import SQLAlchemyfrom flask_login import UserMixinfrom werkzeug.security import generate_password_hash, check_password_hashdb = SQLAlchemy() class User (UserMixin, db.Model): __tablename__ = 'users' id = db.Column(db.Integer, primary_key=True ) username = db.Column(db.String(50 ), unique=True , nullable=False ) password_hash = db.Column(db.String(256 ), nullable=False ) real_name = db.Column(db.String(50 )) role = db.Column(db.String(20 ), default='student' ) student_no = db.Column(db.String(20 ), unique=True , nullable=True ) created_at = db.Column(db.DateTime, default=datetime.utcnow) def set_password (self, pw ): self .password_hash = generate_password_hash(pw) def check_password (self, pw ): return check_password_hash(self .password_hash, pw) def is_admin (self ): return self .role == 'admin' class Module (db.Model): __tablename__ = 'modules' id = db.Column(db.Integer, primary_key=True ) key = db.Column(db.String(30 ), unique=True ) name = db.Column(db.String(30 )) enabled = db.Column(db.Boolean, default=True ) class Announcement (db.Model): __tablename__ = 'announcements' id = db.Column(db.Integer, primary_key=True ) title = db.Column(db.String(200 ), nullable=False ) content = db.Column(db.Text, nullable=False ) author_id = db.Column(db.Integer, db.ForeignKey('users.id' )) created_at = db.Column(db.DateTime, default=datetime.utcnow) author = db.relationship('User' , backref='announcements' ) class Assignment (db.Model): __tablename__ = 'assignments' id = db.Column(db.Integer, primary_key=True ) title = db.Column(db.String(200 ), nullable=False ) description = db.Column(db.Text) subject = db.Column(db.String(50 )) due_date = db.Column(db.DateTime) creator_id = db.Column(db.Integer, db.ForeignKey('users.id' )) created_at = db.Column(db.DateTime, default=datetime.utcnow) creator = db.relationship('User' , backref='assignments' ) class Submission (db.Model): __tablename__ = 'submissions' id = db.Column(db.Integer, primary_key=True ) assignment_id = db.Column(db.Integer, db.ForeignKey('assignments.id' )) student_id = db.Column(db.Integer, db.ForeignKey('users.id' )) content = db.Column(db.Text) grade = db.Column(db.String(20 )) feedback = db.Column(db.Text) submitted_at = db.Column(db.DateTime, default=datetime.utcnow) assignment = db.relationship('Assignment' , backref='submissions' ) student = db.relationship('User' , backref='submissions' ) class Photo (db.Model): __tablename__ = 'photos' id = db.Column(db.Integer, primary_key=True ) file_path = db.Column(db.String(255 ), nullable=False ) caption = db.Column(db.String(100 )) uploader_id = db.Column(db.Integer, db.ForeignKey('users.id' )) created_at = db.Column(db.DateTime, default=datetime.utcnow) uploader = db.relationship('User' , backref='photos' ) class BlogPost (db.Model): __tablename__ = 'blog_posts' id = db.Column(db.Integer, primary_key=True ) title = db.Column(db.String(200 ), nullable=False ) content = db.Column(db.Text, nullable=False ) author_id = db.Column(db.Integer, db.ForeignKey('users.id' )) created_at = db.Column(db.DateTime, default=datetime.utcnow) author = db.relationship('User' , backref='blog_posts' ) class Video (db.Model): __tablename__ = 'videos' id = db.Column(db.Integer, primary_key=True ) title = db.Column(db.String(200 ), nullable=False ) description = db.Column(db.Text) source_type = db.Column(db.String(10 ), default='upload' ) file_path = db.Column(db.String(255 ), nullable=True ) bv_id = db.Column(db.String(20 ), nullable=True ) uploader_id = db.Column(db.Integer, db.ForeignKey('users.id' )) created_at = db.Column(db.DateTime, default=datetime.utcnow) uploader = db.relationship('User' , backref='videos' )
relationship 的价值 :有了它,photo.uploader.real_name 直接取作者名,不用手写 join。
第 9 章 建表与改表 1 2 3 4 5 6 7 8 9 def init_db (app ): with app.app_context(): db.create_all() if Module.query.count() == 0 : for k, n in [('announcement' ,'公告' ),('homework' ,'作业' ), ('album' ,'班级相册' ),('blog' ,'班级博客' ),('video' ,'视频课堂' )]: db.session.add(Module(key=k, name=n, enabled=True )) db.session.commit()
开发期改了模型字段怎么办?简单粗暴三板斧:
删掉 instance/ 下的 db 文件重建(数据全没,适合早期)
SQLite 命令行手动 ALTER TABLE xxx ADD COLUMN yyy TEXT;
正式路线:Flask-Migrate (Alembic) —— 数据不能丢时学它
✅ 检查点:说出 db.create_all() 对已存在的表不会 做什么。
第三部分:实战 —— 自托管班级网站(第 10-19 章) 第 10 章 项目骨架 1 2 3 4 5 6 7 8 9 10 myclass/ ├── app.py # 应用工厂 + 主路由 ├── models.py # 第 8 章的模型 ├── forms.py # 第 5 章的表单 ├── utils.py # 权限装饰器、文件保存工具 ├── templates/ # Jinja2 模板 ├── static/ └── uploads/ # 用户上传(照片/视频),不进 git! ├── photos/ └── videos/
app.py 初始化部分:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 import osfrom flask import Flaskfrom models import dbfrom flask_login import LoginManagerBASE_DIR = os.path.abspath(os.path.dirname(__file__)) UPLOAD_DIR = os.path.join(BASE_DIR, 'uploads' ) os.makedirs(os.path.join(UPLOAD_DIR, 'photos' ), exist_ok=True ) os.makedirs(os.path.join(UPLOAD_DIR, 'videos' ), exist_ok=True ) app = Flask(__name__) app.config['SECRET_KEY' ] = os.environ.get('SECRET_KEY' , 'dev-key' ) app.config['SQLALCHEMY_DATABASE_URI' ] = 'sqlite:///myclass.db' app.config['MAX_CONTENT_LENGTH' ] = 2 * 1024 * 1024 * 1024 app.config['UPLOAD_DIR' ] = UPLOAD_DIR db.init_app(app) login_manager = LoginManager(app) login_manager.login_view = 'login' from models import User@login_manager.user_loader def load_user (uid ): return User.query.get(int (uid))
第 11 章 Kavita 式登录体系(需求 #2) Kavita 的设计哲学:没有注册入口 。首次启动 → 网页上创建第一个管理员 → 之后所有账号由管理员在后台建立。班级场景天然适配(学生不能自助注册)。
实现 = 三道门 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 from flask import redirect, url_for, abort, requestfrom flask_login import current_user@app.before_request def site_gate (): """每个请求先过这道闸""" if request.endpoint == 'static' : return if User.query.count() == 0 : if request.endpoint != 'setup' : return redirect(url_for('setup' )) return if request.endpoint == 'setup' : abort(404 ) if not current_user.is_authenticated: if request.endpoint not in ('login' , 'logout' ): return redirect(url_for('login' ))
/setup 路由 —— 只有全站空的时候才存在:
1 2 3 4 5 6 7 8 9 10 11 @app.route('/setup' , methods=['GET' , 'POST' ] ) def setup (): if request.method == 'POST' : u = User(username=request.form['username' ], real_name='管理员' , role='admin' ) u.set_password(request.form['password' ]) db.session.add(u) db.session.commit() login_user(u) return redirect(url_for('index' )) return render_template('setup.html' )
登录页 /login 就是第 5 章表单 + login_user(user);全站不提供 register 路由 。 教师/学生账号从哪来?→ 下一章,后台手动加或 Excel 批量导。
✅ 检查点:删掉数据库重跑,描述从 / 被弹到 /setup 再建站的完整链路。
第 12 章 用户管理 + Excel 批量导入(需求 #3) 管理员需要两条路添加学生:手动一条条加 (第 5 章 StudentForm 已备好)和 Excel 一键导一个班 (新班主任刚需)。
Excel 模板(列顺序固定):
用户名
姓名
学号
初始密码
身份
zhangsan
张三
2024001
123456
student
导入的四段式流程 (背下来,所有导入功能都是这个骨架):解析 → 全量校验 → 预览确认 → 事务写入
先做预览步骤 (不落库),确认后加 ?commit=1 再真正写入——防止一个坏行毁掉整批:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 from functools import wrapsfrom flask import abortfrom flask_login import current_userdef role_required (*roles ): def deco (f ): @wraps(f ) def wrapper (*a, **kw ): if not current_user.is_authenticated or current_user.role not in roles: abort(403 ) return f(*a, **kw) return wrapper return deco
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 import io, refrom openpyxl import load_workbook, Workbookfrom utils import role_required@app.route('/admin/users/import' , methods=['GET' , 'POST' ] ) @role_required('admin' ) def import_users (): if request.method == 'POST' and request.files.get('file' ): f = request.files['file' ] if not f.filename.endswith(('.xlsx' , '.xlsm' )): flash('只支持 .xlsx 文件' , 'danger' ) return redirect(url_for('import_users' )) wb = load_workbook(io.BytesIO(f.read()), read_only=True ) ws = wb.active rows, errors = [], [] for i, row in enumerate (ws.iter_rows(min_row=2 , values_only=True ), start=2 ): if row is None or all (c is None for c in row): continue username, real_name, sno, pw, role = (list (row) + [None ]*5 )[:5 ] if not all ([username, real_name, sno, pw]): errors.append(f'第{i} 行:用户名/姓名/学号/密码不能为空' ); continue if User.query.filter_by(username=str (username).strip()).first(): errors.append(f'第{i} 行:用户名 {username} 已存在' ); continue if User.query.filter_by(student_no=str (sno).strip()).first(): errors.append(f'第{i} 行:学号 {sno} 已存在' ); continue rows.append((str (username).strip(), str (real_name).strip(), str (sno).strip(), str (pw).strip(), str (role or 'student' ).strip())) if errors: return render_template('admin/import_preview.html' , rows=[], errors=errors, commitable=False ) if not rows: flash('表格里没有有效数据' , 'warning' ) return redirect(url_for('import_users' )) if request.args.get('commit' ) != '1' : return render_template('admin/import_preview.html' , rows=rows, errors=[], commitable=True ) try : for un, rn, sno, pw, role in rows: u = User(username=un, real_name=rn, student_no=sno, role=role) u.set_password(pw) db.session.add(u) db.session.commit() flash(f'成功导入 {len (rows)} 个账号' , 'success' ) except Exception: db.session.rollback() flash('导入失败,已回滚(什么都没写入)' , 'danger' ) return redirect(url_for('user_list' )) return render_template('admin/import.html' )
配套福利:模板下载 ——用 openpyxl 现场生成一个带头行的 xlsx:
1 2 3 4 5 6 7 8 9 @app.route('/admin/users/template' ) @role_required('admin' ) def user_template (): wb = Workbook(); ws = wb.active ws.append(['用户名' , '姓名' , '学号' , '初始密码' , '身份' ]) buf = io.BytesIO(); wb.save(buf); buf.seek(0 ) return send_file(buf, as_attachment=True , download_name='学生导入模板.xlsx' , mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' )
🤔 思考题 :为什么先校验完全部行、再统一 add+commit,而不是边读边写?(提示:第 37 行学号重复时,前 36 行怎么办?)
第 13 章 模块开关(需求 #4) 管理员能在后台像拧电闸一样开关「公告/作业/相册/博客/视频」。三层配合:
层1 — 数据 :第 8 章的 Module 表 + 第 9 章的初始化。
层2 — 每个模板都知道开关状态 (注入全局上下文):
1 2 3 4 5 @app.context_processor def inject_modules (): mods = Module.query.all () return {'MODULES' : {m.key: m.enabled for m in mods}}
导航栏(base.html):
1 2 3 4 5 {% if MODULES.get('announcement') %}<a href ="{{ url_for('announcements') }}" > 公告</a > {% endif %} {% if MODULES.get('homework') %}<a href ="{{ url_for('assignments') }}" > 作业</a > {% endif %} {% if MODULES.get('album') %}<a href ="{{ url_for('albums') }}" > 相册</a > {% endif %} {% if MODULES.get('blog') %}<a href ="{{ url_for('blog') }}" > 博客</a > {% endif %} {% if MODULES.get('video') %}<a href ="{{ url_for('videos') }}" > 视频课堂</a > {% endif %}
层3 — 关了不只是藏入口,URL 也要拦死 (不然学生收藏夹里的老链接还能访问):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 def module_on (key ): """模块关闭时直接 404,比 403 更干净:不暴露"这里有个功能"的信息""" def deco (f ): @wraps(f ) def wrapper (*a, **kw ): m = Module.query.filter_by(key=key).first() if not m or not m.enabled: abort(404 ) return f(*a, **kw) return wrapper return deco @app.route('/videos/' ) @module_on('video' ) def videos (): ...
后台开关页:列出 Module 表,每行一个 checkbox,POST 回来批量翻转 enabled 即可。
第 14 章 公告模块 最典型的「列表 + 新增 + 删除」三件套,练手用:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 @app.route('/announcements' ) @module_on('announcement' ) def announcements (): anns = Announcement.query.order_by(Announcement.created_at.desc()).all () return render_template('announcements/list.html' , anns=anns) @app.route('/announcements/new' , methods=['GET' , 'POST' ] ) @module_on('announcement' ) @role_required('admin' , 'teacher' ) def announcement_new (): form = AnnouncementForm() if form.validate_on_submit(): db.session.add(Announcement(title=form.title.data, content=form.content.data, author=current_user)) db.session.commit() flash('公告已发布' , 'success' ) return redirect(url_for('announcements' )) return render_template('announcements/new.html' , form=form)
进阶练习(自己写):置顶字段、编辑/删除路由、按页分页。
第 15 章 作业模块 公告三件套 + 一层”提交”关系:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 @app.route('/assignments/<int:aid>/submit' , methods=['GET' , 'POST' ] ) @module_on('homework' ) def submit_homework (aid ): a = Assignment.query.get_or_404(aid) sub = Submission.query.filter_by(assignment_id=aid, student_id=current_user.id ).first() form = SubmissionForm(obj=sub) if form.validate_on_submit(): if not sub: sub = Submission(assignment_id=aid, student_id=current_user.id ) db.session.add(sub) sub.content = form.content.data db.session.commit() flash('已提交' , 'success' ) return redirect(url_for('assignments' )) return render_template('assignments/submit.html' , a=a, form=form)
教师端批改:遍历 a.submissions,填 grade 和 feedback。自己写。
第 16 章 班级相册 图片上传通用工具(第 18 章视频复用同一思路):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 import uuidfrom werkzeug.utils import secure_filenameALLOWED_IMAGE_EXT = {'png' , 'jpg' , 'jpeg' , 'gif' , 'webp' } MAX_IMAGE = 10 * 1024 * 1024 def save_upload (file, folder, allowed_ext ): ext = file.filename.rsplit('.' , 1 )[-1 ].lower() if ext not in allowed_ext: raise ValueError(f'不支持的格式: .{ext} ' ) name = f'{uuid.uuid4().hex } .{ext} ' path = os.path.join(current_app.config['UPLOAD_DIR' ], folder, name) file.save(path) return name
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 @app.route('/albums/upload' , methods=['POST' ] ) @module_on('album' ) def album_upload (): f = request.files.get('photo' ) if f and f.filename: name = save_upload(f, 'photos' , ALLOWED_IMAGE_EXT) db.session.add(Photo(file_path=name, caption=request.form.get('caption' , '' ))) db.session.commit() return redirect(url_for('albums' )) @app.route('/photos/<path:fname>' ) @login_required def photo_file (fname ): return send_from_directory( os.path.join(current_app.config['UPLOAD_DIR' ], 'photos' ), fname)
为什么照片路由要 @login_required?—— 班级照片不该对公网裸奔,即使知道文件名也不行。
第 17 章 班级博客 结构上 = 公告 + 详情页,加一个可选升级:学生交周报,教师可评论(多一个 Comment 表)。Markdown 渲染可先不引入,纯文本 {{ post.content }} 转义输出最安全(Jinja 默认转义 = 天然防 XSS,别手贱加 | safe)。
第 18 章 视频课堂(需求 #5:上传 + B 站嵌入) 一张表两种来源(Video.source_type),列表页统一渲染:
1 2 3 4 5 6 7 BV_RE = re.compile (r'BV[0-9A-Za-z]{10}' ) def parse_bvid (text ): """""" 从用户粘贴的任意文本/链接里抠出 BV 号,如 https://www.bilibili.com/video/BV1GJ411x7h7/?p=2 → BV1GJ411x7h7""" m = BV_RE.search(text or '') return m.group(0) if m else None
新增视频路由,两个表单二选一:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 @app.route('/videos/new' , methods=['GET' , 'POST' ] ) @module_on('video' ) @role_required('admin' , 'teacher' ) def video_new (): title = request.form.get('title' , '' ).strip() if not title: flash('标题必填' , 'danger' ); return redirect(url_for('video_new' )) v = Video(title=title, description=request.form.get('description' , '' )) if request.form.get('mode' ) == 'bilibili' : bv = parse_bvid(request.form.get('bilibili_url' )) if not bv: flash('没能识别出 BV 号,检查粘贴的链接' , 'danger' ) return redirect(url_for('video_new' )) v.source_type, v.bv_id = 'bilibili' , bv else : f = request.files.get('video_file' ) if not f or not f.filename: flash('没选文件' , 'danger' ); return redirect(url_for('video_new' )) v.source_type = 'upload' v.file_path = save_upload(f, 'videos' , {'mp4' , 'webm' , 'ogg' , 'mov' , 'mkv' }) db.session.add(v); db.session.commit() flash('视频已添加' , 'success' ) return redirect(url_for('videos' ))
播放页模板 —— 两种来源渲染成两种播放器:
1 2 3 4 5 6 7 8 9 10 11 {% if v.source_type == 'bilibili' %} <iframe src ="//player.bilibili.com/player.html?bvid={{ v.bv_id }}&page=1&high_quality=1&danmaku=0" scrolling ="no" frameborder ="0" srcdoc ="<p>该浏览器禁止了 iframe,请直链观看:https://www.bilibili.com/video/{{ v.bv_id }}</p>" style ="width:100%;aspect-ratio:16/9;border:none" > </iframe > {% else %} <video controls preload ="metadata" style ="width:100%" > <source src ="{{ url_for('video_file', vid=v.id) }}" type ="video/mp4" > 你的浏览器不支持 HTML5 视频 </video > {% endif %}
本地视频怎么送出去?send_file 的 conditional=True 是灵魂 :
1 2 3 4 5 6 7 8 9 10 @app.route('/videos/<int:vid>/file' ) @module_on('video' ) @login_required def video_file (vid ): v = Video.query.get_or_404(vid) if v.source_type != 'upload' : abort(404 ) path = os.path.join(current_app.config['UPLOAD_DIR' ], 'videos' , v.file_path) return send_file(path, conditional=True , max_age=0 )
不加 conditional=True 的话,拖动进度条会直接失败或整片重传——这是视频网站和普通下载最不一样的地方。
边界说明 :mkv/mov 浏览器不一定能直接播放(编码问题),给老师约定上传 MP4(H.264) 最稳;格式转换属于 ffmpeg 领域,教程不展开但值得知道。
第 19 章 管理后台 统计卡片 + 入口集合,纯查询聚合:
1 2 3 4 5 6 7 8 9 10 11 @app.route('/admin' ) @role_required('admin' ) def admin_dashboard (): stats = dict ( users=User.query.count(), students=User.query.filter_by(role='student' ).count(), videos=Video.query.count(), photos=Photo.query.count(), ) modules = Module.query.all () return render_template('admin/dashboard.html' , stats=stats, modules=modules)
后台四宫格入口:用户列表 / Excel 导入 / 模块开关 / 内容审核(删帖删视频)。
第四部分:自托管部署(第 20 章) 20.1 跑起来:gunicorn 1 2 3 pip install gunicorn gunicorn -w 2 -b 0.0.0.0:8000 app:app
SQLite 关键忠告 :多个 worker 同时写 SQLite 会 database is locked。 班级场景开 2 个 worker + 打开 WAL 模式基本免疫:
1 2 3 4 5 6 7 8 9 from sqlalchemy import eventwith app.app_context(): @event.listens_for(db.engine, 'connect' ) def _sqlite_pragmas (dbapi_conn, _ ): cur = dbapi_conn.cursor() cur.execute('PRAGMA journal_mode=WAL' ) cur.execute('PRAGMA busy_timeout=5000' ) cur.close()
20.2 开机自启:systemd(Linux / NAS) /etc/systemd/system/myclass.service:
1 2 3 4 5 6 7 8 9 10 11 12 [Unit] Description =Class WebsiteAfter =network.target[Service] User =wwwWorkingDirectory =/opt/myclassExecStart =/opt/myclass/venv/bin/gunicorn -w 2 -b 127.0 .0.1 :8000 app:appRestart =always[Install] WantedBy =multi-user.target
1 sudo systemctl enable --now myclass
20.3 Nginx 反代(视频上传必改项) 1 2 3 4 5 6 7 8 9 10 11 12 13 server { listen 80 ; server_name class.lan; client_max_body_size 4g ; location / { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host ; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for ; proxy_read_timeout 300s ; } }
进阶(大视频别过 Python):/videos/<id>/file 用 X-Accel-Redirect 交给 Nginx 直接发文件,Flask 只做权限判断。学会前面内容后再回来攻这个。
20.4 备份与安全清单
附录 A. requirements.txt 全家桶 1 2 3 4 5 6 Flask Flask-SQLAlchemy Flask-Login Flask-WTF openpyxl gunicorn
B. 常见报错速查(含本项目实测踩过的坑)
报错
原因
修法
externally-managed-environment
系统 Python 拒绝直接 pip
先激活 venv
sqlite3.OperationalError: disk I/O error
db 文件在网盘/NAS 挂载目录
把 instance/ 挪到本地盘
Jinja 'any' is undefined
模板里用了 Python 内置函数
路由算好布尔值再传模板
400 CSRF token missing
表单没写 hidden_tag() 或 SECRET_KEY 变了
补上;清 cookie 重试
413 Request Entity Too Large
上传超 MAX_CONTENT_LENGTH 或 Nginx client_max_body_size
两处都要调大
database is locked
SQLite 并发写
WAL + busy_timeout,或减 worker
拖视频进度条就重头播放
send_file 没加 conditional=True
加上
Working outside of application context
在 app 创建前查库
包进 with app.app_context():
C. 学习自检清单 基础 :能解释路由/模板/静态文件分别管什么 □数据库 :能手写 CRUD 五种查询并说出 commit 的作用 □登录墙 :能画出「空库→setup→login→全站」的 before_request 决策流 □Excel :能解释为什么”全量校验后再事务写入” □模块开关 :能说出”导航隐藏”和”路由 404”为什么必须两层都做 □视频 :能解释 Range 请求和 conditional=True 的关系 □部署 :能独立把网站在内网跑起来并开机自启 □
全部打勾 = 你已经从”会 Python”进化到”能独立交付一个自托管 Web 应用”。🎉