☰
Flask+SQLite实战:校园二手书交易系统开发与排错全复盘
2026/10/17 8:58:30 网站建设 项目流程

“赵佳荣的作业”这个文件夹名,在我电脑里躺了大半个月。文件夹里装着的,是一个从一句话需求长出来的完整系统:用户登录、图书发布、关键词搜索、下单交易,前后端代码和数据库脚本一个都不少。这篇博文就是这份作业的完整复盘,从最初拆解题目、选技术栈,到一行行代码实现核心功能,再到最后被两个问题折磨到深夜的排错经历,我都会原原本本写出来。如果你正在做课程设计、实训项目,或者想把手头项目从“能跑”做到“能交”,这篇记录应该能帮你省下不少时间。

我会重点讲三件事。第一,需求怎么拆解才不会做成烂尾工程;第二,并发下单这种容易被忽略的细节,为什么值得认真处理;第三,交作业前真正拉开差距的自检动作是什么。零基础的同学也可以放心看,遇到原理性内容我会尽量用直白的话解释清楚,不会堆术语。

1. 作业原文只有一句话,我却先花半天写需求清单

1.1 一句话需求背后的隐藏要求

作业题目在课程平台出现的时候,原文只有不到五十个字:实现一个校园二手书交易系统,支持用户注册登录、图书发布、图书搜索、下单交易,要求界面友好、数据持久化。

看到这种题目,很多同学的第一反应是打开编辑器,先把注册页面画出来。但我的习惯是先列需求清单,尤其是“界面友好”这种没法量化的词,不拆明白,最后大概率做成“按钮能点就不算报错”的演示品。

我把自己代入评分者的角色去想:我拿到这份作业,会先在一个干净环境里运行项目,注册一个账号,发布一本书,再换一个账号登录搜索,最后下单。这串动作背后其实覆盖了很多功能点,它们才是真正要被验收的东西。

1.2 四张功能清单:把模糊目标变成可验收的任务

我最终把需求拆成四个模块,每个模块都写清楚验收标准,而不是停留在“能注册”“能搜索”这种模糊表述上。

模块关键功能验收标准
用户模块注册、登录、密码加密、会话保持密码不以明文入库;刷新页面后登录状态仍然有效
图书模块发布图书、书架展示、关键词搜索、图片上传新发布的图书能在列表看到;按书名或作者能搜出来
订单模块下单、确认收货、取消订单同一本书不能被多个人重复下单;订单状态流转清晰
基础体验导航明确、表单校验、操作反馈、部署文档表单填错有提示;按照文档能从零跑起来

这套表格最大的价值是逼我把“同一本书不能被多个人重复下单”这件事认真对待。如果只在清单里写“支持下单”,很可能写一个前端按钮,点一下就弹窗说成功,根本不考虑后端校验。而写清楚验收标准之后,就必须设计数据库状态位,必须处理并发,必须让两个账号同时下单时只有一个人成功。这已经超过了很多作业的要求。

后来我才发现,这套需求清单不仅指导编码,还直接变成了 README 里的功能列表和演示视频的脚本。作业提交的文档基本就是从这张表展开的,省了返工的力气。

2. 技术选型:明明能用全家桶,我为什么选了轻量组合

2.1 三种方案摆在面前的思考过程

选技术栈是很容易纠结的环节。Python 课设常见的有三套组合:Flask 轻量模板渲染、前后端分离的 Vue 加 DRF、以及 Django 全家桶。我没有直接选最热门的,而是先想清楚这份作业的约束条件。

第一,它要在演示当天保证不出意外,依赖越少越稳。第二,提交代码会被别人阅读,结构要足够清晰。第三,我需要在两周内写完,没有时间处理复杂工程配置。基于这三点,最终选择了 Flask + Jinja2 + SQLite,前端用原生 JavaScript 配一个轻量 CSS 框架。

