
我自己做线上图书项目已经不是第一次了SpringBoot加Vue这个技术组合更是用了很多年。之前帮人做过一个纯商城版的图书销售系统也见过不少同学把阅读器和商城分开做结果系统上线后发现用户买完书要去另一个地方看体验非常割裂。这个阳光好书系统从一开始就不是单纯的图书展示页而是把在线图书阅读和图书销售放在同一个平台里——用户在书城看到一本感兴趣的电子书可以试读几章满意就下单购买买完直接在阅读器里继续读管理员在后台维护图书上架、章节发布和订单发货。整个业务闭环在一个系统里跑通。技术选型上后端用的SpringBoot前端用的Vue这两个在目前JavaWeb项目里是出现频率最高的组合。SpringBoot负责提供稳定的接口服务、数据持久化和业务逻辑处理Vue负责页面的交互和用户体验。这个项目的定位很明确面向做Java方向毕设或者个人作品的同学你需要的是一个能讲清楚、能演示、能扩展的完整系统而不是一个只会增删改查的演示工程。接下来我会从业务建模、数据库设计、阅读器实现、商城交易、前后端联调几个部分把做这个系统时踩过的坑和值得注意的细节都梳理一遍。1. 先把这个系统的业务逻辑理顺很多人在动手写代码之前根本说不清楚阳光好书系统到底要解决什么问题。这是最致命的一点。如果一个系统的边界都模糊后面做出来的东西大概率是一个什么都想做、什么都做不透的缝合怪。1.1 阅读和销售合并解决的是体验割裂问题单独做一个图书阅读器技术上并不难。难的是阅读器里只有免费内容没有商业闭环用户读完了就走平台没有收入、没有留存作者和出版社也没有动力持续提供内容。单独做一个图书商城也不难。但传统商城卖的是实体书你需要考虑库存、物流、快递单号、退货退款业务链特别长。而且用户买完实体书之后和平台的互动基本就断了下一次回访只能靠促销活动。阳光好书系统把这两者合并业务逻辑是这样的平台维护一批电子书每本书有简介、封面、价格和试读章节。用户浏览书城先试读觉得内容值这个价就下单购买。订单支付成功后系统给用户开通这本书的阅读权限。用户打开阅读器按章节阅读阅读进度自动保存下次打开还能接着上次的位置看。这个模式在国内的知识付费和在线阅读平台里已经跑通了。核心价值不是既做了阅读器又做了商城而是发现、试读、付费、阅读、回访这个完整用户旅程被打通了。用户不需要跳出平台去其他工具里阅读运营方也不需要维护两套数据、两套账号体系。1.2 参与这个系统的三种角色权限模型在动手建表之前就必须定下来不然后面加角色字段会让人很痛苦。阳光好书系统按最小可用原则我建议只保留两种角色普通用户和管理员。作者、运营这些角色可以在后期扩展初期没必要给自己加戏。普通用户的用例注册、登录、修改个人信息浏览图书列表按分类筛选用关键词搜索图书查看图书详情和目录试读免费章节将图书加入购物车下单购买在阅读器内阅读已购图书自动保存阅读进度查看个人书架和订单列表管理员的用例图书管理上架、下架、修改价格、维护库存章节管理新增章节、设置试读章数、编辑章节内容订单管理查看订单列表、处理模拟支付订单、发货用户管理禁用账号、重置密码两种角色用一张user表加一个role字段就能区分不需要单独建两张用户表。0表示普通用户1表示管理员。权限控制上后端拦截器校验接口时判断role管理端接口统一加/admin前缀前端再配合Vue Router的路由守卫做页面级隔离。1.3 为什么是SpringBoot Vue而不是其他组合这个选择不是因为它最先进而是因为它最适合这个场景。SpringBoot的优势在于自动配置和生态成熟。图书系统涉及的MyBatis-Plus、JWT、文件上传、分页插件都有非常成熟的开源方案起步成本极低。内嵌Tomcat也让打包部署变得简单一个jar包扔到服务器上就能跑。Vue的优势在于组件化和响应式。图书商城这类SPA页面需要频繁切换列表、详情、购物车、阅读器组件化开发可以把这些页面拆成独立模块阅读状态用Vuex或Pinia管理非常自然。相比传统的JSP模板渲染Vue的前后端分离开发模式让接口调试清晰很多前端页面改了不用重启后端。当然React也是一套优秀的方案但如果你是Java方向团队里大家最熟的前端框架大概率是Vue。中文资料多、上手快、出了坑能搜到答案这就是它在毕设和中小型项目里胜出的原因。技术选型要选团队能驾驭的不是选论坛上吹得最火的。2. 后端骨架SpringBoot模块划分与数据库设计2.1 项目目录怎么组织才能不越到后面越乱很多同学写SpringBoot项目前期很爽一旦模块增多接口、实体、配置全堆在默认包下后面每加一个功能都想重构。我的建议是一开始就按下面的结构组织后面哪怕扩展到二十张表也不会乱。com.sunnybooks ├── controller // 接口层UserController、BookController、OrderController ├── service // 业务层BookService、OrderService、ReadingProgressService ├── mapper // 数据访问层BookMapper、OrderMapper ├── entity // 数据库实体User、Book、Chapter、Order ├── dto // 传输对象BookDTO、OrderDTO、LoginDTO ├── vo // 视图对象BookDetailVO、ChapterVO ├── config // 配置WebConfig、MinioConfig、MybatisPlusConfig ├── common // 通用Result、PageResult、GlobalExceptionHandler └── utils // 工具JwtUtil、BeanCopyUtils这里有一个非常关键的实践前端请求和后端接收数据时不要直接把数据库实体丢出去。数据库实体包含的字段往往比前端需要的多比如用户表的密码hash、图书表的状态字段等。用一个独立的DTO层做数据封装接口只暴露需要的内容。这个习惯看着简单后期改接口、加权限、做接口文档时你会庆幸当初留了这一层。2.2 五张核心表的设计细节阳光好书系统最小可用版本只需要五张表用户表、图书表、章节表、订单表、阅读进度表。购物车表可以加也可以放后面再说。我强烈建议第一版就把阅读进度表做进去因为这是阅读系统区别于普通商城的核心差异点也是你答辩时最值得讲的功能之一。CREATE TABLE user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(255) NOT NULL, nickname VARCHAR(50), avatar VARCHAR(255), role TINYINT DEFAULT 0 COMMENT 0普通用户 1管理员, status TINYINT DEFAULT 1, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE book ( id BIGINT PRIMARY KEY AUTO_INCREMENT, book_name VARCHAR(100) NOT NULL, author VARCHAR(50), cover_url VARCHAR(255), description TEXT, category_id BIGINT, price DECIMAL(10,2) DEFAULT 0.00, stock INT DEFAULT 0, sales_count INT DEFAULT 0, trial_chapters INT DEFAULT 3 COMMENT 免费试读章数, status TINYINT DEFAULT 1 COMMENT 1上架 0下架, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ); CREATE TABLE book_chapter ( id BIGINT PRIMARY KEY AUTO_INCREMENT, book_id BIGINT NOT NULL, chapter_name VARCHAR(100), chapter_no INT, content LONGTEXT, is_trial TINYINT DEFAULT 0 COMMENT 1试读章节 0付费章节, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE orders ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL, book_id BIGINT NOT NULL, book_name VARCHAR(100), total_amount DECIMAL(10,2), status TINYINT DEFAULT 0 COMMENT 0待支付 1已支付 2已完成 3已取消, pay_type VARCHAR(20), create_time DATETIME DEFAULT CURRENT_TIMESTAMP, pay_time DATETIME, UNIQUE KEY uk_order_no (order_no) ); CREATE TABLE reading_progress ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT, book_id BIGINT, last_chapter_id BIGINT, last_chapter_no INT, read_percent DECIMAL(5,2), update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_user_book (user_id, book_id) );说一下几个设计细节。第一book表和book_chapter表必须分开。章节内容用LONGTEXT 存储如果把章节直接塞进book表每次查询图书列表都会把大文本字段带出来接口响应速度会肉眼可见地变慢。分开之后列表页只查book表阅读器才按需查章节内容。第二book表里存了trial_chapters这是为了试读功能。试读章节的判定可以直接用chapter_no trial_chapters来做不一定非要在chapter表里维护is_trial字段。但如果你的系统里试读规则比较灵活比如第一章免费、第五章也免费那就用is_trial字段更合理。第三orders表里冗余了book_name字段。这是刻意为之的。订单产生之后图书可能下架、改价如果不冗余书名后期订单列表还要关联查询book表一旦图书被物理删除订单历史就查不出买的是什么了。电商系统里订单表冗余商品快照是常规操作。第四reading_progress表用了(user_id, book_id)联合唯一索引。为什么不用主键自增id做唯一因为业务上同一个用户对同一本书只可能有一条进度记录如果用代码判断先查再更新并发情况下可能出现两条重复数据。有了唯一索引直接insert ... on duplicate key update或先delete再insert数据库层面就保证了唯一性。2.3 JWT认证与接口权限控制前后端分离的项目Session方案不好用因为前端和后端可能不在同一个域名下CORS跨域时Cookie的处理比较麻烦。JWT是目前最常见的方案用户登录成功后后端签发一个token前端把token存在localStorage里每次请求在Header中携带后端拦截器校验签名和过期时间。JWT工具类核心逻辑如下Component public class JwtUtil { // 生产环境务必放到配置中心或配置文件中用Value注入 private String secret sunny-books-secret; public String createToken(Integer userId, String role) { return Jwts.builder() .claim(userId, userId) .claim(role, role) .setExpiration(new Date(System.currentTimeMillis() 7 * 24 * 3600 * 1000)) .signWith(SignatureAlgorithm.HS256, secret) .compact(); } public Claims parseToken(String token) { return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody(); } }拦截器里做两件事一是解析token二是根据接口前缀判断是否需要管理员权限。公开接口如登录、注册、图书列表、图书详情不需要token这类接口可以在拦截器里直接放行。需要登录的接口如果没有token返回401。需要管理员的接口用/admin前缀统一管理拦截器里校验role是不是1。这里有个非常容易踩的坑JWT的密钥不能硬编码。说出来你可能觉得是废话但很多项目就是这么写的包括一些生产项目。一旦代码泄露任何人都可以用这个密钥伪造token。建议把secret放到application.yml里用Value注入JDK8以上也可以用record或者ConfigurationProperties管理。另一个坑是token的过期时间。图书阅读场景下用户可能连续读两个小时书如果token有效期只有30分钟读到一半突然接口全部401体验极差。我建议普通用户token给7天甚至30天有效期管理端可以短一些比如2小时降低安全风险。这算是阅读类系统的特殊考量。3. Vue端阅读体验的实现从书城到阅读器3.1 前端工程结构与路由守卫前端我用的Vue 3 Vite Pinia Vue Router Element Plus。Vite比Webpack启动快太多开发体验不是一个量级的如果你的SpringBoot项目前端准备单独开发Vite是首选。目录结构如下src ├── api // 接口封装user.js、book.js、order.js ├── assets // 静态资源 ├── components // 通用组件BookCard、Pagination、Header ├── router // 路由配置 ├── store // Pinia状态管理user.js、cart.js ├── views │ ├── home // 首页 │ ├── book // 图书列表、详情 │ ├── reader // 阅读器 │ ├── order // 购物车、订单列表 │ └── admin // 管理端 └── main.js路由守卫是Vue项目里必须用心写的一段逻辑。阳光好书系统的路由分三种公开路由首页、图书列表、图书详情、需要登录的路由购物车、订单、阅读器、管理员路由图书管理、订单管理、用户管理。逻辑很简单每次路由跳转前判断目标路由的meta字段比如meta.requiresAuth为true时检查Pinia里的token没有就跳登录页。管理员路由加meta.requiresAdmin再检查user info里的role。很多同学在这里直接判断有没有token这有个隐患token过期了但localStorage里还残留着旧值前端以为登录了后端接口却不断返回401。更稳妥的做法是后端提供一个接口校验token有效性或者在前端axios响应拦截器里统一处理401并跳登录。3.2 图书详情页的权限判断逻辑图书详情页是阳光好书系统里业务逻辑最重的页面。它要同时展示图书信息、目录、价格、购买状态和阅读入口。页面数据来源是两个接口图书详情接口返回book信息和目录列表另一个是用户是否可读的判断接口。为什么单独做一个判断接口而不是每次查订单表因为订单表只记录支付状态而用户可读这个状态会随着订单支付成功、订单退款、管理员手动开通权限等操作变化。与其每次查询时做复杂的状态推导不如单独建一张user_book_permission表用户支付成功后立即往这张表里插一条记录阅读时直接查这张表。CREATE TABLE user_book_permission ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, book_id BIGINT NOT NULL, source VARCHAR(20) DEFAULT buy COMMENT buy购买 admin管理员赠送, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_book (user_id, book_id) );前端拿到permission字段后详情页按钮显示逻辑就非常清晰未登录展示登录后购买已登录但没购买展示价格和购买按钮同时可以点试读打开阅读器只看前几章已购买展示开始阅读按钮直接进入完整阅读器这个试读和完整阅读在同一个阅读器组件里实现区别就是进入时传入的参数不同。试读模式传bookId和试读章节数完整模式传bookId和上次阅读进度。3.3 章节阅读器与阅读进度同步阅读器是整个阳光好书系统里最像阅读系统的部分。别人看你的项目能不能一眼认出这是图书阅读系统就看阅读器的体验。我的实现方案是用一个独立的Reader.vue组件通过路由参数接收bookId。组件内部主要做几件事第一加载目录。目录数据从图书详情接口拿按chapter_no排序。阅读器左侧或顶部做一个抽屉点击章节名切换内容。第二加载章节内容。切换章节时调用章节详情接口把content字段渲染到页面主体。内容以文本段落为主用v-html渲染需要后端返回富文本但要注意XSS风险。安全做法是后端对内容做过滤或者前端用DOMPurify清洗后再渲染。第三上一章、下一章。这个交互逻辑要处理边界第一章时上一章按钮禁用最后一章时下一章按钮禁用。实现上就是维护一个currentChapterNo切换时带上chapter_no参数重新请求接口。第四字体大小调节。阅读类应用这个功能几乎是标配。我直接在阅读器顶部放一个字号加减控件拿到字号值后设置给页面主体容器的font-size。再配合localStorage记录偏好下次打开还保持用户习惯的字号。第五阅读进度保存。这是最需要用心做的功能。我的方案是阅读器加载时会先请求reading_progress接口获取last_chapter_no如果有记录就自动跳转到上次章节。阅读过程中滚动条滚动时监听scroll事件计算当前阅读位置百分比同时用一个防抖函数每3秒上报一次进度到后端。离开阅读器时在onBeforeUnmount钩子里再强制上报一次确保最后的位置不丢。进度上报的防抖代码很简单但非常管用let timer null function reportProgress() { if (timer) clearTimeout(timer) timer setTimeout(async () { await saveProgress({ bookId: route.params.bookId, lastChapterNo: currentChapterNo.value, readPercent: calcPercent() }) }, 3000) }为什么要防抖因为用户翻页或者滚动时scroll事件一秒触发几十次如果每次都发请求后端压力大前端也会因为频繁的网络请求变得卡顿。3秒一次的频率足够保存进度又不会浪费请求资源。4. 商城交易链路购物车、订单和支付4.1 购物车用一张表就够了电子书商城和实体书商城的购物车设计差异很大。实体书要考虑库存、运费、套餐组合但电子书没有这些麻烦。一份图书就是一个SKU价格固定无实物配送所以购物车表非常简单CREATE TABLE cart ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, book_id BIGINT NOT NULL, quantity TINYINT DEFAULT 1, checked TINYINT DEFAULT 1, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_book (user_id, book_id) );虽然有quantity字段但电子书购买数永远只能是1。保留这个字段是为了兼容以后可能出现的赠送和多买多送玩法。有人问过我一件事购物车为什么不存前端localStorage省一张表我说可以但对于阳光好书系统这种偏教学和毕设的项目购物车存数据库的好处是更完整地演示了一个业务功能从表设计到接口到页面的完整链路答辩时也更有话讲。而且如果用户换浏览器登录购物车数据还在体验一致。购物车接口我建议这样设计GET /cart/list 查询用户购物车POST /cart/add 加入购物车PUT /cart/update 修改数量或勾选状态DELETE /cart/delete/{cartId} 删除条目POST /cart/checkout 结算加入购物车时要做一次校验这本书是否还在售、购物车里是否已经有这本书。已经有的情况直接把quantity置1就行不需要真的累加。4.2 订单状态机不能拍脑袋设计订单模块是商城系统的灵魂也是最容易被做成就是一个insert的地方。很多毕设项目用户点下单后端插一条order记录前端弹个支付成功就完了。这样不是不行但一旦你想把支付做得真实一点就会发现状态管理一塌糊涂。阳光好书系统的订单状态我建议这样设计状态码含义触发条件可流转到0待支付用户下单成功1、31已支付支付回调成功2、32已完成用户确认或超时自动完成无3已取消用户主动取消或超时未支付无下单接口的逻辑顺序很重要先生成订单再扣减库存再清空购物车。这里有个并发问题如果两个人同时购买同一本书库存从1变成负数怎么办最简单也最实用的办法是下单时用乐观锁UPDATE语句里加条件UPDATE book SET stock stock - 1 WHERE id ? AND stock 0返回受影响行数为0就说明库存不足回滚订单。订单号生成建议用时间戳 随机数或者雪花算法。如果用自增主键直接当订单号有几个问题可能被别人猜到订单数量、订单号过短且无业务含义。我用的方案是String orderNo BOOK System.currentTimeMillis() String.format(%04d, new Random().nextInt(10000));这个格式简单直观保证基本唯一。真要上生产用雪花算法或者数据库发号器。4.3 支付对接与模拟支付的取舍真实对接支付宝或微信支付是很多同学卡住的环节因为需要商户资质个人开发者很难拿到正式商户号。这里有两种务实的方案。第一种方案用支付宝沙箱环境。支付宝开放平台提供一个沙箱环境有测试账号、测试密钥完全可以模拟真实支付流程。后端集成流程是配置appId、应用私钥、支付宝公钥、回调地址调用支付宝sdk的支付接口生成支付表单用户支付成功后支付宝异步回调通知后端后端验签成功后更新订单状态。这个方案能让你的项目看起来非常完整答辩时也很有说服力。唯一要注意的是沙箱环境需要自己在支付宝开放平台申请并下载支付宝客户端配合沙箱钱包使用。第二种方案模拟支付也叫假支付。后端提供一个模拟支付接口前端弹出一个支付确认框用户点确认支付后调用后端接口后端直接把这个订单标记为已支付同时写好user_book_permission记录。这个方案零外部依赖适合纯学习和演示场景。我个人建议如果有时间优先做支付宝沙箱因为异步回调验签这个技术点在面试时是加分项。没时间就做模拟支付但一定要在代码里预留支付参数和回调接口的封装后期接真实支付就是换个实现的问题。支付成功后要做两件事一个都不能忘更新订单状态为已支付、往user_book_permission表插入一条记录。如果忘了插权限记录用户钱付了书却读不了这是整个系统最严重的逻辑漏洞。5. 前后端联调时最容易翻车的五个地方SpringBoot项目单测通过不代表项目能跑前后端联调才是真正暴露问题的地方。我把自己做阳光好书系统时反复踩的坑集中说一下这些在文档里经常看不到但每个都会消耗你几个小时。5.1 跨域配置和JWT请求头前端跑在5173端口后端跑在8080端口浏览器会拦截跨域请求。很多同学在后端写了CorsRegistry配置但还是报错往往是因为配置了allowedOrigins(*)的同时又启用了allowCredentials(true)。这两个配置是冲突的允许所有来源的情况下不能携带凭证。JWT认证模式里token通常是放在Authorization头里的这属于自定义请求头所以必须显式允许。正确的配置是Configuration public class WebConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOrigins(http://localhost:5173) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true); } }生产环境上线后把allowedOrigins改成你的线上域名即可。前端发起请求时一定记得在axios里设置withCredentials: true同时带上Authorization头。5.2 本地文件上传与静态资源映射图书封面需要上传章节内容也经常需要插图。如果你用本地上传方案文件会被保存到服务器磁盘的某个目录但SpringBoot默认静态资源只映射classpath下的static目录。你访问http://localhost:8080/upload/cover.jpg得到404就是这个原因。解决方法是自定义资源映射registry.addResourceHandler(/upload/**) .addResourceLocations(file:D:/sunnybooks/upload/);如果项目要部署到云服务器或Docker环境我建议直接上MinIO。MinIO是开源的分布式对象存储服务兼容S3协议在SpringBoot里集成也就几个依赖和一段配置的事。它比本地文件方案强的地方在于文件和代码解耦、支持海量图片、Docker一键部署、可以做成独立存储服务供多个系统复用。你甚至可以把它当工具用微博上很多教程都是SpringBoot整合MinIO实现文件上传。5.3 token过期与axios统一处理前端每个发起请求的地方都手动带token这是最原始的写法。麻烦不说token过期时的处理会变成噩梦。建议封装一个统一的axios实例在请求拦截器里带上token在响应拦截器里统一处理401。这里给出一个精简的封装思路request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer token } return config }) request.interceptors.response.use( response response.data, error { if (error.response?.status 401) { localStorage.removeItem(token) router.push(/login) } return Promise.reject(error) } )统一处理的好处是所有接口的鉴权逻辑都在一个地方不会出现登录接口也带token这种怪问题也不会出现某个页面漏传token导致401后弹出难看的报错框。5.4 分页查询参数两边不一致图书列表必然要分页。前端Element Plus的分页组件页码从1开始后端MyBatis-Plus的page对象页码也是从1开始但如果前端用了某些组件或者后端代码里写了PageHelper页码起始值可能变成0。最常见的现象是前端点第2页传pageNum2后端却理解成跳过0条取第2条记录结果一直在加载重复数据。我的建议是接口参数统一定义为pageNum和pageSize前端和后端都从1开始编号后端代码里不要自己减一。同时在接口文档里明确写清参数含义。前后端联调第一件事就是确认分页参数对不对这能省很多无意义的排查时间。5.5 前端history路由和打包部署Vue项目默认的hash模式部署简单但URL里带个#号不太好看。很多人切到history模式结果部署到服务器后刷新页面就404。原因是history模式下前端路由是浏览器端的history API管理的服务器并没有对应的文件刷新时服务器返回404。如果你是前后端分离部署Nginx配置如下location / { try_files $uri $uri/ /index.html; }如果你图省事把前端打包后的dist目录直接丢进SpringBoot项目的resources/static下那么你需要让SpringBoot将所有未知路由转发到index.html。但这只是权宜之计我还是建议用Nginx单独部署前端SpringBoot只做后端接口服务。这样两个项目职责清晰扩展时彼此不受影响。6. 上线前要补的功课和功能演进方向6.1 阅读器体验打磨阅读器做完基础功能之后我强烈建议再补三个体验优化点夜间模式、字号设置持久化、目录预览。夜间模式实现不难在阅读器页面加一个isDark变量切换时给内容区加一个class背景色换成深色文字颜色换成浅色。设置后的状态存localStorage下次打开阅读器直接读取。字号设置持久化是我一开始忽略的。用户体验的角度用户每次都要重新调字号是很烦的存localStorage成本极低用户体验提升明显。目录预览方面如果章节很多比如几百章一次性渲染所有章节会让滚动列表卡顿。可以用虚拟滚动或者分页加载目录Vue生态里有很多现成方案。6.2 搜索从like到全文检索图书列表的搜索功能第一版用MySQL like查询完全够用比如按书名模糊搜索。但书名、作者、简介这几个字段的搜索会越用越觉得吃力尤其简介字段是text类型like %关键词% 无法走索引数据量大了以后全表扫描性能会很难看。数据量在几千条以内MySQL like方案完全没问题。数据量到几十万上百万就要考虑Elasticsearch或者更轻量级的全文检索方案。有人提到在SpringBoot里集成HanLP做分词这在项目里是可以的中文分词后建立索引再配合Elasticsearch做搜索搜索体验会好很多。但这是中后期优化不用第一版就上。把搜索接口的设计做得合理一点比如参数用keyword参数名不要写死成bookName这样后面换搜索引擎时前端不用改。6.3 缓存与性能优化阅读系统里图书详情和热门榜单是典型的热点数据几乎所有用户进入首页都会查。这些数据量不大但访问频率高非常适合加缓存。用Redis的话key可以设计成book:detail:{id}value存JSON序列化后的图书详情。后台修改图书时主动删除对应的缓存下次请求重新回源数据库。章节内容要不要缓存我的建议是不要直接把LONGTEXT全文塞进Redis。章节内容动辄几十KB如果一本书几百章缓存所有章节会占用大量内存。更合理的做法是缓存章节的列表信息章节正文按需查库。阅读频繁的章节可以用短期缓存比如设置10分钟过期但收益有限早期版本不做也没问题。另外首页热门榜可以定时计算每天凌晨跑一次统计把销售数据排个列表存Redis当天所有访问都走缓存。这样比每次请求都实时统计的体验好得多。6.4 演示数据和项目展示最后说一个很多人忽略的事把一个项目从代码写完变成能拿得出手让人看了眼前一亮演示数据占一半功劳。阳光好书系统至少要准备10本以上不同风格的图书每本书至少3个章节封面图风格统一简介写得像模像样。为什么要这样因为评审老师或面试官看项目时第一眼看到的是界面不是代码。如果书城里只有两三本书封面还是默认占位图哪怕你代码写得再好第一印象就输了。演示数据本身可以用代码初始化写一个CommandLineRunner项目启动时检查数据库是否为空为空就自动插入种子数据。这样无论谁clone你的项目一启动就能看到完整效果不需要手动造数这个体验是很加分的。答辩或面试时我建议重点讲这三个点一是阅读权限设计说明试读和已购用户的权限如何判断二是订单状态机说明支付回调后系统如何更新状态并发开通阅读权限三是阅读进度同步说明防抖上报和断点续读的实现。这三个功能分别对应内容展示、交易闭环、用户粘性正好覆盖了阳光好书系统阅读销售双核心的价值。我个人在实际操作中的体会是这类图书系统最容易翻车的地方不在具体某个技术点而在于打通。书店和阅读器必须是一体的订单和权限必须是联动的进度和章节必须是匹配的。只要这些关键链路都通了剩下的就是打磨细节。如果你正在做类似的项目可以按我上面的思路先搭骨架再逐个模块填充。遇到问题的时候记住一句话先把业务逻辑画清楚再写代码。