这周的第三次作业总算交上去了,长出一口气。如果你也在做类似的课程设计作业——尤其是图书管理系统、学生信息管理、库存管理这类Web开发作业,那这篇应该对你有用。我这次做的是某高校软件设计课程的第三个作业,题目是“图书借阅管理系统”,要求用Web方式实现,支持图书信息的增删改查、用户登录和借阅归还记录,技术栈不限。作业本身不算难,但从题目解读、技术选型到联调上线,中间坑其实不少,尤其是第一次做前后端分离的人,很容易卡在接口和跨域上。下面把我的完整思路、关键代码和踩坑记录都写出来,你们可以直接参考。
1. 题目到手之后,我为什么选了这个方案
1.1 先别急着写代码,把作业要求拆成功能清单
我拿到“图书借阅管理系统”这个题目时,第一反应是这玩意太经典了,网上一抓一大把。但经典题目反而容易被做成千篇一律的“教材代码”,所以我先花了一个小时把需求拆成功能清单,确保自己知道到底要交付什么。
课程要求原文大致是:系统要有图书信息管理、借阅者管理、借书还书操作和基本的借阅记录查询。我把它拆成了下面这个清单:
- 用户端:登录、注册(可选)、查看图书列表、借书、还书、查看自己的借阅记录。
- 管理端:图书的增删改查、借阅记录的查看和删除、用户列表。
这里有个很重要的点:作业要求的“图书借阅管理”,重点在“借阅流程”而不是单纯对图书表做CRUD。很多同学把功夫全花在图书的增删改查上,结果借书还书只是简单地改个字段,这样其实没有完全命中题目。所以我额外设计了借阅记录表,把每次借书、还书都留痕,这样演示给助教看的时候,才能清楚地说明“哪个用户、借了哪本书、什么时候借的、什么时候还的”。
1.2 技术选型:先求稳,再谈酷
技术栈我选了 Flask + SQLite + 原生HTML和JavaScript。没有用Vue,也没有用Spring Boot或者MySQL。原因很简单:这是一次课程作业,我只有一周多的时间,而且要考虑演示环境。
- 后端用Flask,因为Python写起来快,路由和请求处理直观,不需要像Java那样配置一大堆东西。
- 数据库用SQLite,免安装、单文件、随代码一起交即可。老师或助教拿到项目后不需要额外装数据库服务,跑一下就能看到结果。
- 前端用原生JavaScript,没有用构建工具,避免node_modules和打包带来的环境问题。
有人说“技术栈太简单会不会显得没水平”,我的看法是,作业首先要保证能跑通、能演示、逻辑完整。如果时间充裕,可以在关键地方做优化,比如加缓存、加权限控制,而不是一上来就上微服务那一套。把基础功能做扎实,比堆砌一堆用不上的框架名词要重要得多。
2. 数据模型与接口设计:作业能不能跑通,90%看这一层
2.1 数据库表怎么拆:用户、图书、借阅记录的关系
我设计了三张表。很多同学习惯把用户和图书数据混在一起,或者不建借阅记录表,直接在图书表里加一个“是否被借出”的字段。但这样会丢失历史记录,比如“谁借过这本书”“还了没有”都查不到。实际开发里,业务流程的数据必须单独建表。
建表SQL如下,我直接写在schema.sql里:
CREATE TABLE user ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE, password TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'user', created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')) ); CREATE TABLE book ( id INTEGER PRIMARY KEY AUTOINCREMENT, isbn TEXT NOT NULL UNIQUE, title TEXT NOT NULL, author TEXT NOT NULL, publisher TEXT, total_copies INTEGER NOT NULL DEFAULT 1, available_copies INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')) ); CREATE TABLE borrow_record ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, book_id INTEGER NOT NULL, borrow_time TEXT NOT NULL DEFAULT (datetime('now', 'localtime')), return_time TEXT, status TEXT NOT NULL DEFAULT 'borrowed', FOREIGN KEY (user_id) REFERENCES user(id), FOREIGN KEY (book_id) REFERENCES book(id) );这里有几个细节容易忽略:
- 图书表里既有total_copies(总库存),又有available_copies(可借数量)。为什么拆分?因为同一本书可能有多本,借书时只减少可借数量,而不是把整本书删掉。如果所有图书都只存一条记录,就无法处理“同一本书有两本,其中一本被借走”的情况。
- 借阅记录表里的status字段,我用了'borrowed'和'returned'两个字符串值。有人喜欢用0和1,但我建议用可读字符串,这样调试时看数据库一眼就明白,不需要查表说明。
- password字段存储的是哈希后的结果,不是明文。课程作业虽然要求不高,但至少不能把明文密码放到数据库里,否则太难看。
2.2 接口路径和返回格式,照着这个规范写就行
接口设计我遵循了最基础的REST风格,统一返回格式。后端所有接口返回的都是JSON,结构如下:
{ "code": 0, "msg": "success", "data": {} }code为0表示成功,非0表示失败。这样前端只用判断code,不用去解析各种乱七八糟的错误结构。
实际接口清单如下表:
| 方法 | 路径 | 功能 | 是否需要登录 |
|---|---|---|---|
| POST | /api/login | 用户登录 | 否 |
| GET | /api/books | 图书列表(支持分页和搜索) | 是 |
| POST | /api/books | 新增图书 | 管理员 |
| PUT | /api/books/ | 修改图书信息 | 管理员 |
| DELETE | /api/books/ | 删除图书 | 管理员 |
| POST | /api/borrow | 借书 | 是 |
| POST | /api/return | 还书 | 是 |
| GET | /api/records | 借阅记录 | 是 |
关于登录和权限,后面第三章会细说。返回格式统一非常重要,前端代码可以抽一个共用的处理函数,比如这样:
async function request(url, options) { const res = await fetch(url, options); const data = await res.json(); if (data.code !== 0) { alert(data.msg); throw new Error(data.msg); } return data.data; }这样所有接口调用都走一个函数,不用每个请求都重复处理错误,代码干净很多。
2.3 一个容易被忽略的细节:时间字段和状态字段
时间字段我用的是SQLite自带的datetime('now', 'localtime'),这里特别注意有'localtime'和没有'localtime'的区别。如果直接用datetime('now'),存进去的是UTC时间。国内环境下差8个小时,我在调试时就遇到过:明明下午借的书,列表里显示凌晨时间,看着特别奇怪。所以建表的时候默认值一定要写'localtime'。
状态字段背后还有一个业务判断:还书接口要区分“这本书真的被这个人借过吗”。所以还书不是简单地传一个book_id,而是要把user_id和book_id一起传过来,然后在borrow_record表里找一条status='borrowed'且user_id和book_id匹配的记录,把它的status改成'returned'并写入return_time。这样既保证了流程闭环,也防止有人乱调接口把没借过的书“还”了。
3. 前后端联调的完整流程与关键代码
3.1 后端骨架怎么搭
Flask项目的目录并不复杂,我的结构是这样的:
app.py schema.sql static/ index.html style.css app.jsapp.py是启动入口,Flask路由都写在里面。数据库操作我可以直接用Python内置的sqlite3模块,不需要额外的ORM。ORM虽然写起来省事,但查询逻辑不透明,作业演示时被追问“这个查询是怎么实现的”容易答不上来。直接用sqlite3反而更好解释。
核心初始化部分如下:
from flask import Flask, request, jsonify, session import sqlite3 import hashlib app = Flask(__name__) app.secret_key = 'course-work-secret-key' DATABASE = 'library.db' def get_db(): conn = sqlite3.connect(DATABASE) conn.row_factory = sqlite3.Row return conn def init_db(): with sqlite3.connect(DATABASE) as conn: with open('schema.sql', 'r', encoding='utf-8') as f: conn.executescript(f.read()) if __name__ == '__main__': init_db() app.run(debug=True, port=5000)这里flask的session需要设置secret_key,否则session无法正常工作。课程作业里写死一个字符串足够了,但正式项目里肯定要用环境变量注入。
登录接口的实现也比较直接,密码用SHA256哈希后比对:
@app.route('/api/login', methods=['POST']) def login(): data = request.get_json() username = data.get('username') password = data.get('password') if not username or not password: return jsonify({'code': 1, 'msg': '用户名和密码不能为空'}) conn = get_db() user = conn.execute( 'SELECT * FROM user WHERE username = ?', (username,) ).fetchone() conn.close() if user and user['password'] == hashlib.sha256(password.encode()).hexdigest(): session['user_id'] = user['id'] session['username'] = user['username'] session['role'] = user['role'] return jsonify({'code': 0, 'msg': '登录成功', 'data': { 'username': user['username'], 'role': user['role'] }}) return jsonify({'code': 1, 'msg': '用户名或密码错误'})注意这里用了SQL参数化查询,而不是把username变量直接拼进SQL字符串里。以前见过不少同学在作业里用format拼接SQL,最后被一句“xxx' OR '1'='1”把整个用户表举出来了,这个习惯哪怕是一次课程作业也要改掉。
3.2 前端页面怎么调用接口
前端我做了两个页面视图,实际上是一个index.html里两个区块:登录区和主功能区。用原生JS控制显示还是隐藏。
<div id="loginPanel"> <h2>登录</h2> <input type="text" id="username" placeholder="用户名"> <input type="password" id="password" placeholder="密码"> <button onclick="login()">登录</button> </div> <div id="mainPanel" style="display:none"> <h2>图书管理</h2> <button onclick="logout()">退出登录</button> <table id="bookTable">...</table> </div>登录后把session中的用户信息展示出来,再把主面板显示。加载图书列表:
async function loadBooks() { const data = await request('/api/books'); const tbody = document.querySelector('#bookTable tbody'); tbody.innerHTML = ''; data.books.forEach(function(book) { const tr = document.createElement('tr'); tr.innerHTML = ` <td>${book.title}</td> <td>${book.author}</td> <td>${book.available_copies}/${book.total_copies}</td> <td> <button onclick="borrowBook(${book.id})">借书</button> </td> `; tbody.appendChild(tr); }); }用模板字符串拼接HTML确实没有Vue的响应式那么优雅,但作业场景下完全够用。这里有一个安全性问题:如果书名等内容里包含HTML标签,会被浏览器直接解析。课程作业里的数据是自己录入的,问题不大,但我在输出时还是做了简单的escape函数,这是习惯问题。
3.3 联调时最容易翻车的CORS和静态文件路径
这个部分我要重点说,因为我身边至少三个同学都卡在这里。
情况是这样的:开发时前端代码在VS Code里用Live Server打开,地址是127.0.0.1:5500,而后端Flask跑在127.0.0.1:5000。前端用fetch请求http://127.0.0.1:5000/api/books,浏览器会拦截,报这个错:
Access to fetch at 'http://127.0.0.1:5000/api/books' from origin 'http://127.0.0.1:5500' has been blocked by CORS policy这就是跨域问题。前端和后端端口不一致,浏览器出于安全策略不允许直接跨端口访问。解决办法有几个:
- 给Flask安装flask-cors扩展,允许所有来源访问。
- 把所有前端文件放到Flask的static目录里,直接访问5000端口,这样就不存在跨域。
- 用Nginx做反向代理,统一到同一个域名和端口。
我选了第二种办法,因为课程作业不需要单独部署前端静态文件服务器。把index.html、app.js、style.css放在static目录后,访问http://127.0.0.1:5000/就能打开完整页面,后端接口地址直接写相对路径/api/books,不用拼接域名端口,也顺带解决了跨域。
如果你的项目已经用了前后端分离开发,没办法直接塞进Flask里,再去用flask-cors:pip安装后加两行:
from flask_cors import CORS CORS(app)不过,de全局允许跨域在演示时没问题,生产环境就不行了,需要配置白名单。
4. 我把哪些“本来能交差就好”的地方额外打磨了
4.1 登录状态我用Session而不是每次传密码
有人图省事,每个接口都要求前端把用户名密码传过来,后端每次查库验证。这样也能跑,但既不安全也不合理。我用Flask的session机制,登录成功后把用户信息存进Cookie,后续请求后端通过session就能判断身份。
需要登录才能访问的接口,我抽了一个装饰器:
from functools import wraps def login_required(f): @wraps(f) def wrapper(*args, **kwargs): if 'user_id' not in session: return jsonify({'code': 401, 'msg': '请先登录'}) return f(*args, **kwargs) return wrapper在路由上加上这个装饰器就可以保护接口:
@app.route('/api/books', methods=['GET']) @login_required def list_books(): pass说实话,如果之前没接触过装饰器,这个概念有点绕。简单理解就是:你要访问图书列表接口,先过login_required这个“门卫”,它检查你有没有登录,没登录直接拦下。
4.2 错误提示和信息反馈,决定作业演示的观感
很多练习作业的后端接口,一旦出错就是返回一段英文错误文本,或者干脆500。演示的时候,助教随手往登录框里输入一个不存在的用户名,页面弹出一条“Internal Server Error”,观感会非常差。
我的做法是每个接口都考虑“出错路径”:
- 用户名不存在:返回“用户名不存在,请检查输入”。
- 密码错误:返回“密码错误,请重新输入”。
- 借书时库存不足:返回“这本书暂时没有可借副本”。
- 添加图书时ISBN重复:返回“该ISBN已存在”。
这些提示不是硬编码在前端的,而是由后端返回标准格式里的msg字段,前端统一弹出或显示。这么做有几个好处:一是后端逻辑清晰,二是换一个前端页面也能复用,三是演示时显得你考虑周全。
4.3 数据校验:别让随便输入的ISBN把后端搞崩
作业演示时,总有人喜欢在输入框里乱填。如果后端不校验,输入框里填一个长度是100的字符串,数据库字段存不下就会报错;填一个负数作为库存,页面就会显示-1本可借书,非常离谱。
我做了两层校验。第一层在HTML里限制类型:
<input type="number" min="1" max="999" id="totalCopies">第二层在后端接口里处理逻辑。比如添加图书:
@app.route('/api/books', methods=['POST']) @login_required def add_book(): if session.get('role') != 'admin': return jsonify({'code': 403, 'msg': '仅管理员可操作'}) data = request.get_json() title = (data.get('title') or '').strip() author = (data.get('author') or '').strip() isbn = (data.get('isbn') or '').strip() if not title: return jsonify({'code': 1, 'msg': '书名不能为空'}) try: total_copies = int(data.get('total_copies', 1)) if total_copies < 1: raise ValueError except (TypeError, ValueError): return jsonify({'code': 1, 'msg': '库存必须是不小于1的整数'}) ...后面还要检测ISBN是否已存在,存在则提示。其实一个完整的信息系统,校验逻辑会很多,但作业至少要做到“不会因为非法输入直接500”。
5. 踩坑实录与排查技巧
5.1 数据库写入中文变成乱码
第一次在SQLite里插入“三体”这本书,前端页面显示出来是一堆“锟斤拷”之类的乱码。我排查了好一会儿,发现SQLite本身是支持UTF-8的,问题出在Python文件读取schema.sql时没指定编码。
当时我在get_db里面执行schema.sql用的是:
with open('schema.sql') as f:系统默认编码在Windows下是GBK,中文字符就被错误解码了。解决办法是打开文件时明确指定编码:
with open('schema.sql', 'r', encoding='utf-8') as f: conn.executescript(f.read())还有一个相关坑:如果直接在SQLite命令行工具里手动插入中文数据,而终端代码页是936,也容易乱码。最好的做法是不要在命令行里插数据,全让Python代码处理。
5.2 前端一直拿不到数据,但用Postman测接口又是好的
这个问题很典型。前端页面打开控制台,fetch返回的数据不对,但同样一个接口放到Postman里测,完全正常。
后来定位到两个原因。第一个是浏览器缓存了旧的JavaScript文件,代码改了但没生效。解决方法是强制刷新,或者开发时在HTML里引用JS的路径后面加一个版本参数:
<script src="/app.js?v=20250412"></script>第二个原因是后端代码报错,但Flask在debug模式下返回的HTML错误页被前端当成正常消息展示了。前端请求拿到的是500状态,再调用res.json()时就解析失败。解决办法是fetch里先判断res.ok,不是2xx就读取text输出到控制台:
const res = await fetch(url, options); if (!res.ok) { const text = await res.text(); console.error(text); throw new Error('请求失败'); }这样排查后端错误会快很多。
5.3 端口被占用和SQLite数据库被锁
Flask默认端口5000,被其他进程占用时会报Address already in use。我解决的办法是换端口运行:
python app.py --port 5001如果不想每次启动都指定端口,也可以在app.py里用环境变量读取:
import os port = int(os.environ.get('PORT', 5000)) app.run(debug=True, port=port)另一个坑是SQLite数据库文件被锁,报database is locked错误。原因是Python的sqlite3连接默认不是线程安全的,而Flask的调试模式会启动多个线程。我在写数据时没有及时关闭连接,导致锁冲突。解决方法是每次操作后用with语句或显式close,避免长时间占用连接。
5.4 常见问题速查表
| 症状 | 可能原因 | 解决方法 |
|---|---|---|
| 登录后刷新就掉线 | 没设置secret_key或Cookie过期 | 设置app.secret_key,检查session存储 |
| 前端接口跨域报错 | 前端和后端端口不同 | 统一部署到Flask的static目录,或用flask-cors |
| 中文乱码 | schema.sql读取编码错误 | open时指定encoding='utf-8' |
| 借书数量不对 | available_copies和total_copies混用 | 借书时先查可用副本数量,大于0才允许借出 |
| 页面改了没反应 | 浏览器缓存 | 强制刷新或JS路径带版本参数 |
| 500错误但日志没输出 | debug模式未开启 | app.run(debug=True),同时看控制台堆栈 |
6. 这份作业做完,我的几点体会
程序能跑起来只是最基本的要求,真正拉开差距的是细节:数据库字段设计是否合理、接口返回是否有统一格式、错误提示是否友好、身份校验是否安全。这些不会在功能演示时直接体现,但助教深挖代码或者追问的时候,你就能从容应对。
我个人的体会是,做这类管理系统的作业,先把借阅流程走通,再考虑什么“优雅”设计,顺序不能反。我第一次实现的时候,试图先做一个完美的权限模型,结果写了一堆用不上的抽象类,基本功能还没影,后来全推倒重来了。如果你也在赶作业,我建议先把登录、图书增删改查、借书还书四个完整流程跑通,这就有80分了,剩下的边际效用没有你想的那么大。
还有一个小技巧分享给大家:演示前一定要准备一个“破坏性测试清单”。比如故意输入不存在的用户名、连续点两次借书、只填一半表单点提交。我在检查时发现,快速连点两次借书按钮会造成重复借阅记录,后来在按钮上加了禁用状态才解决。这些细节不测出来,演示现场就会非常尴尬。
最后说一句,很多同学会在期末的时候熬夜赶这种作业,但课程设计的前两次作业其实都是在为最终的大作业打基础。这次把Flask的会话机制、SQLite的建表流程、前后端联调思路搞清楚,后面做完整项目时就会顺畅很多。如果你也卡在某一步,欢迎把这篇文章里提到的坑对照自己的项目查一遍,大概率能找到问题。