简介:Fyyur-Udacity-Project 是一份面向全栈开发学习者与 Udacity 项目实践者的完整代码资源,聚焦音乐演出场地与艺术家预订平台的数据库建模与接口开发。项目基于 Python 后端框架搭建,通过接入 PostgreSQL 完成艺术家、场地及演出的存储、查询与更新,帮助读者理解视图、控制器与模型之间的协作关系,适合具备一定 Web 开发基础、希望补齐数据持久化能力的中级学习者。压缩包共 64 个文件,约 2.32MB,涵盖 18 个 HTML 页面模板、10 个 CSS 样式、8 个 JavaScript 脚本、7 个 Python 业务代码,以及 pyc 缓存、字体、图片、迁移脚本与配置文件等,目录结构清晰,便于按模块查阅。已有 75 人学习下载。资源完整呈现了从数据模型设计到 API 端点驱动的实现思路,读者可据此掌握 PostgreSQL 建表、查询与关联操作,并对照表单、模板与静态资源理解前后端衔接方式,是练习数据库驱动型 Web 应用的实用参考。
1. Fyyur 项目到底在练什么:一个演出预订平台的最小闭环
Fyyur-Udacity-Project 这个名字,第一次看到的人多半会愣一下:Fyyur 是什么?其实它是一个虚构的演出票务与艺人经纪平台,核心业务就三件事——场地(Venue)发布演出、艺人(Artist)接演出、观众按城市和州筛选演出。Udacity 把它作为全栈项目的练手载体,目的不是让你做一个能上线的票务系统,而是逼你把「关系型数据建模 + 表单提交 + 列表筛选 + 详情页关联查询」这条链路完整走一遍。
我见过太多人卡在这个项目上,不是因为代码难,而是因为一开始就没想清楚数据表之间怎么连。Venue 和 Artist 是多对多,Show 是中间表还带时间字段,City 和 State 是两级筛选维度。如果你上来就写路由和模板,后面一定会返工。这篇文章按「先建模、再跑通、后优化」的顺序拆,适合刚学完 Flask 或 Express 想找个完整项目练手的人,也适合已经写过 CRUD 但没认真处理过关联查询的开发者。
2. 数据建模先于路由:三张核心表与两级筛选维度
2.1 为什么 Venue、Artist、Show 不能各写各的
很多新手的第一反应是:Venue 表存场地信息,Artist 表存艺人信息,Show 表存演出信息,各自独立不就行了?问题出在「一场演出」这个实体上。一场演出必须同时绑定一个场地和一个艺人,而且同一个场地可以办多场不同艺人的演出,同一个艺人也可以在不同场地演出。这就是典型的多对多关系,Show 表就是那个中间表,但它不是纯粹的关联表,它还携带了 start_time 这个业务字段。
如果你把 Show 表设计成只有 venue_id 和 artist_id 两个外键,那你就没法回答「这个场地下周有哪些演出」这种问题。所以 Show 表至少要有 id、venue_id、artist_id、start_time 四个字段。Venue 表里要有 city 和 state 字段,Artist 表里要有 city 和 state 字段,这样你才能做「按城市筛选艺人」和「按州筛选场地」这两个核心功能。
常见做法是用 SQLAlchemy 的 relationship 来定义关联,而不是手写 JOIN。下面是我一般会用的模型定义方式:
# models.py from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() class Venue(db.Model): __tablename__ = 'venue' id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String, nullable=False) city = db.Column(db.String(120), nullable=False) state = db.Column(db.String(120), nullable=False) address = db.Column(db.String(120)) phone = db.Column(db.String(120)) # 一个场地有多场演出,级联删除避免脏数据 shows = db.relationship('Show', backref='venue', cascade='all, delete') class Artist(db.Model): __tablename__ = 'artist' id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String, nullable=False) city = db.Column(db.String(120), nullable=False) state = db.Column(db.String(120), nullable=False) phone = db.Column(db.String(120)) genres = db.Column(db.String(120)) shows = db.relationship('Show', backref='artist', cascade='all, delete') class Show(db.Model): __tablename__ = 'show' id = db.Column(db.Integer, primary_key=True) venue_id = db.Column(db.Integer, db.ForeignKey('venue.id'), nullable=False) artist_id = db.Column(db.Integer, db.ForeignKey('artist.id'), nullable=False) start_time = db.Column(db.DateTime, nullable=False)这段代码的关键点有三个。第一,cascade='all, delete'保证删除场地时关联的演出记录一并删除,否则外键约束会报错。第二,backref让你可以从 Show 对象直接访问show.venue和show.artist,省去手动查询。第三,start_time用 DateTime 而不是 String,后面按时间排序和筛选才不会出现字符串比较的玄学问题。
2.2 City 和 State 为什么要单独拎出来做筛选
Fyyur 的首页有一个筛选区,用户可以选择州和城市,然后列表只显示对应区域的场地或艺人。如果你直接在 Venue 表上做filter_by(state=...),那当用户只选州不选城市时,你返回的是该州所有场地,这没问题。但当用户选了城市,你就要同时匹配 city 和 state,因为不同州可能有同名城市。
我一般会写一个通用的筛选函数,而不是在每个路由里重复写查询逻辑:
# queries.py def filter_venues(state=None, city=None): query = Venue.query if state: query = query.filter(Venue.state == state) if city: query = query.filter(Venue.city == city) return query.order_by(Venue.name).all()参数说明:state 和 city 都是可选参数,传 None 表示不筛选。order_by(Venue.name)保证列表按名称排序,避免每次刷新顺序不一样。注意这里没有用filter_by,因为filter_by不支持动态拼接条件,用filter更灵活。
2.3 用 Flask-Migrate 管理表结构变更
新手最容易翻车的地方是:模型改了,数据库没改,然后查询报「no such column」。我强烈建议从第一天就用 Flask-Migrate,而不是手动删表重建。初始化命令如下:
# 初始化迁移仓库,只需执行一次 flask db init # 生成迁移脚本,每次改完模型都要执行 flask db migrate -m "add start_time to show" # 应用迁移到数据库 flask db upgrade逻辑说明:flask db init创建 migrations 目录,里面存放版本记录。flask db migrate对比模型和当前数据库的差异,生成一个 Python 脚本。flask db upgrade执行这个脚本,真正修改表结构。如果你改了模型但忘了 migrate,直接 upgrade 是没用的,因为迁移脚本还没生成。
注意:如果 migrate 生成的脚本里出现了你不认识的删表操作,先别急着 upgrade,打开脚本看一眼。自动生成的迁移有时会把字段重命名误判为删除加新增,导致数据丢失。
3. 从表单到详情页:把 CRUD 串成可演示的闭环
3.1 新增场地表单的字段校验与错误回显
Fyyur 的新增场地页面有 name、city、state、address、phone 五个字段,其中 name、city、state 是必填。如果你只用 HTML 的required属性,用户提交空表单时浏览器会拦截,但如果你用 Postman 直接发请求,后端还是会写入空数据。所以后端必须再做一次校验。
我一般会用 Flask-WTF 或者手写校验逻辑。手写的话大概是这样:
# routes.py from flask import render_template, request, redirect, url_for, flash @app.route('/venues/create', methods=['GET', 'POST']) def create_venue(): if request.method == 'POST': name = request.form.get('name', '').strip() city = request.form.get('city', '').strip() state = request.form.get('state', '').strip() # 必填字段校验,任一为空则回显错误 if not name or not city or not state: flash('Name, City and State are required.') return render_template('forms/new_venue.html', name=name, city=city, state=state) venue = Venue(name=name, city=city, state=state, address=request.form.get('address'), phone=request.form.get('phone')) db.session.add(venue) db.session.commit() return redirect(url_for('venues')) return render_template('forms/new_venue.html')逻辑说明:request.form.get('name', '')的第二个参数是默认值,防止 KeyError。.strip()去掉首尾空格,避免用户输入一堆空格绕过校验。校验失败时用flash提示,并把已填字段传回模板,这样用户不用重新输入。db.session.commit()之后必须 redirect,否则刷新页面会重复提交。
参数说明:methods=['GET', 'POST']让同一个路由处理展示表单和接收提交。url_for('venues')是列表页的路由名,根据你的实际函数名调整。
3.2 演出创建时的时间格式与关联选择
创建演出的表单比场地复杂,因为它需要选择场地、选择艺人、填写时间。场地和艺人用下拉框,时间用datetime-local输入框。这里有两个坑:第一,下拉框的 value 要用 id,显示用 name;第二,datetime-local提交的格式是2025-01-15T19:30,Python 的datetime.strptime需要匹配这个格式。
# routes.py from datetime import datetime @app.route('/shows/create', methods=['GET', 'POST']) def create_show(): if request.method == 'POST': try: start_time = datetime.strptime( request.form['start_time'], '%Y-%m-%dT%H:%M') except ValueError: flash('Invalid date format.') return redirect(url_for('create_show')) show = Show(venue_id=request.form['venue_id'], artist_id=request.form['artist_id'], start_time=start_time) db.session.add(show) db.session.commit() return redirect(url_for('shows')) venues = Venue.query.order_by(Venue.name).all() artists = Artist.query.order_by(Artist.name).all() return render_template('forms/new_show.html', venues=venues, artists=artists)逻辑说明:datetime.strptime的第二个参数必须和前端输入框的格式完全一致,%Y-%m-%dT%H:%M对应2025-01-15T19:30。如果用户浏览器不支持datetime-local,提交的可能是空字符串,所以 try/except 是必要的。venue_id和artist_id从表单直接取,因为下拉框的 value 就是数据库 id。
参数说明:Venue.query.order_by(Venue.name).all()保证下拉框按名称排序,方便用户查找。如果你有大量场地,可以考虑用 AJAX 动态加载,但作为练手项目,一次性加载完全够用。
3.3 详情页的关联查询:一次取出场地、艺人和演出
场地详情页需要显示:场地基本信息、该场地的所有演出、每场演出的艺人名称。如果你在模板里对每个演出都查一次艺人,那就是 N+1 查询问题。正确做法是用joinedload一次性把关联数据取出来。
# routes.py from sqlalchemy.orm import joinedload @app.route('/venues/<int:venue_id>') def show_venue(venue_id): venue = Venue.query.options( joinedload(Venue.shows).joinedload(Show.artist) ).get(venue_id) if not venue: return render_template('errors/404.html'), 404 past_shows = [s for s in venue.shows if s.start_time < datetime.now()] upcoming_shows = [s for s in venue.shows if s.start_time >= datetime.now()] return render_template('pages/show_venue.html', venue=venue, past_shows=past_shows, upcoming_shows=upcoming_shows)逻辑说明:joinedload(Venue.shows).joinedload(Show.artist)告诉 SQLAlchemy 在查场地时就用 JOIN 把演出和艺人一起查出来,避免模板里循环查询。past_shows和upcoming_shows按当前时间拆分,模板里分两块展示。get(venue_id)查不到返回 None,手动返回 404 页面。
参数说明:joinedload的链式调用顺序是「先关联演出,再关联演出的艺人」,不能反过来。如果你只写joinedload(Venue.shows),那show.artist还是会触发额外查询。
4. 避坑与排查:Fyyur 项目里最容易翻车的五件事
4.1 现象:删除场地时报外键约束错误
原因:Show 表里有指向该场地的记录,数据库不允许删除被引用的父记录。解决:在 Venue 模型的 relationship 里加cascade='all, delete',或者在删除场地前先手动删除关联演出。我一般用 cascade,因为更省事。
4.2 现象:筛选城市后列表为空,但数据库里明明有数据
原因:用户选的州和城市不匹配,比如选了「CA」但城市是「New York」。解决:前端做级联下拉,选州之后只显示该州的城市。如果不想做级联,后端查询时同时匹配 state 和 city,并在无结果时给出明确提示。
4.3 现象:演出时间显示为「2025-01-15 00:00:00」而不是用户填的 19:30
原因:datetime-local提交的格式带 T,如果你用%Y-%m-%d %H:%M:%S去解析会失败,或者你在模板里用了默认的 str 格式化。解决:解析时用%Y-%m-%dT%H:%M,展示时用strftime('%Y-%m-%d %H:%M')自定义格式。
4.4 现象:新增场地后列表页没更新
原因:db.session.commit()之后没有 redirect,或者 redirect 到了缓存页面。解决:commit 后必须redirect(url_for('venues')),不要直接render_template,否则刷新会重复提交。如果用了浏览器缓存,加一个Cache-Control: no-cache响应头。
4.5 现象:migrate 生成的脚本里出现 drop_table
原因:你改了模型里的表名或者字段名,自动迁移误判为删旧表建新表。解决:打开迁移脚本,把drop_table改成alter_column或者手动写add_column。如果数据不重要,也可以接受重建,但生产环境千万别这么干。
5. 进阶技巧:用种子数据快速验证关联查询
项目做到一半,最烦的是每次测试都要手动填表单。我一般会写一个种子脚本,一次性插入几个场地、艺人和演出,然后直接访问详情页看关联数据是否正确。
# seed.py from app import db, Venue, Artist, Show from datetime import datetime, timedelta def seed(): db.drop_all() db.create_all() v1 = Venue(name='The Blue Note', city='New York', state='NY') v2 = Venue(name='The Fillmore', city='San Francisco', state='CA') a1 = Artist(name='Alice', city='New York', state='NY', genres='Jazz') a2 = Artist(name='Bob', city='Los Angeles', state='CA', genres='Rock') db.session.add_all([v1, v2, a1, a2]) db.session.commit() # 插入一场已结束和一场未开始的演出,验证 past/upcoming 拆分 s1 = Show(venue_id=v1.id, artist_id=a1.id, start_time=datetime.now() - timedelta(days=7)) s2 = Show(venue_id=v1.id, artist_id=a2.id, start_time=datetime.now() + timedelta(days=3)) db.session.add_all([s1, s2]) db.session.commit() print('Seeded:', Venue.query.count(), 'venues,', Artist.query.count(), 'artists,', Show.query.count(), 'shows') if __name__ == '__main__': seed()逻辑说明:db.drop_all()和db.create_all()保证每次运行都是干净数据库,避免旧数据干扰。timedelta(days=7)和timedelta(days=3)分别生成过去和未来的时间,用来验证详情页的 past/upcoming 拆分逻辑。最后打印计数,确认插入成功。
参数说明:db.session.add_all比多次add更高效,但 commit 之前所有对象都还没有 id,所以必须先 commit 一次拿到 v1.id 和 a1.id,再插入 Show。如果你把 Show 和 Venue 一起 add 再 commit,SQLAlchemy 会自动处理依赖顺序,但显式分两次更直观。
跑完种子脚本后,直接访问/venues/1,如果能看到两场演出分别出现在 past 和 upcoming 区域,并且艺人名称正确显示,说明关联查询和模板渲染都没问题。这个习惯帮我省了大量手动填表的时间,也让我在改模型后能快速回归验证。希望帮到你。
本文还有配套的精品资源,点击获取