ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于SpringBoot与HTML的书城阅读器系统开发实战:从选型到部署

基于SpringBoot与HTML的书城阅读器系统开发实战:从选型到部署 接到“基于SpringBoot和HTML的书城阅读器系统”这个项目的时候我的第一反应是这不就是典型的课程设计/毕业设计题吗但真正动手做完一遍之后我才发现越是这样看起来“普通”的题目越能检验一个开发者对Web开发全流程的理解。SpringBoot负责后端服务和接口HTML负责前端展示两者一组合既要打通数据链路又要把“阅读器”这种偏体验的功能做像样里面藏着的细节比想象中多得多。这篇文章就围绕这个书城阅读器系统把我从选型、拆模块、建表、写接口、做页面到部署排坑的完整过程写出来。内容包括为什么用SpringBoot加原生HTML而不是前后端分离、书城阅读器跟普通商城系统在数据设计上的关键差异、阅读进度和书签这些核心功能怎么落地、购物车下单的闭环怎么打通以及我在打包部署时踩过的一串坑。如果你也在做类似的SpringBoot项目或者正准备用这套技术栈练手这篇文章应该能帮你少走不少弯路。1. 为什么是SpringBoot加原生HTML一套书城阅读器的选型复盘先聊聊技术选型。现在一提起Web项目很多人默认就是Vue加SpringBoot的前后端分离再加一套微服务、Redis、消息队列……但“书城阅读器”这个场景用SpringBoot加原生HTML反而是更务实的组合。不是说分离不好而是要对题。1.1 SpringBoot在这套系统里到底省了什么SpringBoot最大的价值在于“约定大于配置”。回想以前用SpringMVC搭项目光是一个web.xml、Spring配置、数据源配置就能折腾一整天而且每个项目都要重复一遍。SpringBoot把这些都收进了自动配置里我只需要在pom.xml里引入依赖写一个application.yml项目就能跑起来。具体到书城阅读器这个系统SpringBoot帮我解决了几件很实际的事内嵌Tomcat不用单独装Tomcatmvn spring-boot:run直接启动部署时一个jar包搞定。自动配置数据源配好数据库连接信息后MyBatis-Plus直接能用不用写一堆DataSource、SqlSessionFactory的Bean。统一异常处理和JSON返回配合RestControllerAdvice接口报错时能返回统一格式的JSON前端好处理。依赖管理省心SpringBoot的Parent POM统一管理版本号我不用去纠结MyBatis-Plus和SpringBoot版本兼容的问题。这一点对做课设、毕设尤其重要因为时间应该花在业务功能上而不是花在环境配置上。1.2 为什么前端不选Vue而用HTML直出可能有朋友会问既然SpringBoot都上了前端为什么不配Vue我的考虑是这样的第一阅读器系统的核心是“文本内容的展示和阅读节奏”不是复杂交互。它需要的是章节列表、正文展示、上一章/下一章、进度记忆这些用原生HTML加JavaScript完全可以搞定而且页面加载更快。引入Vue之后要处理构建工具、路由、状态管理学习成本和工程复杂度都上来了。第二标题就叫“基于HTML的书城阅读器系统”用原生HTML做前端正好贴合这个定位。我在实现时把页面放在static目录下用fetch调用后端REST接口页面和数据完全分离但又没有引入Node.js那一套构建链。对读者来说打开即用、看懂代码的成本也低。第三纯HTML页面配合后端JSON接口在部署时特别省事。打好的Jar包自带静态资源丢到服务器上就能跑不用额外配Nginx托管前端、解决跨域问题。对于单机部署的项目这简直不要太舒服。1.3 阅读器场景对技术栈的特殊要求书城阅读器跟普通商城系统有一个明显区别用户的核心动作不是“买”而是“读”。这意味着系统必须有流畅的阅读链路——找到书、打开目录、读章节、记住进度、下次接着读。这种场景对后端的要求其实不高但对前端页面和数据结构的要求比较特殊。比如章节内容要按顺序组织阅读进度要能按“用户图书”维度快速存取书签要能定位到具体章节。这些需求用原生HTML完全能承载反而比堆前端框架更直接。因为后期做移动端适配也方便HTML页面天然支持响应式布局加几段媒体查询就能让手机端也获得不错的阅读体验。这也是我当时坚持“HTML直出”的另一个原因。2. 功能模块与架构边界书城不只是“书架看书页”书城阅读器听起来是个小系统但真要落地面向的场景还挺完整的。它至少要回答两个问题用户怎么找到并买到书用户怎么舒服地读完书围绕这两个问题我把系统拆成了四个相互独立又彼此协作的模块。2.1 用户与图书管理模块用户模块负责注册、登录和基本信息管理。登录用的是Session用户登录后把userId存进Session后续接口通过Session拿当前用户。密码必须加密存储我用的是MD5加盐虽然安全性不如BCrypt但在课设场景里够用代码也简单。提示如果想让项目看起来更专业登录验证推荐用BCryptSpringSecurity自带这个工具一行代码就能完成加密和校验。图书管理模块是书城的基础数据层。图书信息包括书名、作者、封面图、分类、价格、库存、简介等。分类我做了两级结构比如“计算机”下面有“编程语言”“数据库”等子分类。图书列表支持按分类筛选、按关键字搜索、分页展示这些接口都是后端提供JSON前端页面用JavaScript渲染。这里要特别说一下封面图处理图片上传使用的是MultipartFile接口上传后存到服务器本地目录数据库里只保存图片的访问路径。这样页面通过img标签就能直接展示逻辑清晰。2.2 购物车与订单模块购物车和订单让书城具备交易能力。购物车表记录用户加购的图书和数量支持加入购物车、修改数量、删除、勾选结算。下单时把购物车里的数据读出来生成订单和订单明细同时扣减库存。这个模块是典型的事务场景我在后面“交易链路”部分会重点展开。2.3 阅读器模块阅读器是这套系统的灵魂。它包含书架展示用户最近阅读或收藏的图书方便快速进入。章节列表按顺序展示图书的所有章节支持点击跳转。阅读页展示章节正文支持上一章/下一章切换。阅读进度记录用户读到哪一章、滚动到什么位置下次进来直接恢复。书签允许用户在某一章添加书签并写下备注。读到这里你会发现阅读器模块才是这个系统和普通“图书管理系统”拉开差距的地方也是评委或面试官最可能追问的部分。2.4 三层架构与Controller-Service-Mapper的边界代码结构上我采用的是标准的Controller-Service-Mapper三层架构Controller层只做参数接收、参数校验和结果返回不写业务逻辑。Service层承载核心业务逻辑比如下单时的库存扣减、阅读进度的更新策略。Mapper层基于MyBatis-Plus操作数据库只做数据持久化。各层的边界一定要清晰。举个例子“加入购物车”这个操作Controller只接收userId和bookId检查参数是否为空Service负责判断图书是否存在、购物车里是否已经有这本书、库存是否充足Mapper只负责执行insert或update语句。如果Controller里写了大量SQL相关的逻辑后面维护起来就会很痛苦。3. 数据库设计重点订单与阅读进度这两套数据怎么建模书城阅读器的数据库设计最有讲究的不是用户表和图书表而是阅读进度表和订单相关的表。这两块直接决定核心功能的实现难度和系统性能。3.1 基础表设计我先列出主要表结构这里用MySQL的建表语句展示。用户表t_userCREATE TABLE t_user ( id bigint(20) NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL COMMENT 用户名, password varchar(100) NOT NULL COMMENT 密码, nickname varchar(50) DEFAULT NULL COMMENT 昵称, avatar varchar(255) DEFAULT NULL COMMENT 头像路径, create_time datetime DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;图书表t_bookCREATE TABLE t_book ( id bigint(20) NOT NULL AUTO_INCREMENT, category_id bigint(20) NOT NULL COMMENT 分类ID, title varchar(100) NOT NULL COMMENT 书名, author varchar(50) DEFAULT NULL COMMENT 作者, cover varchar(255) DEFAULT NULL COMMENT 封面路径, price decimal(10,2) NOT NULL COMMENT 价格, stock int(11) NOT NULL DEFAULT 0 COMMENT 库存, description text COMMENT 简介, sales int(11) NOT NULL DEFAULT 0 COMMENT 销量, create_time datetime DEFAULT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;章节表t_chapterCREATE TABLE t_chapter ( id bigint(20) NOT NULL AUTO_INCREMENT, book_id bigint(20) NOT NULL, chapter_no int(11) NOT NULL COMMENT 章节序号, title varchar(200) NOT NULL COMMENT 章节标题, content text COMMENT 章节正文, PRIMARY KEY (id), KEY idx_book_no (book_id, chapter_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;注意章节表要建立book_id chapter_no的联合索引因为阅读器最常见的操作就是“查某本书按顺序取章节”没有这个索引数据量一大查询就会慢。3.2 阅读进度的存储方案阅读进度是整个系统最有特点的表。我最初的想法很简单用户读到哪一章就在进度表里存一个chapter_id。但后来发现这样不够精确——用户读到一半关掉页面下次打开想从原文位置继续读光记录章节是不够的。最终的方案是CREATE TABLE t_read_progress ( id bigint(20) NOT NULL AUTO_INCREMENT, user_id bigint(20) NOT NULL, book_id bigint(20) NOT NULL, chapter_id bigint(20) NOT NULL COMMENT 最近阅读章节, scroll_percent int(11) DEFAULT 0 COMMENT 章节内滚动百分比, update_time datetime DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_user_book (user_id, book_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;关键设计点以“用户图书”为唯一维度每个用户对每本书只有一条进度记录无论读了多少次都更新这一条。存储滚动百分比而非滚动像素。像素值在不同屏幕宽度下没有意义百分比可以适配PC、手机等任何尺寸。更新策略是“离开时保存”。进入阅读页时读取进度离开章节时把当前章节ID和滚动百分比提交给后端。不采用“每滚动一次就保存”那样接口压力太大也没必要。这套方案的好处是查询极快用户打开某本书的阅读页后端直接根据user_id book_id查到唯一的进度记录一条SQL搞定不用limit 1碰运气。3.3 书签表的建模书签表比进度表简单但容易踩坑的是“重复书签”问题。用户手滑点两次“加入书签”如果表里没有唯一约束就会出现两条一模一样的书签。CREATE TABLE t_bookmark ( id bigint(20) NOT NULL AUTO_INCREMENT, user_id bigint(20) NOT NULL, book_id bigint(20) NOT NULL, chapter_id bigint(20) NOT NULL, remark varchar(255) DEFAULT NULL COMMENT 备注, create_time datetime DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_user_chapter (user_id, book_id, chapter_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;加了(user_id, book_id, chapter_id)的联合唯一键之后插入前可以先查询也可以直接捕获DuplicateKeyException做去重处理。书签列表查询时我喜欢把章节标题一起返回前端就不需要再遍历章节表去匹配标题了这属于典型的“接口多查一点、前端少算一点”的取舍。3.4 订单相关的表订单表设计主要考虑两个问题订单状态如何流转、下单时如何保存商品快照。我用了订单表加订单明细表的结构一笔订单对应多条明细。CREATE TABLE t_order ( id bigint(20) NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL COMMENT 订单编号, user_id bigint(20) NOT NULL, total_amount decimal(10,2) NOT NULL COMMENT 总金额, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已取消, create_time datetime DEFAULT NULL, pay_time datetime DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;订单明细表t_order_item保存下单时的图书名称、价格、数量、封面等信息。为什么订单表里不直接存书名和价格因为图书的价格、名称可能变动但订单一旦生成这笔交易的信息就不能变了。所以在明细表里冗余一份“商品快照”这是电商设计的常识放到书城也一样适用。4. 阅读器核心体验实现目录、翻页、进度记忆与书签打开任意一本电子书用户最关心的就是三件事目录能不能看、正文读起来顺不顺、下次进来还在不在原来的位置。这一章我按这三个目标拆开讲实现细节。4.1 章节目录页的实现章节列表页的核心逻辑是根据book_id查出当前图书的所有章节按chapter_no升序排列。这里用MyBatis-Plus的LambdaQueryWrapper很直观ListChapter chapters chapterMapper.selectList( new LambdaQueryWrapperChapter() .eq(Chapter::getBookId, bookId) .orderByAsc(Chapter::getChapterNo) );前端是静态HTML页面目录列表由JavaScript遍历接口返回的JSON数组动态生成。每个章节项绑定一个点击事件跳转到阅读页并带上bookId和chapterId参数。有个细节值得提一下如果章节数量很大比如一本书有三四百章一次性全部渲染会比较慢可以考虑分页加载或虚拟滚动。但对于课设体量一次全查全渲染问题也不大。4.2 阅读页的实现阅读页是独立的一个HTML文件URL格式类似/read.html?bookId1chapterId3页面加载时JavaScript从URL里解析出bookId和chapterId然后调用后端接口GET /api/chapter?bookId1chapterId3后端返回章节对象包含章节标题、正文、上一章ID、下一章ID等字段。为什么后端还要返回上下章ID因为这样前端在渲染“上一章/下一章”按钮时不需要再维护章节序列的对应关系后端一次性算好前端只管跳转。翻页逻辑我一开始写了两种方案一种是无刷新AJAX加载一种是整页跳转。最后选择了无刷新方案点击上一章/下一章时JavaScript重新请求接口更新标题和正文区域同时把浏览器的URL用history.pushState更新成新的参数。这样的好处是用户不会感到页面闪烁阅读连续性更好。先后端核心代码GetMapping(/chapter) public Result getChapter(Long bookId, Long chapterId) { Chapter chapter chapterMapper.selectById(chapterId); // 查找上一章、下一章 Chapter prev chapterMapper.selectOne(new LambdaQueryWrapperChapter() .eq(Chapter::getBookId, bookId) .lt(Chapter::getChapterNo, chapter.getChapterNo()) .orderByDesc(Chapter::getChapterNo) .last(limit 1)); Chapter next chapterMapper.selectOne(new LambdaQueryWrapperChapter() .eq(Chapter::getBookId, bookId) .gt(Chapter::getChapterNo, chapter.getChapterNo()) .orderByAsc(Chapter::getChapterNo) .last(limit 1)); MapString, Object data new HashMap(); data.put(chapter, chapter); data.put(prevChapterId, prev null ? null : prev.getId()); data.put(nextChapterId, next null ? null : next.getId()); return Result.success(data); }4.3 进度记忆的坑进度记忆我前面提到了存储设计这里说实现的坑。最开始的版本我在每次章节切换时都调用一次保存接口后来发现一个问题用户A在手机上看书读到一半切到后台再切回来时进度反而被“旧请求”覆盖了。原因是切后台前发出的异步请求还没执行完新的请求先到了后到的旧数据把新数据覆盖了。解决思路有两个请求顺序控制前端在切换章节时先打断上一次未完成的保存请求再发起新请求。时间戳字段后端在更新进度时用update_time做乐观锁控制只接受比当前记录更晚的更新。我在系统里用的是第一种方案因为逻辑更简单。前端使用AbortController来取消上一次未完成的fetch请求切章时先abort()再继续。这个坑如果不提很多人做完功能测试是发现不了的但实际使用中特别影响体验。保存进度的接口还有一个注意点滚动百分比应该取“已经滚动的距离/可滚动总高度”而不是简单用scrollTop / document.height。正确写法是const scrollPercent Math.round( (document.documentElement.scrollTop || document.body.scrollTop) / (document.documentElement.scrollHeight - document.documentElement.clientHeight) * 100 );这里要区分“内容总高度”和“可滚动距离”后者是要减掉视口高度的不然算出来的百分比永远偏大恢复位置时页面会跳过头。恢复进度的代码也有一点讲究进入阅读页后等正文渲染完成再执行滚动定位否则scrollTop设置不生效。我是在fetch拿到数据、innerHTML更新完之后用requestAnimationFrame包一层再设置滚动位置。4.4 书签功能的实现书签功能的操作路径是阅读页底部放一个“加入书签”按钮点击后弹出一个小输入框允许用户写备注然后提交到后端。已加过书签的章节按钮状态会显示“已收藏”。这里的判断逻辑依赖前面说的联合唯一键。前端进入阅读页时调一次接口确认当前章节是否已存在书签GET /api/bookmark/check?bookId1chapterId3返回true或false控制按钮文案和状态。加入书签时后端捕获重复插入的异常返回友好提示。书签列表页是独立的HTML展示用户在所有书里添加的书签每条记录显示书名、章节标题、备注和添加时间并提供“点击跳转到阅读位置”的入口。这里尤其要注意跳转时要带上chapterId直接打开阅读页并定位到进度。5. 购物车、下单与库存扣减交易链路的完整闭环如果说阅读器是灵魂那交易模块就是让系统真正“成型”的部分。下面重点说加购、下单和库存管理。5.1 加入购物车的逻辑“加入购物车”虽然只有四个字但后端要考虑的情况不少。我的实现逻辑是这样的参数校验bookId不能为空userId从Session取。查图书是否存在、是否下架。查购物车里是否已经有这本书。有则数量加一无则新增一条记录。返回购物车数量前端更新角标。代码片段大致长这样Override Transactional public void addToCart(Long userId, Long bookId) { Book book bookMapper.selectById(bookId); if (book null) { throw new BizException(图书不存在); } CartItem item cartMapper.selectOne(new LambdaQueryWrapperCartItem() .eq(CartItem::getUserId, userId) .eq(CartItem::getBookId, bookId)); if (item null) { item new CartItem(); item.setUserId(userId); item.setBookId(bookId); item.setQuantity(1); cartMapper.insert(item); } else { item.setQuantity(item.getQuantity() 1); cartMapper.updateById(item); } }5.2 下单与库存扣减的事务处理下单是整个系统里最需要谨慎的地方。下单过程需要做四件事读取购物车选中的商品、校验库存、生成订单和明细、清空购物车。这几步必须在一个事务里完成否则会出现库存扣了但订单没生成、或者订单生成了购物车没清掉之类的脏数据。一个容易被忽略的问题是先查库存再扣库存存在并发超卖风险。虽然课设系统一般没什么并发量但作为一个负责任的开发者还是应该用数据库的原子操作来扣减库存Update(UPDATE t_book SET stock stock - #{quantity} WHERE id #{bookId} AND stock #{quantity}) int deductStock(Param(bookId) Long bookId, Param(quantity) int quantity);注意这里的SQL条件是stock quantity。如果影响行数为0说明库存不足直接抛出异常让事务回滚。这种写法比“先select再update”安全得多也简单得多。下单方法加Transactional注解整个流程如下Transactional public Order createOrder(Long userId, ListLong cartItemIds) { // 1. 计算总金额校验库存并扣减 // 2. 生成订单号 // 3. 插入订单表 // 4. 插入订单明细表 // 5. 删除购物车中已结算项 return order; }订单号生成我用的是时间戳加随机数保证唯一性同时给order_no加了唯一索引兜底。更严谨的做法是用雪花算法生成但课设场景时间戳加随机数完全够用。5.3 订单状态管理订单状态我用一个status字段表示0待支付、1已支付、2已取消。支付功能是模拟的用户点击“支付”按钮后后端直接把状态改为已支付并记录支付时间同时把图书的销量加一。这里有一个体验细节前端在订单列表里要展示不同的状态标签和操作按钮。待支付订单显示“去支付”和“取消订单”已支付订单显示“去阅读”。我把“去阅读”作为订单列表的隐藏入口用户在书城买完书之后闭环从这里再次接回阅读模块整个流程就串起来了。如果想让订单模块看起来更完整可以在用户下单后生成一个简单的“模拟支付页面”倒计时30分钟未支付自动取消。用SpringBoot的Scheduled定时任务扫一遍超时订单也是加分项。6. 部署与排坑记录路径、编码、打包这些最容易卡住的点项目写完只是完成了第一步能部署到服务器上跑起来才算真正结束。这一章记录我在部署和本地运行过程中踩过的真实坑每一个都是花过时间排查的。6.1 静态资源路径404问题第一次把项目打包成Jar运行后发现页面能打开但CSS、JS全部加载不出来控制台一片404。排查之后发现是静态资源路径配置的问题。SpringBoot默认把classpath:/static/作为静态资源目录。我在application.yml里写了spring.mvc.static-path-pattern: /static/**然后页面里的引用路径就变成了/static/css/style.css。问题在于我实际把CSS文件放在了static/css/下但访问路径却多了一层static。解决办法有两种要么把配置里的/static/**去掉直接使用默认的/**映射要么把文件放到static/static/目录下。我当时选择了删掉自定义配置恢复默认映射这样/css/style.css直接对应static/css/style.css最符合直觉。6.2 中文乱码问题中文乱码是老生常谈但真要排查起来也够呛。当时的现象是页面上显示书名是问号从数据库直接查询没问题但通过接口返回就乱码。最后定位到三个地方数据库连接URL必须加上编码参数url: jdbc:mysql://localhost:3306/bookcity?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai表结构统一用utf8mb4不用utf8。utf8mb4是utf8的超集能完整支持中文和特殊字符。前端页面meta charsetutf-8必须在head最前面而且不能有BOM头。我用IDEA创建HTML时不小心加了BOM头导致页面即使声明了编码也还是乱码删除BOM后才正常。6.3 打包部署细节打包时我踩过一个印象深刻的坑用IDEA自带的打包功能打的Jar包本地能跑放到服务器上启动报“没有主清单属性”。后来才发现是打包方式不对。正确做法是使用Maven的package命令或者在IDEA右侧Maven工具里执行clean加package这样SpringBoot插件会生成可执行Jar包含Main-Class和所有依赖。还有一个部署上的细节服务器上如果同时跑着其他服务8080端口很可能被占用。启动前先检查端口必要时在启动命令里指定端口java -jar bookcity.jar --server.port8081或者把application.yml里的端口改成环境变量server: port: ${SERVER_PORT:8080}这样部署灵活性高很多不需要每次改代码重新打包。另外项目里的封面图上传路径我当初写的是本地绝对路径比如D:/upload/部署到Linux服务器上就完全失效。后面改成相对路径并在配置里增加上传目录upload: dir: ./upload静态资源映射也加了一段配置把/upload/**映射到实际磁盘目录。这一步如果不处理最直接的结果就是图书封面在服务器上全部裂图。6.4 其他值得记录的小坑再列几个可能影响体验的小问题idea控制台乱码启动日志全是乱码的话在Help - VM Options里加上-Dfile.encodingUTF-8同时IDEA右下角编码切到UTF-8。页面缓存不刷新改完HTML后浏览器还显示旧页面我一度以为是代码没生效后来发现是浏览器缓存。解决方式是在页面引用的CSS/JS后加版本号参数比如style.css?v2或者开发时用无痕窗口。接口返回时间格式问题默认返回的时间是一串时间戳前端拿到后要格式化。我在实体类的日期字段上加了JsonFormat(pattern yyyy-MM-dd HH:mm:ss)统一输出格式省去前端转换。这些坑单个看都很小但任何一个没处理好都会让系统看起来“就是差了那么点意思”。做课设、毕设或者个人练手项目的时候把这些细节打理好项目整体完成度立刻不一样。做完这个书城阅读器系统我最深的一个体会是不要因为技术栈“老”或“简单”就敷衍SpringBoot加原生HTML这套组合反而逼着我把接口设计、数据库建模、事务处理和前端渲染这些基本功都踏踏实实过了一遍。特别是阅读进度和书签这两个功能虽然只是两张表加几个接口的事但背后涉及的是对用户行为的理解——用户需要的是“无缝续读”不是“重新找位置”。如果只按普通的CRUD做这项目做完也就完了把这些体验细节做进去才真能叫阅读器系统。后续如果想继续扩展我建议优先把书评、评分和用户书架加上这三个功能都是基于现有表结构做小改动就能实现的性价比很高。
返回列表