方案优点缺点结论
Flask + SQLite + 模板渲染依赖少,结构简单,本地一跑就通页面与逻辑耦合,大型项目会乱最适合课程作业
Vue SPA + DRF + MySQL交互流畅,前后端职责分明环境搭建复杂,文件多,调试链路长适合毕业设计,作业周期扛不住
Django 全家桶自带后台管理,ORM 完善框架初始化结构重,新手不易理解核心逻辑偏重,不便于演示关键实现

这里还有一个容易忽视的点:SQLite 虽然服务能力不如 MySQL,但它压根不需要安装数据库服务,一个文件就能跑。评分者打开项目,执行初始化脚本,再启动应用,整个过程不到三分钟,这对作业提交来说是巨大优势。我不需要别人额外装一个数据库管理系统,也能完成“数据持久化”这条需求。

2.2 项目结构从第一天就要布局好

项目结构是决定代码可读性的第一道关卡。很多同学的代码全堆在一个文件里,最后两三千行根本没法维护。我第一天就把目录定成下面这个样子。

bookflow/ ├── app.py # 应用入口和路由注册 ├── models.py # 数据库模型 ├── views/ │ ├── auth.py # 注册登录相关路由 │ ├── books.py # 图书发布与检索路由 │ └── orders.py # 下单与订单管理路由 ├── templates/ # Jinja2 模板 ├── static/ │ ├── css/ │ ├── js/ │ └── uploads/ # 用户上传的图书图片 ├── schema.sql # 建表脚本 ├── requirements.txt # 依赖清单 └── README.md # 运行说明

模块拆分的逻辑是按业务分,而不是按文件类型分。auth、books、orders 三个路由文件对应三个业务域,谁负责什么一眼就能看出来。schema.sql 单独放,是因为我希望初始化数据库可以重复执行,表结构定义和业务代码彻底分离。

requirements.txt 也要锁版本,这一点新手最容易漏。不锁版本的时候,别人如果用新版依赖跑你的代码,可能一步就报错。把主版本范围写清楚,比如 pin 到你实际跑通过的版本,能让项目从零复现时少踩很多坑。

3. 从注册到下单,核心功能是这样一步步跑通的

3.1 用户注册与登录:密码绝不能明文入库

第一个核心功能是用户注册登录。很多作业在这里会直接用一个字典存用户名密码,甚至把密码原样写入数据库。这个做法在课程作业里也许不会被深究,但我还是按真实项目标准来写,用哈希函数处理密码。

from werkzeug.security import generate_password_hash, check_password_hash from models import User, db def register(username, password): if not username or not password: return {"error": "用户名和密码不能为空"} if len(password) < 6: return {"error": "密码长度至少需要6位"} if User.query.filter_by(username=username).first(): return {"error": "用户名已存在"} user = User( username=username, password_hash=generate_password_hash(password) ) db.session.add(user) db.session.commit() return {"ok": True}

为什么不直接存 MD5 或 SHA1?因为这些算法速度太快,攻击者可以每秒跑几亿次,彩虹表也很容易生成,几乎起不到保护作用。Werkzeug 的 generate_password_hash 默认使用 PBKDF2 加盐迭代,同样的密码每次生成的哈希都不一样,这才是防御撞库的正确思路。

登录逻辑就反过来,取出用户记录后用 check_password_hash 比对哈希值。靠着这层哈希处理,注册登录模块即使被翻代码也不会因为密码存储方式被扣分。这是作业里成本最低却最显专业度的一处细节。

3.2 图书发布:表单验证、文件上传与存储路径

图书发布是平台的起点。这里有两个细节值得展开:表单字段的验完再入库,以及上传文件的安全处理。

我规定发布一本书必须填写书名、作者、课程名、定价和描述,其中书名和作者不能为空,定价必须是大于零的数字。校验全部放在后端,不能只靠前端 input 标签的 required,因为前端的限制可以被绕过,后端校验才是兜底。

