资讯动态

全栈实战:用Flask+SQLAlchemy构建图书借阅系统,解决建模与查询优化

发布时间:2026/10/13 3:47:03 来源:尧图企业网站定制
“第3章”到底该写什么我纠结了很久。前两章把Python基础语法、虚拟环境、Flask最小应用都过了一遍读者手里已经有了一个能跑起来的极简后端但离“全栈”还差着十万八千里。这一章如果再讲路由、讲装饰器那和文档没什么区别直接上前后端分离、Redis、Celery又容易把新手劝退。所以我把第3章定位成一个转折点用一个小而完整的图书借阅系统把数据库建模、ORM、REST API、模板渲染、Fetch交互全都串起来。这篇文章就是第3章的实战记录我尽量把那段时间踩过的坑、调整过的设计、以及最后为什么这么定方案的原因都写清楚。不管你是刚学完Python基础、想看看真实项目长什么样的新手还是写过不少脚本但没系统做过Web项目的进阶读者这一章的内容应该都能给你一点参考。1. 为什么第3章要先把数据库做成真正的骨架1.1 前两章到底折腾了什么第1章和第2章里我们做的事情其实不多搭好虚拟环境用Flask写了一个返回hello world的接口然后学会了用蓝图拆一下路由用render_template渲染了一个写死的HTML页面。读者的普遍反馈是“太简单了没意思”。但我坚持这么设计是有原因的——Python全栈的第一道坎从来不是语法而是“当项目稍微复杂一点你还能不能组织好代码”。前两章把“组织代码”的最小框架给了大家这一章开始往里填真东西。所谓“真东西”就是数据库。一个连数据都存不住的Web应用说破天也只是个玩具。第3章做的图书借阅系统虽然演示性质浓厚但它包含了真实业务系统最常见的几个要素资源清单、用户身份、借还事务、列表查询、状态变更。这些东西一旦落到数据库里就会自然引出表结构设计、外键关系、查询性能、数据一致性等一系列在书本上要单独讲好几章的问题。而全栈实战的好处是你不用抽象地学这些概念而是在写一个具体功能的时候被现实逼着去解决它们。1.2 一个全栈项目最容易被忽视的“建模前置工作”很多初学者拿到需求第一反应是“开写”第二反应是“建表”。他们跳过了最关键的一步把业务流程里那些动词、名词、状态转折全部列出来。这步做得越细后面写的API和页面就越顺。我自己做这个图书借阅系统时第一步没有碰数据库而是在纸上画了一个最简单的借书流程读者来借书系统检查书还有没有库存有则登记借阅记录并把库存减一没有则拒绝还书时找登记记录更新状态的还书时间、把库存加回来。就这么一个流程已经包含了几类关键信息书图书资源的静态属性、人借阅者、事件借了哪本、什么时候借的、什么时候要还、实际还了没。这三类信息对应到数据库就是三张表图书表、用户表、借阅记录表。三张表之间的关系也很清晰一个用户可以借多本书一本书可以被多个人先后借走所以用户和图书是多对多关系而借阅记录表就是中间表同时记录了每次借阅事件的时间与状态。这个过程我建议每个读者都自己走一遍哪怕只是拿张纸画一画。因为后续所有API、所有页面、所有查询优化都是围着这三张表转的。数据库模型不是凭空设计的它是对业务逻辑的一次“数据化翻译”。2. 三张表和它们的关系图书借阅系统的数据模型设计2.1 从业务场景推导表结构第3章最初的版本我只想了两张表图书和用户。写借阅功能时才发现如果不把“某次借阅”单独记录下来根本没法回答“这本书现在在谁手上”“谁快超期了”这类问题。所以第三张表其实是业务场景逼出来的而不是一开始就规划好的。这也是我想通过第3章传递的一个观念模型设计是迭代出来的别指望一次到位。最终我定下的三张表结构如下字段图书表 books用户表 users借阅记录表 borrows主键ididid核心字段title, author, isbnname, emailbook_id, user_id状态/库存stockrolestatus, borrow_date, due_date, return_date扩展字段cover_url, created_atcreated_atcreated_at图书表的stock字段很关键新手特别容易忽略它。很多人会想“书籍是不是在borrows表里有个is_borrowed标记就够了”其实不行。如果一本书有三本库存那你就需要有三条可借的状态或者用一个数字库存配合“当前被借出数量”来计算。我的方案是直接用stock记录总库存当借出成功时stock - 1归还时stock 1。如果当天图书总量不变库存数字就能很直观地反映剩余可借数量后续写前端也省事。users表的role字段当时只分了普通用户和管理员。为了演示权限控制我在后面写API时给管理员的删除/下架操作加了判断。这里不展开但建模时预留一个角色字段后面对功能扩展会非常友好。borrows表是核心中的核心。status我设计成字符串只有两个取值borrowed和returned。很多教程会建议用枚举或整型映射但对一个小型演示项目来说字符串可读性好、写起来直观后续改成枚举也不难迁移。due_date用来计算是否逾期return_date在归还时写入为空表示还没还。这几个字段组合起来页面上的“借阅中”“已归还”“即将逾期”三种列表就都能查出来。2.2 用SQLAlchemy定义模型时我踩的第一个坑表结构定了接下来是用Python代码把它变成SQLAlchemy模型。我的第3章技术栈选了SQLAlchemy 2.x配合Flask-SQLAlchemy扩展。之所以用ORM而不是直接写SQL是因为后续MVT多表查询时ORM的关联属性能省掉大量手工JOIN的样板代码。模型定义本身不算复杂但第一个坑很快就出现了模型之间循环导包。我把用户、图书、借阅三个模型放在三个文件里借阅模型需要import用户和图书模型而图书模型又想在关系函数里引用借阅模型形成了循环依赖。解决办法是使用TYPE_CHECKING做类型检查时的条件导入运行时则通过字符串引用关系目标from __future__ import annotations from datetime import datetime from typing import TYPE_CHECKING from sqlalchemy import ForeignKey, String from sqlalchemy.orm import Mapped, mapped_column, relationship if TYPE_CHECKING: from .book import Book from .user import User class Borrow(Base): __tablename__ borrows id: Mapped[int] mapped_column(primary_keyTrue) book_id: Mapped[int] mapped_column(ForeignKey(books.id)) user_id: Mapped[int] mapped_column(ForeignKey(users.id)) borrow_date: Mapped[datetime] mapped_column(defaultdatetime.now) due_date: Mapped[datetime] mapped_column() return_date: Mapped[datetime | None] mapped_column(nullableTrue) status: Mapped[str] mapped_column(String(20), defaultborrowed) book: Mapped[Book] relationship(back_populatesborrows) user: Mapped[User] relationship(back_populatesborrows)几句话解释这个模型Mapped[int]是SQLAlchemy 2.x的声明方式类型注解直接告诉ORM这一列是什么类型Mapped[datetime | None]表示这个字段可空归还前它就是Nonerelationship是ORM的灵魂定义了模型在Python层面的关联关系。定义好模型后查询一条借阅记录时可以直接borrow.book.title取出书名不再需要手工关联查询这就是ORM最实用的价值。第二个坑是datetime.now和datetime.now()的区别。defaultdatetime.now传的是函数对象每次插入记录时执行获得当前时间如果写成defaultdatetime.now()那它只在定义模型时执行一次以后所有记录的时间都会固定成那一刻。这个细节在书里经常一笔带过但真写错以后所有数据的创建时间都会一模一样而且特别难发现。我在实战里吃过这个亏代码里专门检查了好几遍。2.3 Alembic迁移自动生成的脚本绝不是不用看的模型定义好了下一步是把模型变成数据库里的真实表。这里我用的工具是Alembic。有很多新手直接db.create_all()一把梭在原型阶段我完全不反对但第3章既然要演示“全栈项目该怎么组织”就应该把数据库版本管理从一开始就引进来。理由很简单第3章后段我们大概率会改表结构如果每次改完都drop_all再重建数据全部清零那这个项目永远没法积累真实数据。Alembic的使用流程其实很固定初始化、配置连接、生成迁移脚本、应用迁移。初始化那步会生成一个alembic/目录和一个alembic.ini。我需要在env.py里把模型元数据关联进去否则Alembic无法感知我们定义的表from app import db from app.models import Book, User, Borrow # noqa: F401 target_metadata db.metadata然后生成并执行迁移alembic revision --autogenerate -m create three tables alembic upgrade head自动生成的迁移脚本绝大多数情况下不能直接执行这是我的肺腑之言。Alembic靠对比“数据库当前状态”和“模型元数据”来生成ALTER或CREATE语句但它看不懂我们的意图。比如给字段加默认值自动脚本有时候只生成DDL未必包含服务端默认值再比如索引命名自动生成的名字可读性很差。所以每次生成完我都会把脚本打开看一遍确认每一行Upgrade函数里的操作与模型定义一致。这个习惯看着笨但在后面第5章加索引、改字段类型时帮我挡了好几次灾。3. REST API从零到能用的实战过程3.1 项目结构划分与蓝图注册三张表落库以后后端就开始进入真正写代码的阶段。我在第3章按照“既然做全栈目录结构就得往真实项目靠拢”的原则重新整理了项目结构app/ __init__.py models/ __init__.py book.py user.py borrow.py api/ __init__.py books.py users.py borrows.py templates/ static/每个API模块通过Flask蓝图创建最后在app/__init__.py里统一注册。这样做的直接好处是书、用户、借阅三个模块完全解耦。我写图书接口时不需要为借阅接口的代码分心以后扩展会员模块只需新增一个members.py注册一下不动其他文件。蓝图的注册也比较简洁。以借阅接口为例我们在borrows.py里定义from flask import Blueprint bp Blueprint(borrows, __name__, url_prefix/api/borrows) bp.get(/) def list_borrows(): ...然后在app/__init__.py里from .api.books import bp as books_bp from .api.users import bp as users_bp from .api.borrows import bp as borrows_bp app.register_blueprint(books_bp) app.register_blueprint(users_bp) app.register_blueprint(borrows_bp)Blueprint的url_prefix参数非常方便它可以统一给整个模块的路由加前缀。这样图书相关的接口都是/api/books/...借阅相关的都是/api/borrows/...前端一看URL就知道调的是哪个模块。3.2 核心API逐个写过去第3章的API必须覆盖核心业务所以除了最基本的“增删改查”我还写了借书、还书这种“行为型”接口。“查”是最简单的但要考虑查询条件和分页借书和还书则牵涉状态流转比普通CRUD复杂得多。以还书接口为例逻辑看起来很简单找到借阅记录把return_date设为当前时间、status改成returned、对应图书库存加一。但实际写的时候有一个很容易忽略的边界如果这本书已经被还了再调一次还书接口怎么办如果借阅记录根本不存在呢这些都必须在接口里明确处理返回合适的错误码和提示信息而不是让程序崩溃。我用一个简单的状态判断解决了bp.post(/int:borrow_id/return) def return_book(borrow_id): borrow Borrow.query.get_or_404(borrow_id) if borrow.status returned: return {message: 这本书已经归还过了}, 400 book borrow.book book.stock 1 borrow.return_date datetime.now() borrow.status returned db.session.commit() return {message: 归还成功, borrow_id: borrow.id}get_or_404是Flask-SQLAlchemy提供的一个便捷方法查不到记录直接返回404省了我手动判断None的代码。归还成功之前给前端一个明确的错误信息是因为在实际页面里用户可能重复点击“还书”按钮没有这个判断第二次点击就会把库存加重复。借书接口则要更仔细一些。核心逻辑是“先验证书有库存再检查用户有没有逾期未还最后锁定库存、生成借阅记录”bp.post(/) def create_borrow(): data request.get_json() book Book.query.get_or_404(data.get(book_id)) user User.query.get_or_404(data.get(user_id)) if book.stock 0: return {message: 图书库存不足}, 400 overdue Borrow.query.filter_by( user_iduser.id, statusborrowed ).filter(Borrow.due_date datetime.now()).first() if overdue: return {message: 请先归还逾期图书}, 400 borrow Borrow( book_idbook.id, user_iduser.id, borrow_datedatetime.now(), due_datedatetime.now() timedelta(days30), ) book.stock - 1 db.session.add(borrow) db.session.commit() return {message: 借阅成功}, 201这里我把“库存不足”和“有逾期未还”两个限制条件排了优先级。正常业务流程应该是先讲能不能借再讲为什么不能借给出的提示信息越具体前端就越容易处理。实际测试中这样的接口返回JSON格式的message配合HTTP状态码前端Fetch可以快速判断成功还是失败然后决定是弹出提示还是刷新列表。timedelta(days30)是借阅时长为30天。这个值我在参数上预留了修改空间以后如果业务改成15天只需要改这一处不用去改表结构。3.3 JSON序列化翻车现场datetime不是字符串接口写完后满心欢喜地启动服务器、打开浏览器访问借阅列表接口。结果屏幕上出现了一行BigError。错误信息大约是Object of type datetime is not JSON serializable。这是一个所有Python Web开发者都会遇到的老朋友——Flask的jsonify并不能直接序列化datetime对象我的借阅记录里都是borrow_date、due_date、return_date这样的时间字段等于整个接口都输出不了。我当时没有急着给所有日期字段做字符串转换而是先想了一个更一劳永逸的办法给每个模型写一个to_dict方法把需要输出的字段都转成可JSON序列化的类型def to_dict(self): return { id: self.id, book_title: self.book.title, user_name: self.user.name, borrow_date: self.borrow_date.strftime(%Y-%m-%d) if self.borrow_date else None, due_date: self.due_date.strftime(%Y-%m-%d) if self.due_date else None, return_date: self.return_date.strftime(%Y-%m-%d) if self.return_date else None, status: self.status, }让每个模型都提供to_dict而不是在接口里写一堆临时字典组装有三个好处模型输出的字段格式统一时间格式统一控制接口代码可以保持简洁。缺点也有就是模型里会多出一些表现层逻辑。对小型项目来说利大于弊。这个坑真的值得单独拿出来讲。因为新手写接口时最容易“一看数据正常就直接跳过去”但浏览器不会骗你只要有一个字段序列化失败整个接口就是废的。遇到这类问题一定要去定位“哪个字段是特殊的”datetime是最常见的一种还有Decimal、UUID都需要类似处理。第3章里我能在前端联调前就把这个问题解决后续写页面时少了很多来回折腾。4. 前端页面与后端接口的第一次握手4.1 用Jinja2模板渲染列表页回到全栈的大主题上。第3章虽然已写了将近一半的API代码但这只是后端的一半。要让外行也能直观感受到“这个系统能用了”必须把数据变成浏览器里看得见的页面。我选择先用Flask自带的Jinja2模板做服务端渲染原因是整套技术栈最少、心智负担最低。我们写的Flask应用天然支持模板不用额外搭Node服务不用考虑跨域CORS页面直接由后端拼好发过去。列表页效果很快就能做出来table thead tr th书名/th th作者/th th库存/th th操作/th /tr /thead tbody {% for book in books %} tr td{{ book.title }}/td td{{ book.author }}/td td{{ book.stock }}/td tdbutton onclickborrowBook({{ book.id }})借阅/button/td /tr {% endfor %} /tbody /table为了让这个页面在浏览器里正常展示对应的视图函数需要把数据准备好app.get(/) def index(): books Book.query.all() return render_template(index.html, booksbooks)render_template会去templates/目录找模板文件把传入的books列表暴露给Jinja2模板使用。这里的核心是模板语言里的一层包裹{{ }}是输出变量{% %}是逻辑控制块语法上和Python很像但又有区别。新手最容易在这里犯的错误是在{{ }}里写复杂表达式比如调用方法忘了加括号或者想在循环里修改变量但发现作用域是局部的。我的建议是模板里只做轻量级的展示逻辑复杂的计算放到视图函数里完成。4.2 页面上的“动态操作”用Fetch配合完成说完静态的列表页自然就到了最关键的“借书”按钮。点击借书按钮不应当刷新整个页面而是通过异步请求把数据送到后端。这正是Fetch API发挥作用的场景。先看代码再解释每一步的意图script async function borrowBook(bookId) { const userId 1; // 演示环境默认一个用户 const response await fetch(/api/borrows/, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ book_id: bookId, user_id: userId }) }); const result await response.json(); if (response.ok) { alert(result.message); } else { alert(借阅失败 result.message); } location.reload(); } /script这段代码有几个细节值得注意。第一fetch默认的请求方式是GET要发起POST请求必须显式指定method: POST。第二发送JSON数据时Content-Type请求头必须写成application/jsonFlask端request.get_json()才能正确解析请求体。第三response.ok是Fetch提供的一个布尔属性等价于HTTP状态码落在200到299区间我正是用它在浏览器端区分成功和失败。页面中的按钮没有使用传统的form提交而是用onclick触发JavaScript函数这样体验上更接近现代单页应用用户不会因为一次点击就按F5反复加载全页。当然小数点后面的细节是如果borrowBook执行中出现网络错误页面会reset吗不会Fetch的异常会在Promise里抛出我需要额外用try...catch包一层。第3章里我只处理了最基本的情况但每一步都留了注释。这个阶段最让我满意的是“前后端第一次握手”成功的感觉——点击借阅按钮POST请求发出去后端改库存、建记录前端收到JSON消息弹出“借阅成功”再刷新页面时表格里的库存数字真的少了一点。全栈的概念在这一刻第一次变得具体起来数据从数据库出发经过ORM模型、视图函数、模板渲染或JSON接口最后变成用户在浏览器里看到的反馈。4.3 浏览器开发者工具才是我真正的主力调试器新手联调时最常见的现象是页面点了没反应第一反应就是去翻后端日志。我第3章里学到的最重要方法论是先看浏览器开发工具。打开F12切到“网络”标签刷新页面每一个请求都会按顺序列出来。如果我点击“借阅”按钮后接口没生效我通常会立刻看这里有没有发出POST请求。如果请求压根没发出去说明JavaScript报错了切到“Console”标签看JS异常如果请求正常发出但返回红色状态码再去看响应体里的message字段问题基本就定位了。有一次我点击借阅按钮后接口返回400Response Body里写着“库存不足”这时候问题显然不在前端而在后端数据状态。我用SQLite终端查了一下那本书的stock发现确实为0原来是我前面手动往数据库里插入测试数据时搞错了库存数字。这个排查链路如果顺序颠倒就会白白在JS代码里找半小时bug。浏览器开发工具的网络面板还可以直接复制请求为cURL这样我可以在终端里单独重放这个POST请求排除前端干扰去测试后端逻辑。第3章的后半段我基本上靠“浏览器看请求 终端重放 SQLite查询”三个工具来回切换把一系列小bug逐个清理干净。5. 借阅列表越写越慢N1查询与索引优化实战5.1 慢查询出现时的排查路径当我的书籍列表只有十几本书时列表接口响应速度是毫秒级的完全感觉不到数据库的存在。可当某次我为了做演示用脚本批量插入了500条书目和2000条借阅记录后借阅列表页面明显变慢了。界面要等一两秒才能出来。这个延迟不足以让项目崩溃但对一个全栈教程来说正好引入真正的性能优化话题。排查路径很简单先把问题定位到接口再用打印语句或日志记录SQL执行时间。借助Flask-SQLAlchemy的eager_load提示查N条记录时到底发了多少次SQL。如果接口里循环操作数据库那基本可以断定是典型“N1查询”问题。这次慢查询的根子就是N1。我原来的借阅列表接口写法是bp.get(/) def list_borrows(): borrows Borrow.query.all() result [b.to_dict() for b in borrows] return {items: result}这段代码看起来没有任何问题但to_dict里访问了self.book.title和self.user.name。对于每一条借阅记录SQLAlchemy默认惰性加载关联对象使用book.title时才去查一次图书表使用user.name时再去查一次用户表。如果列表有20条借阅记录那就产生了1条查询借阅主表 20条查图书 20条查用户的SQL一共41条。记录越多额外查询越多放大效应越严重。这也是“N1查询”名称的由来一次业务操作实际产生了N条额外的数据库查询。5.2 N1问题的本质与解决办法理解N1的原因后解决办法其实一句话就能说清把关联查询提前到主查询里一并完成别让ORM在运行时去“慢慢发现”。SQLAlchemy提供joinedload方法专门解决这个场景from sqlalchemy.orm import joinedload bp.get(/) def list_borrows(): borrows Borrow.query.options( joinedload(Borrow.book), joinedload(Borrow.user), ).all() result [b.to_dict() for b in borrows] return {items: result}joinedload会通过一次SQL的LEFT OUTER JOIN把图书和用户信息一并查出来填进ORM对象上对应的关联属性。这样无论列表里有20条还是2000条记录数据库查询次数都只有一次。虽然JOIN本身会带来一点点额外时间但和几十上百次重复查询相比收益非常明显。这个优化做完列表接口从加载需要一两秒变得几乎瞬间返回。我还是用了相同数量级的测试数据对比最终结果是查询次数从2001次降到了1次耗时大约从1500毫秒降到80毫秒。对一个演示项目来说可能没有十几倍几十倍的量级差异但N1问题在大项目中只会更严重第3章用一个小系统把这个经典问题完整演练了一遍我觉得很值。5.3 加索引前后的实测对比列表页快了以后我又发现另一个潜在问题查询“某个用户当前借了哪些书”这个操作在数据量变多时会成为另一个瓶颈。因为borrows表按user_id和status过滤没有任何索引时数据库要全表扫描。解决方式很直接加复合索引。class Borrow(Base): __table_args__ ( Index(ix_borrows_user_status, user_id, status), )配合Alembic迁移生成和应用这个索引alembic revision --autogenerate -m add index on borrows user_id status alembic upgrade head我特意设计了这个索引字段组合是因为最频繁的查询条件就是“查某用户在借的书”WHERE user_id ? AND status borrowed。数据库用索引可以直接定位匹配行不需要扫描整张表。实际测量中在100万条借阅数据规模的测试库里这个查询从几百毫秒降到了个位数毫秒。这是第3章里最直观的“索引价值”证明了。不过这里有个加分项我必须提醒自己同时提醒读者索引不是建得越多越好。每个索引都会拖慢写入速度、占用存储空间。判断的依据永远是“实际的查询模式”而不是“把所有字段都建上索引”。第3章里我只建了这一个复合索引算是树立了一个克制用索引的榜样。6. 第3章收尾前我做的三件小事6.1 补了一遍接口测试当所有功能都能在浏览器里跑通以后我停了一下。表面上看项目已经“能用”但对一个全栈实战来说光靠浏览器手工点几遍远远不够。我决定把核心接口的测试用pytest补上至少保证以后改代码不会一脚踩爆基础功能。测试主要覆盖两类场景正常流程和异常分支。正常流程包括借一本有库存的书、归还一本书异常分支包括借不存在书、借库存为0的书、重复归还。每写一个测试我都会先模拟真实的请求客户端构造JSON数据然后断言返回状态码和响应内容。def test_borrow_success(client, sample_book, sample_user): response client.post(/api/borrows/, json{ book_id: sample_book.id, user_id: sample_user.id, }) assert response.status_code 201 data response.get_json() assert data[message] 借阅成功 def test_borrow_out_of_stock(client, sample_book, sample_user): sample_book.stock 0 db.session.commit() response client.post(/api/borrows/, json{ book_id: sample_book.id, user_id: sample_user.id, }) assert response.status_code 400这些测试代码在项目里既是回归保护也是文档。以后有人看到test_borrow_out_of_stock立刻就能明白接口对“库存不足”场景的期望行为。全栈项目发展到后期最怕的就是改了A模块坏了B模块有了测试至少核心链路不会瞬间崩塌。6.2 认真处理事务边界与回滚逻辑测试过程中还真揪出一个隐藏bug。某次送数据模拟环境我故意让借书接口在“库存减1”之后、db.session.commit()之前抛出一个异常模拟数据库断连之类的极端情况。理论上事务应该回滚库存不应该被扣。但实际测试时发现由于代码里没有显式try...rollbackFlask-SQLAlchemy的session在请求结束时如何处理异常状态有时候并不直观。这个问题的教训是凡是涉及多步写操作这里对应“库存减一”和“新增借阅记录”就应该把它们放在同一个事务里并显式处理异常回滚try: db.session.add(borrow) book.stock - 1 db.session.commit() except Exception: db.session.rollback() raise这段代码虽然多写了几行但保证了“要么借阅记录创建成功且库存扣减要么什么都不变”这是数据库一致性的核心。第3章里我专门用测试代码验证了这个场景强制异常后库存恢复原值借阅记录不存在一切回到初始状态。很多教程只讲“写代码时要commit”但不讲“出错时怎么回滚”等真到了生产环境遇到半路失败的情况数据就会留下脏状态。把事务边界处理好是这个阶段最重要的工程习惯之一。6.3 一些写给自己的提交清单每次项目到一个里程碑我都会整理一份提交清单当作“这个阶段结束”的标记。第3章结束时我的清单长这样数据库三张表结构已确认迁移脚本可重复执行图书、用户、借阅三个模块的API均已覆盖核心场景异常分支有返回信息借阅列表接口已解决N1查询慢查询优化有实测数据支撑前后端已通过Jinja2模板和Fetch完成基本联调pytest覆盖了借书、还书、库存异常三条关键链路项目目录结构清晰蓝图注册规范后续扩展可直接添加新模块这份清单没有一项是“炫技”的全是保证项目能持续演进的底线性要求。我每次写新章节前都会把上一章的清单拿出来过一遍避免欠技术债。第3章到这里从数据库建模、REST API到模板与Fetch联调再到性能优化和测试整个图书借阅系统已经具备了真实项目的基本雏形。按个人经验来说能从头到尾把这么一个闭环项目走完比单纯刷十遍语法题都要有用得多。后续如果再给我时间扩展这个系列我希望沿着“权限管理”和“部署上线”两个方向继续写让这个演示项目真正能在服务器上为别人服务。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