Python+Vue图书借阅管理系统开发实战:从登记表到前后端分离
1. 爱心图书馆到底缺一个什么样的借阅系统1.1 从一张手写借阅登记表说起很多社区爱心图书馆、学校图书角最初的管理方式就是一张A4纸登记表。读者在纸上写下姓名、联系方式、书名、借书日期还书的时候再划掉。这个模式对几十本藏书还能对付一旦图书超过几百本、读者超过几十个人问题就全冒出来了书被谁借走了查不到、一本书什么时候该还全靠管理员脑子记、有人借了半年没还也没发现、想统计哪个类型的书最受欢迎更是不可能的事。这个项目要做的就是用Python写后端、Vue写前端搭一套能真正跑起来的图书借阅管理系统。我之前在帮一个社区爱心图书馆做这类系统的时候最大的感受是这类系统的核心需求其实非常克制不需要什么高深算法关键是把谁在什么时间借走了哪本书、什么时候该还、书还剩几本可借这几件事记录清楚、查询方便、操作顺手。技术栈选Python加Vue加Pycharm也不是赶时髦而是这个组合在开发效率、学习资料、后期维护之间取得了很实际的平衡。1.2 三类用户角色和他们的真实需求先别急着写代码把用户角色理清楚是这类系统能不能落地的关键。爱心图书馆的使用者基本可以分成三类普通读者能注册登录、浏览藏书、搜索图书、查看详情、借书、还书、查看自己的借阅历史和逾期情况。图书管理员能处理借还书操作、录入新书、修改图书信息、查看所有读者的借阅记录、处理逾期、管理读者状态。系统管理员在管理员基础上还能管用户权限、查看统计报表、备份数据。从需求角度说普通读者最在意的是能不能快速找到我想看的书、我还有几本没还、有没有超期管理员在意的是借还操作够不够快、信息准不准而作为开发人员我们真正要解决的技术问题其实是三个图书库存的准确性、借阅状态的正确流转、前后端数据的同步一致。我见过不少类似项目功能越加越多最后变成了一个大杂烩反而把最核心的借还流程做得一塌糊涂。做这个系统一定要记住一个原则先把借书、还书、续借、查询这几条主干流程跑通再考虑锦上添花的功能。2. 技术选型分析django还是flaskVue在这套系统里的位置2.1 django和flask的取舍笔记标题里同时出现了django和flask很多刚接触Python的朋友会纠结到底学哪个、用哪个。我在实际项目中两个都深度用过说说我的判断。django走的是电池全带路线自带ORM、Admin后台、认证系统、表单处理、迁移工具。优点是你不需要自己去拼装太多东西按它的MVT模式组织代码项目结构天然清晰安全防护SQL注入、XSS跨站脚本也做得比较到位。对图书借阅系统这种典型的增删改查业务django的Admin后台几乎可以白送一个管理界面早期非常省事。缺点是框架比较重ORM的复杂查询一旦没用好性能会出问题而且它的约定优于配置风格初学者有时候不太理解为什么要这么分层。flask是轻量级微框架核心只处理路由和请求响应ORM、表单校验、认证这些都要自己选配。它的优势是透明、灵活每个组件都是自己亲手装上去的出问题了容易定位。适合中小型项目也适合想真正理解web框架原理的人。缺点就是自由度过高如果项目负责人心里没有清晰的架构很容易把代码写成一团乱麻。以图书借阅系统这个项目为例我个人推荐如果你希望快速交付、还想要现成的Admin后台选django如果你想保持代码最小化、每个模块都自己掌控选flask。但无论选哪个后端的API设计思路完全是相通的。下面核心技术部分我以django为例展开因为这些逻辑在flask里同样能用SQLAlchemy实现差别不大。2.2 前后端分离的通信机制axios和RESTful API这个项目的显著特征是Python Vue也就是典型的前后端分离。Vue负责浏览器里的页面渲染和用户交互Python后端只负责提供JSON格式的数据接口两边通过HTTP协议通信。Vue项目里通常用axios这个库来发请求。比如前端要获取图书列表就往后端发一个GET /api/books/请求后端查询数据库后返回一串JSON数据Vue拿到数据后渲染成页面表格。整个过程的核心是我要说的RESTful API设计把图书、读者、借阅记录都当作资源用HTTP方法表达操作语义。一个最简单的登录接口前端这样调用import axios from axios axios.post(/api/auth/login/, { username: this.loginForm.username, password: this.loginForm.password }).then(res { // 登录成功保存token并跳转到首页 localStorage.setItem(token, res.data.token) this.$router.push(/) }).catch(err { this.$message.error(用户名或密码错误) })后端在django里对应的视图可能长这样import json from django.contrib.auth import authenticate from django.http import JsonResponse from django.views.decorators.csrf import csrf_exempt csrf_exempt def login(request): if request.method POST: data json.loads(request.body) user authenticate(usernamedata.get(username), passworddata.get(password)) if user: token generate_token(user) return JsonResponse({code: 0, token: token, username: user.username}) return JsonResponse({code: 1, msg: 用户名或密码错误})看到区别没有后端不再返回HTML页面而是返回结构化数据。页面长什么样、按钮怎么摆全部交给Vue去处理。这种分工让前端工程师和后端工程师可以并行开发也方便以后做小程序、App时复用同一套后端接口。2.3 Pycharm里的项目搭建流程在Pycharm里搭建这个项目我建议按下面几步来创建虚拟环境。项目打开后在File - Settings - Project - Python Interpreter里新建一个虚拟环境避免依赖冲突。安装后端依赖。在终端执行pip install django djangorestframework django-cors-headers pillow如果走flask路线则是pip install flask flask-cors flask-sqlalchemy。创建django项目和应用django-admin startproject library_system再python manage.py startapp books。一个常见的坑是环境变量和解释器路径搞错。很多新手在Pycharm里换了虚拟环境之后终端还是用系统Python执行命令结果报ModuleNotFoundError: No module named django其实不是代码问题而是解释器没切过来。Pycharm的Terminal左下角可以查看当前激活的环境确保路径指向项目内的venv。Vue前端项目我用命令行工具构建npm create vuelatest或者vue create library-web装好之后用npm run serve启动开发服务器。开发阶段前端跑在8080端口后端跑在8000端口两边端口不同必然会遇到跨域问题这个是后面联调章节要讲的第一个坑。3. 数据库建模把借书还书这个动作拆成可落地的数据关系3.1 核心表结构设计图书借阅系统的数据库设计最核心的就是三张表图书表、读者表、借阅记录表。很多新手在这里容易犯的错误是一拍脑袋就建表结果后期加需求发现根本改不动。先说图书表。图书信息不只是书名和作者至少还要有ISBN编号、分类、出版社、馆藏数量、当前可借数量、存放位置。可借数量这个字段非常关键它不等于馆藏数量因为有一部分书可能正在被借走。我习惯用total_count表示馆藏总量available_count表示当前可借数量每次借书扣1还书加1这样查询首页和列表页的时候不用实时join借阅记录表去算数量性能好很多。再说读者表。在这个场景里读者和系统用户其实可以合并成一张表加上角色字段区分是普通读者还是管理员。除了用户名密码还需要手机号、借阅卡号、最大借阅数量、当前借阅数量、状态。这里有一个容易忽略的设计点借阅卡号要保证唯一很多人直接用自增id当卡号一旦数据迁移或者合并就容易冲突。最后是借阅记录表这是整个系统的核心。记录里要存借书时间、应还时间、实际还书时间、续借次数、状态。特别要注意的是应还时间最好在建记录的时候就按借书时间借阅周期算好存进去而不是等查询的时候临时计算这样即使以后调整借阅周期规则历史记录也不受影响。3.2 借阅记录的状态流转借阅状态看起来简单但设计不好就会混乱。我把它做成一个明确的状态列表borrowing在借中还没还。returned已归还正常。overdue已归还但发生了逾期。overdue_borrowing在借且已超过应还时间。用几个短语还是不够直观我用一段伪代码来描述状态的迁移规则借书时创建记录状态borrowing应还时间当前时间30天图书available_count减1 还书时 如果当前时间 应还时间状态returned 如果当前时间 应还时间状态overdue 图书available_count加1 记录实际还书时间 续借时 如果状态!borrowing不允许 如果续借次数2不允许 应还时间15天续借次数加1这套规则在数据库层面还需要一个辅助查询某个读者当前有多少本逾期未还的书这决定了能不能继续借书。有些公益图书馆会规定有逾期未还就不能借新书这个逻辑在借书接口的校验里体现。3.3 用ORM建表的示范django的ORM写起来比较直观。以借阅记录为例from django.db import models from django.conf import settings class BorrowRecord(models.Model): STATUS_CHOICES ( (borrowing, 在借中), (returned, 已归还), (overdue, 逾期归还), (overdue_borrowing, 逾期未还), ) book models.ForeignKey(books.Book, on_deletemodels.CASCADE, verbose_name图书) reader models.ForeignKey(settings.AUTH_USER_MODEL, on_deletemodels.CASCADE, verbose_name读者) borrow_time models.DateTimeField(auto_now_addTrue, verbose_name借书时间) due_time models.DateTimeField(verbose_name应还时间) return_time models.DateTimeField(nullTrue, blankTrue, verbose_name实际还书时间) renew_count models.IntegerField(default0, verbose_name续借次数) status models.CharField(max_length20, choicesSTATUS_CHOICES, defaultborrowing, verbose_name状态) class Meta: db_table borrow_record ordering [-borrow_time]用flask-SQLAlchemy的写法也差不多class BorrowRecord(db.Model): __tablename__ borrow_record id db.Column(db.Integer, primary_keyTrue) book_id db.Column(db.Integer, db.ForeignKey(book.id)) reader_id db.Column(db.Integer, db.ForeignKey(user.id)) borrow_time db.Column(db.DateTime, defaultdatetime.now) due_time db.Column(db.DateTime) return_time db.Column(db.DateTime) renew_count db.Column(db.Integer, default0) status db.Column(db.String(20), defaultborrowing)在Pycharm里用ORM建完表之后记得执行python manage.py makemigrations和python manage.py migrate把模型同步到数据库。第一次做的时候很多人会漏掉迁移这一步然后报table books_book does not exist的错误这个其实是操作流程问题不是代码问题。4. 后端接口实现借还书业务的核心逻辑与并发控制4.1 登录认证和权限控制接口设计的第一步是把需要登录才能访问的接口和后端对应的权限控制做对。django自带的auth模块提供用户表和会话机制但前后端分离项目里更常用的是JWTJSON Web Token方案。用djangorestframework-simplejwt这个库安装配置之后登录接口会自动返回一个token前端在后续请求的请求头里带上Authorization: Bearer token后端就能识别用户身份。权限控制我一般用django-rest-framework的IsAuthenticated和自定义权限类from rest_framework.permissions import BasePermission class IsAdminUser(BasePermission): def has_permission(self, request, view): return request.user and request.user.is_staff class IsReaderUser(BasePermission): def has_permission(self, request, view): return request.user and not request.user.is_staff借书接口是读者权限图书录入接口是管理员权限这样在视图上标注清楚就不会出现普通读者跑到管理后台乱改数据的问题。4.2 借书接口的状态校验清单借书接口是整个系统里坑最多的地方因为它涉及多个条件的组合判断。我把它拆成一个清单用户必须已登录。图书必须存在且状态为上架。available_count必须大于0。用户当前在借数量不能超过上限比如5本。用户不能有逾期未还的记录。同一本书用户不能重复借除非已还。这些校验看着琐碎但少一个都会在生产环境出问题。比如第6条如果不检查用户可以在未还的情况下反复借同一本书系统中就会出现多条在借记录账目全乱。对应的django视图核心逻辑如下from django.db import transaction from django.utils import timezone from datetime import timedelta from rest_framework.decorators import api_view, permission_classes from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response api_view([POST]) permission_classes([IsAuthenticated]) def borrow_book(request): book_id request.data.get(book_id) book Book.objects.select_for_update().get(idbook_id) # 校验图书是否可借 if book.available_count 0: return Response({code: 1, msg: 此书暂时没有可借的库存}) # 校验读者是否有逾期记录 has_overdue BorrowRecord.objects.filter( readerrequest.user, statusoverdue_borrowing ).exists() if has_overdue: return Response({code: 1, msg: 你有逾期未还的书请先归还}) # 校验在借数量 borrowing_count BorrowRecord.objects.filter( readerrequest.user, statusborrowing ).count() if borrowing_count 5: return Response({code: 1, msg: 已达到最大借阅数量}) with transaction.atomic(): record BorrowRecord.objects.create( bookbook, readerrequest.user, due_timetimezone.now() timedelta(days30), statusborrowing ) book.available_count - 1 book.save() return Response({code: 0, msg: 借书成功, data: {record_id: record.id}})4.3 还书接口和逾期计算还书接口相对简单但有一个隐藏逻辑必须处理判断当前时间是否超过了due_time从而决定记录状态是正常归还还是逾期归还。很多人在这里很容易犯一个错误就是用前端传来的时间或者用户选择的时间正确做法是以后端服务器时间为准不要让客户端传时间因为客户端时间可以随意修改。api_view([POST]) permission_classes([IsAuthenticated]) def return_book(request): record_id request.data.get(record_id) try: record BorrowRecord.objects.select_for_update().get( idrecord_id, readerrequest.user, statusborrowing ) except BorrowRecord.DoesNotExist: return Response({code: 1, msg: 借阅记录不存在或已归还}) now timezone.now() record.return_time now record.status overdue if now record.due_time else returned record.save() book record.book book.available_count 1 book.save() return Response({code: 0, msg: 还书成功})4.4 事务与行锁防止图书库存被借超这是整个模块里最容易翻车的地方。假设某本书只剩1本库存两个读者在同一秒发起借书请求如果没有做并发控制两个请求都查询到available_count 1然后都在各自的事务里减1最终这本书的available_count可能变成-1数据库里出现借超的严重数据错误。解决方案是在读取库存时给数据库行加锁。我上面代码里写的select_for_update()就是干这个的。它会在数据库层面锁定这一行直到当前事务提交或回滚其他事务必须等锁释放才能读取或修改这行数据。为了加深理解我补充说明一下这个锁的使用时机必须先启动一个事务再执行select_for_update()查询。一个常见的错误写法是前面已经查询过了后面才想起要加锁于是又查了一次但两次查询之间数据已经被别的请求改掉了。正确做法是用transaction.atomic()包裹住整个查询库存扣减数量创建记录的过程且第一行读取就加锁。在flask里对应的写法是使用with_for_update()book Book.query.filter_by(idbook_id).with_for_update().first()配合SQLAlchemy的db.session.commit()手动控制事务边界。这个点属于教科书里一句话带过、但实际开发必踩的坑做图书类系统一定要重视。5. Vue前端从登录页到管理后台的完整页面链路5.1 前端工程初始化与目录规划Vue端我用的是Vue 3 Vue Router Pinia也可以选Vuex Element Plus组件库。Element Plus提供了一套现成的表格、表单、弹窗、消息提示组件做后台管理类页面效率特别高。在Pycharm的控制台先跑一遍初始化命令npm create vuelatest library-web cd library-web npm install npm install axios element-plus vue-router4 pinia目录规划上我习惯把所有页面组件放在src/views下按业务模块分文件夹src/views/ LoginPage.vue 登录页 RegisterPage.vue 注册页 HomePage.vue 图书列表首页 BookDetailPage.vue 图书详情页 MyRecords.vue 我的借阅记录 admin/ AdminDashboard.vue 管理后台框架 BookManage.vue 图书管理 BorrowManage.vue 借还管理 ReaderManage.vue 读者管理 src/api/ request.js axios实例封装 auth.js 认证相关接口 books.js 图书相关接口 records.js 借阅记录相关接口 src/router/ index.js 路由配置含守卫 src/store/ user.js 用户状态这种按业务模块划分的目录结构比按组件类型划分更适合项目中期扩展。等系统做到第三四个版本要加统计报表的时候新增一个Report.vue和reports.js就行了不会把原来的代码搅乱。5.2 axios封装与API对接axios不能直接在组件里到处new实例不然拦截器、统一错误处理根本没法维护。我用一个独立文件把axios封装成统一出口import axios from axios import { ElMessage } from element-plus import router from /router const request axios.create({ baseURL: /api, timeout: 10000 }) // 请求拦截器自动附带token request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) // 响应拦截器统一处理业务码和登录过期 request.interceptors.response.use( response { const res response.data if (res.code ! 0) { ElMessage.error(res.msg || 请求失败) return Promise.reject(new Error(res.msg)) } return res }, error { if (error.response error.response.status 401) { ElMessage.error(登录已过期请重新登录) localStorage.removeItem(token) router.push(/login) } else { ElMessage.error(网络异常请稍后重试) } return Promise.reject(error) } ) export default request注意baseURL: /api这种写法是把请求代理给后端后面在Vite配置文件里要设置proxy不然开发环境下前端8080端口请求8000端口直接跨域。Vite配置如下// vite.config.js export default defineConfig({ server: { proxy: { /api: { target: http://localhost:8000, changeOrigin: true } } } })这个配置意味着前端把所有/api开头的请求转发到后端8000端口浏览器里看到的请求是同源的跨域问题在开发阶段就绕开了。5.3 图书检索与分页展示图书列表页面是整个系统访问量最大的页面我在设计上做了三点搜索、分类筛选、分页。搜索关键字匹配书名和作者分类筛选按图书分类下拉选择分页用Element Plus的el-pagination组件。Vue组件里比较关键的代码如下template div classbook-list el-input v-modelsearchKeyword placeholder搜索书名或作者 clearable inputhandleSearch / el-select v-modelselectedCategory placeholder按分类筛选 changeloadBooks el-option label全部 value / el-option v-forc in categories :keyc.id :labelc.name :valuec.id / /el-select el-row :gutter16 el-col :span6 v-forbook in bookList :keybook.id el-card click$router.push(/book/${book.id}) img :srcbook.cover_url classbook-cover alt封面 / h4{{ book.title }}/h4 p{{ book.author }}/p p可借数量{{ book.available_count }} / {{ book.total_count }}/p /el-card /el-col /el-row el-pagination v-model:current-pagecurrentPage :page-sizepageSize :totaltotal current-changeloadBooks / /div /template搜索功能我这里用的是防抖用户停止输入300毫秒后才发请求不然每敲一个字母就打一次后端会把接口打爆。防抖的实现不需要引入额外库写一个简单的定时器即可handleSearch() { clearTimeout(this.timer) this.timer setTimeout(() { this.currentPage 1 this.loadBooks() }, 300) }5.4 管理员操作面板的交互设计管理后台的交互重点是快。图书管理员一天可能要处理几十次借还操作如果每借一本书都要打开好几个页面填表单效率会很差。我的做法是在主界面放一个借还处理区域管理员输入读者借阅卡号或手机号系统自动带出读者信息及其当前在借书籍然后通过扫码枪或手动输入图书ISBN完成借书还书则直接点记录列表里的归还按钮。这个交互里面隐藏了一个后端接口通过借阅卡号查读者。接口返回读者的基本信息、当前借了几本、有没有逾期。管理员借书时不需要让读者输密码这是管理员代操作和读者自助操作的根本区别所以在权限上要单独给一个IsAdminUser。还书操作还有一个容易踩的细节如果同一本书被同一个读者借了多次归还时不能只凭图书id找记录必须带上借阅记录id否则系统不知道你还的是哪一次。我前端传record_id管理员点击某条具体的借阅记录后面的归还这样就不会混淆。6. 联调和部署阶段的高频问题排查实录6.1 跨域请求被拦截前后端分离开发时最常遇到的报错就是浏览器控制台里出现Access to XMLHttpRequest at http://localhost:8000/api/... has been blocked by CORS policy。原因很好理解前端运行在http://localhost:8080后端在http://localhost:8000浏览器认为这是两个不同源的服务出于安全策略拦截了跨域请求。开发阶段我推荐用我在5.2节说的Vite代理方案因为最省事。如果前端部署后和后端不在同一个域名下就必须要后端开启CORS。django项目安装django-cors-headers在settings.py里配置INSTALLED_APPS [ ... corsheaders, ] MIDDLEWARE [ corsheaders.middleware.CorsMiddleware, ... ] CORS_ALLOWED_ORIGINS [ http://你的前端域名, ]一个常见问题是部署到线上后忘了前端请求携带token导致跨域预检请求失败。记得在后端配置CORS_ALLOW_HEADERS里包含authorization否则前端请求头里带token的话跨域会被拦。6.2 借书日期显示差一天的时区问题我在测试阶段发现一个诡异的现象借书成功后页面显示的借书日期总是比实际时间早8个小时。排查后确认是时区设置问题。django默认的TIME_ZONE是UTC如果你的USE_TZ True数据库里存的是UTC时间前端拿到后如果不做转换直接显示就会比北京时间少8小时。解决方案有两个根据项目部署方式二选一项目只在国内用直接设置TIME_ZONE Asia/Shanghai和USE_TZ Falsedjango存的就是本地时间前端拿到就直接显示简单粗暴。项目需要服务多时区用户数据库统一存UTCUSE_TZ True前端拿到时间后使用new Date(value).toLocaleString(zh-CN)这样的方法在浏览器本地时区显示。图书借阅系统一般只在国内某个社区运行我推荐方案一省掉很多麻烦。但要注意改了USE_TZ之后原来已经写入数据库的UTC时间不会自动转换需要跑一次数据修正脚本。6.3 Django静态文件加载不出图片图书封面图片加载不出来是这类系统上线初期的高频问题。这里要分清楚两个概念开发环境的静态文件访问和生产环境的静态文件服务。开发环境里如果你在项目根目录建了media/文件夹用来存放上传的封面图片必须在settings.py里这样配置MEDIA_URL /media/ MEDIA_ROOT BASE_DIR / media然后在根路由urls.py里手动加上静态文件服务的路由from django.conf import settings from django.conf.urls.static import static urlpatterns [ ... ] static(settings.MEDIA_URL, document_rootsettings.MEDIA_ROOT)很多新手只配置了MEDIA_ROOT忘了加最后那行static()结果图片地址404。还有个细节模板或者前端请求图片的地址前面一定要拼接MEDIA_URL。前后端分离项目里前端直接请求http://后端域名/media/covers/xxx.jpg这个地址是后端静态文件服务返回的。生产环境里Nginx要单独配置一个location指向media目录不能依赖django处理静态文件这一点放到部署部分说。6.4 上线部署waitress、gunicorn与Nginx的配合项目做完之后要上线不能用开发服务器python manage.py runserver跑生产这个服务器既慢又不安全。django项目生产环境我一般用gunicornLinux服务器或waitressWindows服务器作为WSGI服务器。flask项目同理。以gunicorn为例启动命令gunicorn library_system.wsgi:application -b 0.0.0.0:8000 --workers 3--workers 3表示开3个工作进程具体数量参考服务器CPU核心数一般是2*CPU核心数1。worker数量不是越多越好因为每个worker都会申请数据库连接和内存。在生产拓扑上Nginx承担两个职责一是反向代理把外部80端口的请求转发给内部8000端口的gunicorn二是托管Vue打包后的静态文件。Vue项目在本地执行npm run build后生成dist目录把整个dist目录上传到服务器然后Nginx配置指向它server { listen 80; server_name your.domain.com; # Vue静态文件 location / { root /var/www/library-web/dist; index index.html; try_files $uri $uri/ /index.html; # 前端路由history模式必须加这一行 } # 后端API接口 location /api/ { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } # 上传的图书封面 location /media/ { alias /opt/library_system/media/; } }注意try_files $uri $uri/ /index.html这一行Vue Router如果开了history模式刷新非首页路由时如果没有这个配置会直接404。这是个非常经典的部署坑很多项目本地跑得好好的一部署刷新就白屏问题就出在这里。不想处理这个规则的话Vue Router改用hash模式可以规避但URL会多一个#号不太好看。部署踩坑之后我一般还会写一个简单的启动脚本把gunicorn启动、日志输出、进程守护都包进去配合systemd做成服务。这样服务器重启之后系统能自动起来不用每次手动敲命令公益图书馆的志愿者也不用懂Linux命令才能重启系统。我在实际交付这个项目之后最大的体会是图书借阅系统虽然业务不复杂但它把前后端分离、数据库设计、认证权限、并发控制、部署运维这些web开发的核心环节都涵盖全了是很好的练手项目。如果你是学生或者准备转行的开发者照着这个项目完整做一遍比看十本教程都管用。只要把核心借还流程的数据一致性和遇到问题时的排查思路吃透这套系统不管换成django还是flask、Vue2还是Vue3都能从容应对。