def create_book(title, author, course_name, price, description, image): if not title or not author: return {"error": "书名和作者不能为空"} if len(title) > 50 or len(author) > 50: return {"error": "书名或作者长度超限"} if not isinstance(price, (int, float)) or price <= 0: return {"error": "价格必须是正数"} book = Book( title=title, author=author, course_name=course_name, price=price, description=description, image_path=save_image(image) ) db.session.add(book) db.session.commit()

图片上传是最容易写出安全隐患的地方。用户上传文件名可能是中文,也可能包含特殊字符,直接拼接保存路径会带来路径穿越和文件覆盖风险。我这里把所有上传文件改名成“时间戳加随机字符串”的形式,后缀再白名单校验,只允许 jpg、png、gif 等常见格式。

import os, uuid from werkzeug.utils import secure_filename ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "gif"} def save_image(file): if file is None: return "" ext = file.filename.rsplit(".", 1)[-1].lower() if ext not in ALLOWED_EXTENSIONS: raise ValueError("不支持的图片格式") new_name = f"{uuid.uuid4().hex}.{ext}" save_path = os.path.join("static/uploads", new_name) file.save(save_path) return new_name

这样处理后,数据库里只存相对文件名,页面展示时通过统一函数生成 URL。好处是项目迁移到其他电脑时不会出现本地绝对路径失效的问题,这个坑我在后面还会详细讲。

3.3 搜索功能:一条带参数绑定的SQL也能扛住作业需求

搜索需求是“按书名、作者或课程名模糊查询”。最直观的实现方式是用 SQL 的 LIKE 条件。但怎么写这条 SQL 是有讲究的,我见过不少同学喜欢直接把关键词拼进查询字符串,这是典型的 SQL 注入入口。我的写法是参数绑定。

SELECT * FROM books WHERE title LIKE ? OR author LIKE ? OR course_name LIKE ? ORDER BY created_at DESC;

对应的 Python 代码使用占位符传递参数:

def search_books(keyword): pattern = f"%{keyword}%" sql = """ SELECT * FROM books WHERE title LIKE ? OR author LIKE ? OR course_name LIKE ? ORDER BY created_at DESC """ return db.session.execute(sql, (pattern, pattern, pattern)).fetchall()

参数绑定之后,数据库会把传入内容当作数据而不是 SQL 指令的一部分,用户输入再奇怪的字符也不会破坏查询结构。这对于作业来说,属于“别人不看也就罢了,看了就会发现这人和只会拼接字符串的人不是一个水平”的细节。

搜索排序我用了简单的时间倒序,新发布的图书优先展示。如果课设要求里有“热门排序”,可以根据订单数再加一个统计字段,但作业场景下时间倒序已经足够清晰。

3.4 下单流程:一个原子更新解决了并发超卖

下单是整个系统里最容易被低估的功能。表面上看,下单就是把图书状态从“可售”改成“已售”,然后生成一条订单记录。但如果不加处理,两个人几乎同时下单同一本书,会出现两个人都以为下单成功的情况。

我先写了一个最直观的版本,后来在下一章讲的并发测试中暴露问题。

# 初版写法:先查询,再判断,再更新 book = Book.query.get(book_id) if book.status == 0: # 0 表示可售 book.status = 1 order = Order(user_id=uid, book_id=book_id) db.session.add(order) db.session.commit()

这段代码的问题在于“检查状态”和“修改状态”是两个操作。如果两个请求同时读到 status=0,它们都会进入 if 分支,然后各自生成订单。最终效果就是同一本书被卖了两次。这种现象叫竞争条件,线下交易几乎不会遇到,线上高并发下单却很常见。

正确写法是把检查状态和修改状态合并成一条原子更新语句,让数据库锁保证只有一个请求能成功修改。

from models import Book, Order, db def create_order(book_id, user_id): # 原子更新:只有 status=0 时才能被改成 1 updated = Book.query.filter_by(id=book_id, status=0).update({"status": 1}) if updated == 0: return {"error": "这本书刚刚被别人下单了"} order = Order( user_id=user_id, book_id=book_id, status="created" ) db.session.add(order) db.session.commit() return {"ok": True}

