我自己做线上图书项目已经不是第一次了,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、3 |
| 1 | 已支付 | 支付回调成功 | 2、3 |
| 2 | 已完成 | 用户确认或超时自动完成 | 无 |
| 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页,传pageNum=2,后端却理解成"跳过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你的项目,一启动就能看到完整效果,不需要手动造数,这个体验是很加分的。
答辩或面试时,我建议重点讲这三个点:一是阅读权限设计,说明试读和已购用户的权限如何判断;二是订单状态机,说明支付回调后系统如何更新状态并发开通阅读权限;三是阅读进度同步,说明防抖上报和断点续读的实现。这三个功能分别对应内容展示、交易闭环、用户粘性,正好覆盖了阳光好书系统"阅读+销售"双核心的价值。
我个人在实际操作中的体会是,这类图书系统最容易翻车的地方不在具体某个技术点,而在于"打通"。书店和阅读器必须是一体的,订单和权限必须是联动的,进度和章节必须是匹配的。只要这些关键链路都通了,剩下的就是打磨细节。如果你正在做类似的项目,可以按我上面的思路先搭骨架,再逐个模块填充。遇到问题的时候,记住一句话:先把业务逻辑画清楚,再写代码。