一个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
# 1. 确认 Python 3.9+
python3 --version

# 2. 建项目目录
mkdir myclass && cd myclass

# 3. 虚拟环境(必做!隔离依赖)
python3 -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate

# 4. 本项目全部依赖
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 Flask
app = Flask(__name__)
app.config['SECRET_KEY'] = '换成一串随机字符' # session/CSRF 必需

@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 FlaskForm
from wtforms import StringField, PasswordField, SelectField, SubmitField
from 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 datetime
from flask_sqlalchemy import SQLAlchemy
db = 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() # 写入(不 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 datetime
from flask_sqlalchemy import SQLAlchemy
from flask_login import UserMixin
from werkzeug.security import generate_password_hash, check_password_hash

db = 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') # admin / teacher / 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): # 第 13章的主角
__tablename__ = 'modules'
id = db.Column(db.Integer, primary_key=True)
key = db.Column(db.String(30), unique=True) # announcement/homework/album/blog/video
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): # 第 18 章的主角
__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') # upload / bilibili
file_path = db.Column(db.String(255), nullable=True) # 本地文件
bv_id = db.Column(db.String(20), nullable=True) # B站 BV 号
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()

开发期改了模型字段怎么办?简单粗暴三板斧:

  1. 删掉 instance/ 下的 db 文件重建(数据全没,适合早期)
  2. SQLite 命令行手动 ALTER TABLE xxx ADD COLUMN yyy TEXT;
  3. 正式路线: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 os
from flask import Flask
from models import db
from flask_login import LoginManager

BASE_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 # 上传上限 2GB(视频需要)
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, request
from flask_login import current_user

@app.before_request
def site_gate():
"""每个请求先过这道闸"""
if request.endpoint == 'static':
return
# 门1:数据库里一个用户都没有 → 全站强制去 /setup(初始化页)
if User.query.count() == 0:
if request.endpoint != 'setup':
return redirect(url_for('setup'))
return
# 门2:已初始化 → setup 页自动关闭
if request.endpoint == 'setup':
abort(404)
# 门3:普通访客 → 只能看登录页,其余一律拦截(登录墙)
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
# utils.py
from functools import wraps
from flask import abort
from flask_login import current_user

def 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
# admin.py(路由部分)
import io, re
from openpyxl import load_workbook, Workbook
from 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)

# 第二步:确认提交(?commit=1)→ 事务写入
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():
# 返回的 dict 在所有模板里可直接用。班级规模(几百 QPS 以内)每请求查一次库完全OK
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)) # relationship 直接给对象
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,填 gradefeedback。自己写。

第 16 章 班级相册

图片上传通用工具(第 18 章视频复用同一思路):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# utils.py
import uuid
from werkzeug.utils import secure_filename

ALLOWED_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}')
# uuid 重命名:防同名覆盖、防路径穿越(secure_filename 兜底中文名场景)
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_fileconditional=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)
# conditional=True → 支持 HTTP Range 请求 → 视频可以拖进度条、断点续传
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
# ↑ 文件:实例 ↑ 注意:不再是 app.run()

SQLite 关键忠告:多个 worker 同时写 SQLite 会 database is locked
班级场景开 2 个 worker + 打开 WAL 模式基本免疫:

1
2
3
4
5
6
7
8
9
# app.py 加上(SQLite 的连接事件钩子)
from sqlalchemy import event
with 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') # 撞锁时最多等 5 秒再报错
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 Website
After=network.target

[Service]
User=www
WorkingDirectory=/opt/myclass
ExecStart=/opt/myclass/venv/bin/gunicorn -w 2 -b 127.0.0.1:8000 app:app
Restart=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; # 内网域名或裸 IP

client_max_body_size 4g; # ← 默认 1M!不改这个,视频上传必挂 413

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>/fileX-Accel-Redirect 交给 Nginx 直接发文件,Flask 只做权限判断。学会前面内容后再回来攻这个。

20.4 备份与安全清单

  • 备份 = 定期复制 instance/myclass.db + uploads/(写个 cron 每天 tar 一份)
  • SECRET_KEY 放环境变量,别提交进 git
  • 强制所有人改掉初始密码(导入的 123456 是临时票)
  • 只在内网开放;真要公网访问就挂 HTTPS(Let’s Encrypt / 群晖反代)
  • debug=False

附录

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 应用”。🎉