update 返回的 updated 表示受影响的行数。如果等于 0,说明这条记录的状态已经不是 0 了,也就是书已经被人抢走。这个方案不依赖复杂的锁机制,却对“同一本书不能重复售卖”这样的需求完全够用。为了让逻辑可读,我还在模型里加了索引,让数据库在 status 过滤时不需要扫全表。

这一步做完后,我特地用一个并发脚本模拟下单,确认同一本书只能生成一个订单,才敢把这块代码算作完成。

4. 最磨人的三个坑,从现象到根因逐个拆

4.1 并发下单测试暴露出的超卖问题

我原本以为初版下单逻辑已经够用了,直到准备提交前,我写了一个小脚本模拟两个账号同时下单,结果意外地发现同一本书生成了两个订单。当时第一反应是脚本写错了,后来反复看日志才发现两个请求确实都进入了 if book.status == 0 分支。

这次排查让我深刻理解了数据库事务和并发控制的基本概念。

问题本质是,我的“查询状态”和“更新状态”之间有时间窗口。请求 A 和请求 B 在同一毫秒级内到达,A 读到 status=0,B 也读到 status=0,A 更新为 1,B 随后也更新为 1,于是两个订单都被创建。数据库的默认隔离级别下,读取操作并不会锁住这行数据,所以这个场景防不住。

我一开始试图用加锁的方式补救,比如手动加一个 SELECT ... FOR UPDATE,但 SQLite 对事务并发的支持比较有限,代码也会变复杂。后来改写成了条件更新,利用 update 语句自身的原子性,把“判断”和“修改”缩成一个操作,问题迎刃而解。这次经历让我养成了一个习惯:凡是涉及“某个资源只能被操作一次”的需求,优先考虑条件更新而不是先查后改。

4.2 中文乱码:排查到最后一层才发现是编码声明

课程作业里中文乱码几乎人人都会碰到,但这次的经历让我把排查链路完整走了一遍。

现象是:我在表单里输入中文书名“高等数学”,提交后数据库里能查到正确文字,但浏览器展示出来的却是乱码。这个现象很怪,因为数据库正确,说明写入没坏,那问题一定出现在读取或展示环节。

我先检查数据库连接串,发现代码里没有显式指定编码,而 SQLite 本身默认就是以 UTF-8 存储文本,所以写入正常。接着我检查页面模板,发现 HTML 头部的 meta 标签写的是 charset=gb2312,而 Flask 返回响应头里设置的又是 text/html; charset=utf-8,两边打架了。

浏览器在决定如何解码响应时,会优先使用响应头里的 charset。响应头声明 UTF-8,HTML 里也声明 UTF-8,理论上应该一致。但有一个问题被我忽略了:提交表单时如果 HTML 页面声明旧编码,浏览器发送请求体时也会按旧编码编码,导致后端接收到的中文已经变成了错误字节。最终我在所有页面补上统一的 UTF-8 声明,后端也固定使用 UTF-8,问题才彻底消失。

这次排错给我留下的经验只有一句话:全链路编码必须从表单到数据库到响应头保持一致,任何一环不一致,都会在某个看似无关的地方炸出来。

4.3 图片上传后换台电脑就消失

图片上传功能的坑最隐蔽。我在自己电脑上测试时,图书图片显示正常,可把项目文件夹拷贝到另一台电脑运行后,所有上传过的图片都不见了。打开浏览器开发者工具一看,图片请求的地址变成了类似 C:/Users/xxx/Pictures/1.png 的形式,我才意识到这是存储路径的问题。

我最初写代码时很顺手地用了文件对象的完整路径,比如 file_path = "D:/project/uploads/" + filename,然后把这个绝对路径存进数据库。问题在于,作业文件夹移交之后,路径前缀换了,数据库里的绝对路径就全部失效。

修复方法其实很简单:数据库只保存相对于 static/uploads 目录的文件名,展示页面时用 url_for 动态生成 URL。这样无论项目放在哪个目录,nginx 或 Flask 的静态文件服务都能正确找到文件。

from flask import url_for url = url_for("static", filename=f"uploads/{book.image_path}")

同时我还限制了上传图片的大小,超过 5MB 直接拒绝。这个限制不是为了防攻击,而是避免演示时打开页面加载一堆大图,影响流畅度。课设项目里图片全部控制在几百 KB 内就够了。

4.4 登录状态丢失:会话机制与浏览器兼容

另一个不算严重但很影响体验的问题是登录状态刷新页面就消失。我最初用前端 localStorage 保存当前用户名,刷新后读取显示,看起来简单,但这是伪登录。只要关掉浏览器,或者用户手动清存储,登录状态就没了。

后端登录逻辑需要维护会话。我改用 Flask 的 session,登录成功后把 user_id 塞进去,后续请求通过 session 判断身份。session 默认使用签名 Cookie,客户端无法伪造,这比自己造 Token 要稳得多。

同时还要处理浏览器的 cookies 策略,跨页面请求时如果不带认证信息,会一直跳回登录页。我把所有受保护的路由集中写了一个装饰器,统一校验 session,没有登录就重定向,这样就不需要每个视图函数重复写判断代码。

5. 交作业前,我按这份清单做了最后一轮检查

5.1 从零环境跑起来,才叫真正可交付

功能全部写完后的第一步,不是在功能里再点上几遍,而是假装自己是一个第一次拿到项目的评审者,在干净环境里照着 README 跑一遍。我特意找了个只装了基础 Python 解释器的目录,把项目丢进去,严格按照自己写的步骤操作,发现了两处文档和实际不符的地方。

比如我 README 里写的初始化命令漏了环境变量设置,导致第一次启动后找不到配置项。这类问题如果自己不提,评分者跑不通,第一印象就直接打折。

我建议把这个“干净环境测试”放在所有功能测试之前,因为项目起不来,里面功能再好都白搭。而这个操作本身只要十分钟,性价比极高。

5.2 功能验收、演示视频与代码注释

演示视频是我在所有作业里都会额外提交的一项材料。录一个五分钟左右的视频,按用户视角走完整流程:注册、登录、发布图书、搜索、下单、确认收货。视频的好处是,评分者不需要琢磨项目怎么跑,直接看结果,这是最省力的展示方式。

视频脚本顺序我直接按功能清单表格排,保证每个验收标准对应的操作都能在画面上出现。录制时把浏览器控制台也打开,遇到报错立即停下修复,不把问题留到视频里。提交材料里还可以把视频链接放到 README 最前面。

代码注释方面,我会在核心函数上方写三行左右的说明:它做什么、为什么这么做、调用时要注意什么。注释不追求多,重点把“为什么”讲清楚,尤其是条件更新那部分,不写注释别人很容易误删关键逻辑。比起满篇废话注释,这种关键位置的注释才是真正有用的。

5.3 复盘:作业和作品之间隔着一层用心

把项目打包的那一刻,我重新翻了一遍整个任务,最大的收获不是学会了多少个框架 API,而是理解了“作业”和“作品”的差别。作业只要功能点都有就能交差,作品则需要考虑别人打开它时的体验:能否快速跑起来、代码是否清晰、关键逻辑有没有说明、演示过程是否顺畅。

这些看起来都是花时间做小事,但恰恰是这些小事把两个水平相近的提交拉开差距。后来帮同学检查作业时我发现,九成问题都出在文档跟不上、依赖版本没锁、路径写死,而不是核心功能跑不通。这说明项目质量的瓶颈往往不在技术难度,而在交付意识。

我在实际整理提交物时还有个小技巧想分享:交作业前找一位从没打开过你项目的朋友,只把 README 发给他,让他照着跑一遍。他遇到的每一个卡点,都是你文档里缺的句子。这个测试你找十个人测,每个人都会发现不同的惊喜,而且比你闷头自测效率高得多。课设如此,任何要交付的项目也是如此。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询