1. 项目概述与需求拆解
网上书店管理系统,这名字一听就明白——就是把线下书店的那一套流程搬到线上,用代码重新实现一遍。但真正动手做起来,很多人会发现自己陷入一个误区:要么把功能越想越复杂,购物车、优惠券、秒杀、直播带货什么都想塞进去;要么就是做得太简陋,用户注册登录完就不知道该干啥了,管理员后台更是形同虚设。
这个项目是我接手过的一个典型全栈实战项目,前端用Vue,后端用SpringBoot,数据库选了MySQL,整套技术栈在当前国内中小型项目中非常主流。网上书店的核心价值在于“管理”二字,而不在于“网上”二字。也就是说,真正需要用心设计的是图书信息的管理流程、订单状态的流转控制、库存与销量的联动关系,以及前后端分离架构下的接口规范,而不是一味堆砌花哨的页面效果。
适合谁来参考?两类人。第一类是正在准备毕业设计或求职作品集的计算机专业学生,这个项目可作为完整的全栈实践,覆盖从需求分析、表结构设计到接口联调、部署上线的全过程;第二类是初级后端工程师想补全自己对前后端分离项目的整体认知,尤其是SpringBoot如何同时服务Web页面和API请求,事务控制、权限拦截、异常处理这些企业级关注点如何在真实项目中落地。
1.1 核心用户角色定义
系统面向三类角色:游客、注册用户、管理员。三者权限完全隔离,每一类角色看到的界面、能调用的接口、能触发的业务逻辑都不一样,这也是系统设计的第一个关键决策。
游客是最轻量的角色,只允许浏览图书、按分类检索、查看图书详情和评论,不能下单、不能加入购物车。所有游客可见的接口都走公开路由,不需要携带令牌。这里我强调一个设计原则:哪怕是游客可见的数据,也要做服务端校验,不能在前端隐藏就万事大吉。比如图书库存小于0的数据,服务端查询时就该过滤掉,而不是等到前端渲染时才去判断。
注册用户是系统的主体角色,拥有完整的购书链路操作权限。注册登录后,可以进行个人信息维护、收货地址管理、图书加入购物车、提交订单、查看历史订单、取消订单、图书评论等操作。用户的鉴权方式我采用了Token机制(具体方案下文细说),每次请求都会在拦截器中校验Token的有效性,并从Token中解析出用户ID,为后续的“数据归属校验”提供依据——用户只能查看和操作属于自己的订单和地址,这是数据安全的最低要求。
管理员负责整个平台的运营维护,功能模块包括图书管理(新增、编辑、上下架、库存调整)、订单管理(发货、退款处理、订单状态查看)、用户管理(禁用/启用账号)、分类管理和统计数据(销量排行、库存预警)。
1.2 功能模块清单
最终落地的功能模块分为前台和后台两大块,我列一份当时自己整理的功能明细,给你做个参照:
前台用户端:
- 用户注册、登录、退出,个人信息编辑与密码修改
- 图书分类浏览、书名搜索、价格区间筛选、销量排序
- 图书详情页(图书封面、简介、作者、出版社、ISBN编码、库存状态、用户评论列表)
- 购物车(添加图书、修改数量、删除条目、全选/批量删除、选中项合计金额)
- 订单确认(关联收货地址,生成订单,订单状态初始为待付款)
- 订单管理(订单列表按状态分类展示:待付款、待发货、待收货、已完成、已取消)
- 图书评论与评分(仅限已购买该图书的用户可评论)
- 收货地址管理(新增、编辑、删除、设置默认地址)
后台管理端:
- 数据看板:商品总数、用户总数、今日订单数、本月销售额
- 图书管理:图书列表(分页+多条件搜索)、新增/编辑图书、上架/下架、库存调整
- 分类管理:分类树、新增/编辑/删除分类,删除前校验分类下是否有图书
- 订单管理:全量订单列表、按状态筛选、订单详情、发货操作、退款审核、取消订单
- 用户管理:用户列表、禁用/启用、重置密码
这样一套功能设计下来,删掉了哪些东西?没有做会员积分、没有做优惠券叠加、没有做图书推荐算法、没有物流接口对接。我的取舍标准很简单:项目要完整展示“你对业务的理解”,而不是“你听说过多少概念”。物流接口对接这类功能,涉及第三方平台资质调用,如果在本地开发环境根本无法真实测试,做了也是摆设。把订单状态流转、库存一致性这些核心链路做扎实,价值远高于堆砌没有业务闭环的功能点。
2. 技术选型与整体架构设计
网上书店系统的技术选型,我在做之前给自己定了三条原则:第一,技术栈必须是主流且资料丰富的,遇到问题搜索三分钟能找到答案;第二,每个框架只采用其最核心、最稳定的特性,不做激进的应用;第三,前端后端都能在本地跑起来,不需要依赖云服务器。
2.1 后端技术栈:SpringBoot为核心的原因
后端选择SpringBoot,可以说是当前Java Web开发的默认答案,但我会把具体理由讲透。
SpringBoot解决了传统SSH/SSM框架配置繁琐的痛点。传统的Spring MVC项目需要维护web.xml、spring-mvc.xml、spring-mybatis.xml等一堆XML配置文件,光是配置扫描包、事务管理器、视图解析器就要折腾半天。SpringBoot采用自动化配置的思路,引入对应场景的starter依赖,框架会自动装配好默认配置。比如引入了spring-boot-starter-web,内嵌的Tomcat、Jackson序列化、WebMVC基础配置就都准备好了;引入了spring-boot-starter-validation,参数校验器也就自动注册了。
这种设计哲学就是约定大于配置,让开发者把精力从“怎么配框架”转移到“怎么写业务”上来。网上书店系统里有大量CRUD和状态流转操作,SpringBoot能够让项目在十分钟内启动起来进入业务开发,这对开发效率的提升非常显著。
我用SpringBoot 2.7.x版本,搭配MyBatis-Plus作为持久层框架。为什么不选Spring Data JPA?我的考量是:书店管理系统的查询场景非常多样,图书列表要支持分类级联查询、订单报表要关联用户和订单项、统计接口要写聚合SQL,MyBatis-Plus的灵活SQL能力更适合这种“查询条件多变”的系统需求。同时MyBatis-Plus提供了BaseMapper和IService,单表CRUD完全不用手写SQL,LambdaQueryWrapper配合条件构造器,代码可读性也不错。
数据库选了MySQL 8.0,字符集统一utf8mb4,排序规则utf8mb4_general_ci。InnoDB引擎支持事务和行级锁,订单生成、库存扣减这些核心操作必须依赖数据库事务保证一致性。Web服务器直接使用SpringBoot内置的Tomcat,默认8080端口,生产环境打包成Jar运行,不额外配置外部服务器,减少环境差异。
2.2 前端技术栈:Vue 2 + Element UI的务实选择
前端我选的是Vue 2.6.x + Element UI 2.15.x + Vue Router 3 + Vuex 3 + Axios,用Vue CLI 4创建工程。为什么不选Vue 3?项目落地时Vue 3生态虽然已经在推进,但Element Plus还处于快速迭代期,稳定性和资料丰富度不如Element UI成熟。网上书店这类管理型系统,核心是表单、表格、对话框、分页这些中后台组件,Element UI表现稳定,第三方封装组件也多,踩坑成本最低。
Vue 2的组件化开发模式让前后端分离变得很自然。以书架列表页为例,一个BookList组件负责展示图书网格,内部包含Card子组件展示单本图书的信息卡片,再有Pagination分页组件。组件之间通过props传递数据、通过$emit抛事件,数据流方向单向清晰,后面维护时定位bug非常顺利。
Axios负责HTTP请求。我在封装Axios时做了三件重要的事情:第一,统一的baseURL配置,开发环境代理到后端服务端口,生产环境通过Nginx反向代理到后端;第二,请求拦截器自动从localStorage读取Token并添加到请求头;第三,响应拦截器统一处理HTTP状态码和后端业务码,401状态码直接跳转到登录页,其他错误信息通过Message组件全局提示。
前端路由采用Vue Router的路由懒加载。每个模块的组件单独打包成chunk,只有当访问对应路径时才加载对应JS文件。这样做的收益非常直观——系统首页的加载速度明显提升,因为首屏不需要加载全量代码。另外一个设计细节是对路由做了全局前置守卫,未登录用户访问需要鉴权的页面时直接重定向到登录页,这是前端层面的第一道访问控制。
2.3 系统架构分层逻辑
整个系统在逻辑上分为四层,每层的职责严格单一。我把分层的设计思路单独说一下,因为在后来的代码审查中,这个做得好不好直接决定了代码的维护难度。
表现层(Controller层):只负责接收HTTP请求、参数校验、调用业务层接口、封装统一返回结果。Controller里不写任何业务逻辑,一个方法对应一个URL端点。
业务层(Service层):业务规则都在这层实现。比如用户下单时,订单业务方法内要完成创建订单、扣减库存、清空购物车相关条目三个操作,并且这三个操作必须在同一事务内。事务的边界放在Service层,是Spring事务管理的默认语义,Controller层的方法不在事务管理范围内。
持久层(Mapper层):就是MyBatis-Plus的BaseMapper接口扩展,只做数据库的增删改查操作,不做业务判断。复杂查询通过XML文件里的自定义SQL实现。
领域对象层(Entity/Model层):对应数据库表和前端展示对象,细分为Entity、DTO、VO。其中Entity对应数据库表字段,与数据库一一对应;VO是返回给前端展示的对象,比如图书详情页要返回图书信息+分类名称+评论统计,单独定义BookDetailVO,而不是直接返回Entity。
我一直坚持一个观点:Entity、VO、DTO必须区分开。虽然这会增加几个类的代码量,但好处是避免数据库字段直接暴露给前端,面向前端的字段增加了,数据库结构调整不影响前端接口的兼容性。
3. 数据库设计与核心表结构拆解
数据库设计是这类业务系统的地基,地基不牢,业务逻辑写得再漂亮也是空中楼阁。我按“用户—商品—订单”这条核心链路设计表结构,所有表都包含create_time、update_time两个通用字段,用MyBatis-Plus的自动填充功能统一维护。
3.1 用户与权限存储设计
用户表users结构如下:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键自增 |
| username | varchar(50) | 登录用户名,唯一索引 |
| password | varchar(100) | BCrypt加密后的密码 |
| nickname | varchar(50) | 昵称 |
| varchar(100) | 邮箱 | |
| phone | varchar(20) | 手机号 |
| role | tinyint | 角色:0-用户 1-管理员 |
| status | tinyint | 状态:0-正常 1-禁用 |
| create_time | datetime | 注册时间 |
密码没有采用MD5加盐这种传统方式,而是直接用Spring Security框架中的BCryptPasswordEncoder。BCrypt算法自带随机盐,每次加密同样的明文得到不同的密文,数据库中即使出现两个相同密码的用户,存储的密文也不同。而且BCrypt计算速度刻意设计得较慢,这让暴力字典攻击的成本大幅上升。用户表上建的唯一索引是username,同时业务逻辑里注册接口也校验了username是否已存在,数据库层面再做一层保障,避免并发注册时用户名重复。
管理员也用同一张表,通过role字段区分。这种设计的考量是:管理员的认证逻辑和普通用户完全一致,只是后续的接口鉴权不同。系统启动时通过CommandLineRunner初始化一个默认管理员账号,用户名和初始密码从application.yml配置文件中读取,降低首次部署的配置成本。
3.2 图书与分类的关联设计
图书表books和分类表categories的设计是经典的一对多关系。
categories表字段:
- id:主键
- name:分类名称,比如文学小说、计算机技术、历史传记、少儿读物
- sort:排序值,数值小的排前面
- status:是否启用
books表字段:
- id:主键
- title:书名
- author:作者
- publisher:出版社
- isbn:国际标准书号
- category_id:外键关联分类表
- price:定价
- discount_price:售价,实际的销售价格
- stock:库存数量
- sales:销量,每下一单就累加
- cover_url:封面图地址
- description:图书简介
- status:上架状态 0-上架 1-下架
- create_time、update_time
外键约束的取舍值得一提。理论上应该给category_id建外键约束,但实际开发中我对大表之间一般不使用物理外键,而是通过业务逻辑维护关联,也就是逻辑外键。物理外键在数据量大了之后会影响插入和更新性能,而且会导致一些数据库锁竞争问题。但逻辑外键需要开发者有很强的纪律性,删除分类前必须查询该分类下是否还有图书,否则就会出现孤儿数据。
图书列表的查询场景做了索引优化:组合索引(category_id, status)覆盖“按分类浏览上架图书”的高频查询条件;title字段建了普通索引,配合前端的模糊搜索。explain查看执行计划时,这两个查询都走了索引,扫描行数明显下降。
3.3 订单链路的关键表设计
订单相关的表是这套系统里业务复杂度最高、也最能体现设计水平的部分。如果订单表设计不合理,后面做订单状态流转会非常痛苦。
我设计了四张订单相关表:orders主表、order_items订单项表、cart购物车表、addresses收货地址表。
orders表核心字段:
- id:订单ID,我没有用数据库自增ID做订单号,而是单独生成一个18位业务订单号,格式为时间戳+随机数,这样订单号不会暴露订单量等商业数据
- order_no:业务订单号,唯一索引
- user_id:下单用户ID
- total_amount:订单总金额
- status:订单状态 0-待付款 1-待发货 2-待收货 3-已完成 4-已取消
- receiver_name:收货人姓名
- receiver_phone:收货人电话
- receiver_address:收货人地址
- remark:买家备注
- create_time、pay_time、ship_time、finish_time:各个状态流转的时间记录
order_items表核心字段:
- id、order_id、book_id、title(快照书名)、cover_url(快照封面)、price(快照价格)、quantity(购买数量)
- 订单项为什么要冗余图书信息?这算是我踩过坑总结出来的经验。下单后图书的价格和书名可能被管理员修改,甚至图书被下架删除。如果不做快照,用户查看历史订单时看到的信息就和下单当时不一致,会引发严重的售后纠纷。所以每个订单项都保存了下单时的书名、价格、封面,本质上是对订单数据的持久化保护。
cart购物车表相对简单:id、user_id、book_id、quantity,增加一个user_id + book_id的唯一索引,防止同一本书重复加入购物车。
addresses收货地址表:id、user_id、receiver_name、receiver_phone、province、city、district、detail_address、is_default。一个用户可以有多个地址,默认地址通过is_default字段标记,业务逻辑保证一个用户最多只有一个默认地址。
3.4 数据库事务与一致性方案
书店下单是一个典型的分布式事务场景的微缩版,同一个数据库但涉及多张表。用户点击“提交订单”后,业务层面要执行以下操作:使用当前选中的购物车数据创建订单、生成订单项、扣减图书库存、累加图书销量、清空对应购物车条目。这五个动作必须全部成功或全部失败,不能出现订单创建成功但库存没扣减的情况。
我在OrderServiceImpl的createOrder方法上加了@Transactional注解,Spring的事务管理机制会保证这个方法内所有的Mapper操作在同一个数据库事务中执行。事务中任何一步抛出RuntimeException,整个事务就回滚到最初状态。
这里有一个特别需要注意的细节:事务方法内部的try-catch会吞掉异常,导致事务无法回滚。所以网上书店项目里我做了强约束,创建订单的Service方法不捕获异常,异常统一抛给Controller层处理。Controller层的方法加了事务控制,对事务方法内所有检查点——库存不足、购物车为空、图书下架——统一抛出带业务提示的自定义异常。
库存扣减使用了乐观锁保护。更新语句是:UPDATE books SET stock = stock - #{quantity} WHERE id = #{bookId} AND stock >= #{quantity}。受影响行数为0,说明库存不足,立即抛出异常触发回滚。这种写法直接利用数据库的行级锁能力保证并发安全,不需要额外引入Redis分布式锁,对书店系统这个量级完全够用。
4. 核心功能模块的SpringBoot实现解析
分开表结构之后,我们进入后端核心模块的实现细节。前面把数据库和架构搭好,这一步才是真正写代码、跑功能的时候。我按“权限认证、商品浏览、购物下单、订单流转、后台统计”这几个模块挨个拆解设计与实现的关键点。
4.1 基于Token的用户登录认证机制
用户认证方案在JWT和Session之间我最终选了JWT。原因在于前后端分离架构下,后端不维护会话状态,前端JavaScript代码持有Token,每次请求通过Header传递,这样后端水平扩展多个实例时也不需要引入Session共享方案。
流程是这样的:用户提交用户名和密码,LoginController调用LoginService校验,认证通过后调用JwtUtil工具类生成Token字符串。Token中我封装了三个关键信息:用户ID、用户名、角色。工具类使用HS256算法签名,密钥配置在application.yml中,有效期为24小时。
前端拿到Token后存储在localStorage中,每次Axios请求都携带Authorization: Bearer 请求头。后端自定义拦截器JwtInterceptor实现了HandlerInterceptor接口,在preHandle方法中执行两种校验:第一,判断请求的URL是否在白名单中(比如登录接口、注册接口、图书浏览接口);第二,解析请求头中的Token,如果Token无效、过期或没有携带Token,直接返回401状态码,由前端跳转登录页。
这个拦截器的实现有一个关键的注意点:Interceptor中获取到的Token解析结果需要传递给Controller使用。我采取的方式是把用户ID解析出来放到HttpServletRequest的attribute中,Controller通过@RequestAttribute注解获取当前登录用户ID。也有同行采用ThreadLocal存储,但多线程环境下需要注意清理线程局部变量,防止内存泄漏。
JwtUtil工具类用io.jsonwebtoken库实现,核心方法就三个:generateToken(userId, username, role)、parseToken、isTokenValid。密钥至少32个字节,我用一个UUID字符串作为JWT密钥,确保每次签名不重复。
4.2 商品浏览与多条件检索实现
图书浏览是前端访问频率最高的接口,支撑首页书架、分类筛选、搜索三大场景。我写了一个统一的查询接口GET /api/books,接收pageNum、pageSize、keyword、categoryId、sortType等请求参数。
MyBatis-Plus的LambdaQueryWrapper在这个接口里发挥大作用,参数不同能构造出不同的查询条件。keyword参数会对书名进行LIKE模糊匹配。categoryId参数会精确匹配分类ID。动态SQL的能力体现在:某个参数为空,对应的查询条件就不拼上,这种动态拼接正是书店系统查询条件多变的解药。
sortType参数支持两种排序规则:sales代表按销量倒序,price代表按价格从低到高。分页查询用了MyBatis-Plus的Page对象,传入页码和每页数量,框架自动执行LIMIT语句并统计总记录数。最终返回结构里包含records列表、total总数、pages总页数、current当前页,前端直接把这些字段赋给分页组件。
图书详情接口GET /api/books/{id}则是另一种数据装配的典型体现。它读取book表记录,同时根据category_id查分类名称,根据id查该图书的评论列表,再计算评论的平均评分,最终组装成一个BookDetailVO返回前端。这个接口串起三张表的数据,也是前后端接口对接中最常用到VO对象的场景。
我强烈建议,阅读量多的接口要做好缓存意识。这个项目是按毕业设计标准写的,Redis不是必选项,但如果未来上线运营,图书详情和列表接口是Redis缓存的首选对象,把热点数据放在缓存里,数据库的压力能降低一个量级。
4.3 购物车与下单事务的完整链路
购物车操作相对简单。AddCart接口接收bookId和quantity,先查用户购物车中是否有同一本书,有则进行数量累加,没有则新增记录。这里同样用user_id + book_id唯一索引防止重复数据,即使并发提交也能靠数据库兜底。
真正体现事务功底的是下单接口,POST /api/orders。前端传过来的是选中购物车记录的ID数组列表和收货地址ID。后端逻辑如下。先根据地址ID查出收货信息,如果找不到就报“收货地址不存在”;再根据购物车记录ID列表查出购物车明细,同时关联查出图书信息和库存;接着计算总金额、循环校验每本书的库存是否充足;然后创建主订单和订单项,执行库存扣减和销量累加;最后批量删除已下单的购物车记录,事务提交后返回订单号。
计算总金额时出现过一个典型的边界问题——前端计算的总金额是否可靠?我的答案是坚决不信。前端传的总金额只能是展示参考,后端必须根据数据库中的售价重新计算每个订单项的小计和总金额。否则用户可以通过篡改前端请求,以低价购买高价值图书。这个规则适用于所有涉及金额计算的系统,采购、支付、退款同理。
4.4 订单状态机的设计策略
订单状态我定义了0到4五个状态,状态之间的流转约束如下:待付款可取消或付款,付款后进入待发货;待发货由管理员操作发货进入待收货;待收货由用户确认收货进入已完成。这里我没有开发完整的在线支付模块,而是做了一个支付模拟逻辑,用户点击“模拟付款”后直接置为支付完成,支付时间写入当前时间。真实系统中此处应对接支付平台的预下单API,但核心状态流转逻辑是通用的。
管理员后台的发货操作对应UPDATE语句:UPDATE orders SET status = 2, ship_time = NOW() WHERE id = #{id} AND status = 1。这个AND条件至关重要,它确保只能从待发货状态发到待收货状态,防止订单状态出现跳跃式错误。同样的原理,用户取消订单也用了UPDATE orders SET status = 4 WHERE id = #{id} AND status = 0的条件更新。如果受影响行数为0,就说明状态已经不是待付款,不能取消,这就是乐观锁思想在状态流转上的应用。
订单列表查询按用户ID和状态条件组合,管理员的订单列表则增加了用户搜索的维度。分页查询统一使用Page对象,时间倒序排序,让最新的订单出现在最前面。管理员订单详情接口会同时查询订单主表、订单项列表和用户简要信息,一次请求把详情页的所有数据返回。
4.5 后台管理模块的权限控制
管理员的接口和用户接口共用同一个拦截器校验Token,但额外加了一个角色校验的步骤。我按用户角色设置了访问权限,管理员才能访问的后台接口(图书管理、订单管理和用户管理模块)在Controller上加了一个自定义注解@RequireAdmin,拦截器解析出Token中的角色字段后判断是否拥有管理员权限。这种基于注解的权限控制实现很轻量,不需要引入Spring Security的全套过滤器链,业务侵入更小。
管理员的图书编辑操作里有一件事容易忽略——修改图书库存。图书编辑页面有独立的库存调整输入框,管理员直接输入新库存值保存。这与下单时的扣减库存操作走的是完全不同的SQL,一个是直接UPDATE覆盖库存,一个是条件扣减。两条路径并发时有可能出现数据不一致,比如管理员把库存改成10的同时,用户在秒杀一件商品后库存变成了9,而后台看到的最新值可能是10。我在实际项目中通过给books表增加version字段做了乐观锁,更新时校验version版本号,避免库存修改的覆盖冲突。
5. Vue前端页面与接口联调实录
前端工作量和后端相当,甚至更容易让人烦躁——接口不稳定时联调最磨人。这一部分把前端工程结构、核心页面实现、前后端联调的经验按顺序讲透。
5.1 前端工程结构与路由权限设计
前端工程用Vue CLI创建,src目录下的结构组织如下:
- src/api:按业务模块拆分的接口请求文件,比如book.js、order.js、user.js、admin.js
- src/assets:静态资源,比如Logo、默认图书封面图
- src/components:公共组件,比如Pagination、UploadImage、EmptyState
- src/router:路由配置文件,包含路由表、路由守卫
- src/store:Vuex状态管理,主要存储用户信息和登录状态
- src/views:页面级组件,按用户端和管理端分子目录,比如views/user下分Home、BookList、BookDetail、Cart、OrderList等页面文件,views/admin下分Dashboard、BookManage、OrderManage、UserManage等页面文件
- src/utils:工具函数,包括Axios封装、Token存取、公共校验方法
路由权限通过全局前置守卫实现。router.beforeEach钩子函数里做三个判断:第一个判断目标页面是否需要登录权限,通过路由meta字段的requiresAuth属性标记;第二个判断本地Token是否存在,不存在则跳转登录页;第三个判断目标页面是否需要管理员权限,通过meta.requiresAdmin标记,若当前用户不是管理员则跳转首页。这个前端路由防线虽然不能替代后端权限校验,但能显著改善体验,普通用户访问管理端时能立即被导走,而不是等接口返回401。
5.2 用户端核心页面实现
注册/登录页是两个表单页面,用Element UI的el-form组件配合内置校验规则实现。用户名校验规则是3到20个字符,密码至少6位,邮箱格式用正则校验。登录页有个小交互点:登录成功后,系统读取localStorage中存储的“上次登录后想访问的页面路径”,如果有则跳转到那个路径,否则返回首页。这个功能是用户被登出后,重新登录可以回到之前的浏览位置,细节虽小但体验提升明显,值得做。
图书列表页是典型的搜索+列表布局。顶部的搜索栏包含关键词输入框、分类下拉框、排序单选组。中间内容区用el-row和el-col栅格布局展示图书卡片。分页区使用el-pagination组件,当前页码和每页条数绑定到data中的分页参数,切换时重新调用查询接口。这里有一个性能优化点:搜索条件和分页参数的变化,统一触发一个loadBooks()方法,方法内部把当前参数串成请求参数对象传递给后端接口,不需要写多个重复的请求方法。
购物车页面利用Vuex管理选中状态。el-table表格展示购物车商品,每条记录有默认勾选框,我通过row-key绑定购物车记录ID,配合table的@selection-change事件实时计算选中商品的总金额。底部的结算栏固定在页面底部,选中数量、合计金额实时刷新,点击“去结算”后把选中的购物车记录ID数组传给下单接口。
订单提交页相当于结算页。页面加载时调用地址列表接口展示用户已有地址,用户选择收货地址后点击“提交订单”,后端下单成功会返回订单号。页面跳转到订单列表页,并展示一条“下单成功,订单号XXX”的成功提示。如果下单失败(比如库存不足),后端返回的业务错误信息经过Axios拦截器弹出提示,用户停留在结算页修正参数后重新提交。
订单列表页用el-tabs按状态分类展示。每个Tab页签下展示对应状态的订单卡片,卡片内展示订单号、商品缩略图、商品名、单价、数量、合计金额和状态标签。待付款状态下显示“付款”“取消订单”两个操作按钮;待收货状态显示“确认收货”按钮。所有操作按钮触发后都调用对应的接口,成功后刷新当前Tab的数据,这个刷新逻辑通过调用当前Tab对应的查询方法完成。
5.3 管理端页面实现要点
管理端页面我都采用统一的布局结构。左侧是导航菜单,顶部是管理员信息和退出入口,内容区是路由对应的页面组件。菜单项与前端路由一一对应,每个页面都遵循以下模式:顶部是筛选条件区域,中间是数据表格,底部是分页器和操作按钮。
图书管理页是管理端最复杂的页面。新增/编辑图书用el-dialog对话框承载表单。表单里关于封面上传的设计用的是Element UI的el-upload组件,前端把图片传给后端的一个独立上传接口,后端保存到本地上传目录,并把可访问的URL路径返回,前端把URL存储到表单的coverUrl字段中。上传时要注意给文件名加时间戳前缀,避免同名文件覆盖。图书状态用el-switch组件控制上架/下架,切换时调用更新状态接口。
订单管理页面的表格列非常长:订单号、用户名、商品数、总金额、下单时间、订单状态、操作按钮。订单状态用el-tag展示不同颜色便于识别,待发货状态的操作列中显示“发货”按钮,点击后弹窗确认发货,确认后表格数据刷新。管理员点击订单号或“详情”按钮,会打开订单详情对话框,里面通过el-descriptions描述列表展示收货地址、订单项明细、状态流转时间等信息。
5.4 前后端联调复盘
联调阶段排障最多的是两类问题:跨域和参数格式不匹配。
跨域问题在开发环境是通过Vue CLI的devServer代理解决的。我在vue.config.js里配置:
module.exports = { devServer: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } }这样前端开发服务器监听在8081端口,请求/api开头的路径时会代理转发到后端的8080端口,浏览器端只看到同源请求,不存在跨域问题。生产环境则用Nginx做反向代理,前端静态文件由Nginx托管,/api路径代理到Java服务端口。
参数格式不匹配的坑则主要出在日期和JSON序列化上。Java后端默认的日期序列化格式是ISO格式,前端拿到后显示直接报错。我通过Jackson的配置类全局统一了日期格式为“yyyy-MM-dd HH:mm:ss”,并且设置了时区为GMT+8。另一个注意点是MyBatis-Plus的LocalDateTime类型需要引入jsr310模块的序列化适配,我在pom.xml里显式放了jackson-datatype-jsr310依赖,避免出现LocalDateTime序列化报错的困扰。
联调时还有一个高频问题:后端返回的Long类型ID在JavaScript中会丢失精度。如果数据库主键是用雪花算法生成的那么ID位数非常长,超过JavaScript能够精确表示的数字范围(2^53-1),前端拿到的ID末尾几位会变成0,导致后续操作失效。好在这个项目使用自增主键所以没有碰到这个问题,但如果未来换用雪花ID,需要留意给Long字段加@JsonSerialize(using = ToStringSerializer.class)注解转成字符串处理。
6. 常见问题排查记录
开发过程中遇到的每个问题,这里按“现象、原因、解决”的形式复盘一份速查表,很多细节是我在解决后才意识到可以避免的。
| 现象 | 原因分析 | 解决方案 |
|---|---|---|
| 前端请求后端接口一直报403 | 拦截器判断请求路径不在放行名单中,未登录时拒绝访问 | 排查Controller路径与放行名单中的路径匹配规则,通常是因为请求URI带上了项目前缀 |
| 下单成功后库存没变化 | Service方法内异常被try-catch吞掉了,事务没生效 | 事务方法内不捕获异常,或者try-catch后重新抛出RuntimeException |
| 用户下单后订单列表是空的 | 订单表与订单项表的分页查询条件有误,用户ID传错 | 检查订单查询是否根据当前登录用户ID过滤,确认Token解析出的用户ID正确 |
| 图片上传成功但页面不显示 | 上传文件的URL路径写成了本地磁盘路径,无法通过浏览器访问 | 上传目录配置为项目静态资源映射路径,或者通过Nginx映射到/upload目录 |
| 前端修改图书信息后列表不刷新 | 更新成功后没有重新加载表格数据 | 统一封装refreshData方法,在新增、编辑、删除操作成功回调中调用 |
| 中文内容出现乱码 | 数据库连接URL没有指定utf8字符集编码 | jdbc连接串添加characterEncoding=utf8参数,数据库表建立时统一utf8mb4 |
| 长时间不操作后请求报401 | Token有效期太短,过期了 | 前端在Axios响应拦截器捕获401后跳转登录页,后端可根据需求延长有效期或做刷新机制 |
6.1 事务回滚失效的两个隐蔽场景
事务问题是我在这个项目里排查最久、最深度的一类问题,这里多说两句。Spring的@Transactional默认只在RuntimeException(未检查异常)下回滚,如果方法抛出的是检查异常(比如IOException),事务是不会回滚的。所以业务代码通常定义自己的RuntimeException子类BusinessException,在业务校验不通过时统一抛出,让事务感知并回滚。
还有一个隐蔽场景是自调用导致的代理失效。假设一个类中有方法A和方法B,两者都在同一个类中,方法A调用this.B()时,@Transactional如果只加在方法B上,通过this调用不会经过Spring的代理对象,事务注解完全不生效。解决方案是通过注入自身代理对象或者把需要事务控制的方法拆到另一个Service类中,由外层调用。
6.2 多环境配置与打包部署经验
项目要交付给别人运行时,最怕“在我电脑上能跑”的情况。我在application.yml中做了多环境配置:application-dev.yml对应本地开发环境,数据库连接串指向本机的MySQL;application-prod.yml对应生产环境,数据库地址、文件上传路径都可以单独配置。启动命令中通过--spring.profiles.active=prod来指定激活哪个环境的配置。
后端打包用Maven,执行mvn clean package命令产出可执行Jar包。SpringBoot的打包插件spring-boot-maven-plugin默认把依赖一起打包成Fat Jar,无需在服务器上安装Tomcat,直接执行java -jar命令就能运行。
前端构建执行npm run build,产物在dist目录,把dist下的全部文件上传到Nginx的html目录,然后配置Nginx将/api路径反向代理到Java服务端口。如果做本地演示,也可以把dist目录放到SpringBoot的静态资源目录中,交给SpringBoot直接托管,减少一个Nginx依赖项。但生产环境强烈建议用Nginx,因为静态文件的高并发处理能力远比Java容器强。
6.3 部署上线前的检查清单
项目在最终交付验收之前,我整理了一份自检清单,跟着这个走一遍大概率能避开低级失误:
- 数据库初始化脚本可以重复执行吗?使用CREATE TABLE IF NOT EXISTS,准备好种子数据,首启动要能直接看到演示数据
- 管理端初始密码是否已在文档中说明?要求首次登录后修改密码的具体入口要在文档中标注清楚
- 上传目录的路径是否通过配置管理?避免写死绝对路径导致换服务器就崩
- 后端日志配置级别是否合理?生产环境最低Info级别,开发环境Debug级别
- 统一异常处理类是否覆盖全部Controller?避免出现未捕获异常给前端返回一堆英文堆栈信息
- 关闭SpringBoot的Swagger或接口文档调试入口,避免把接口细节暴露给外部用户
7. 个人经验总结与扩展建议
整个网上书店系统从设计、开发到部署,给我最深的体会是:一类项目的开发成败,三分在代码,七分在需求分析和架构设计的提前量。
提前量体现在哪些地方?在开发“购物车”模块时就想到下单时要对购物车条目加锁防止重复提交,在开发“用户管理”时就想到封号后已经登录的用户应该被强制退出,在开发“图书管理”时就想到编辑图书时库存字段要和订单扣减做并发协调。这些设计如果等到联调时才暴露,返工成本极高。所以在项目里,我宁愿在开始时多画半小时状态流转图,也不愿上线后花一整天去处理脏数据。
如果你准备在这个项目基础上做扩展,我的建议是抓住三条主线。第一条是业务深度线,给订单模块对接真实的支付回调逻辑,引入RabbitMQ或者Spring事件机制做支付成功后的异步库存操作和通知推送;第二条是技术广度线,给项目引入Redis做热点书籍的缓存,引入Elasticsearch做全文检索,把搜索体验从LIKE模糊匹配提升到分词检索级别;第三条是工程化方向线,把Docker部署和GitLab CI集成进来,让项目一键镜像构建,一键发布到服务器,这部分在企业面试中是很受关注的亮点。
最后再分享一个我亲测有效的调试小技巧:在SpringBoot配置里开启SQL日志打印,mybatis-plus.configuration.log-impl设置为org.apache.ibatis.logging.stdout.StdOutImpl。开发阶段几乎每次接口调用都能在控制台看到完整的SQL语句、绑定参数和执行结果。碰到数据不匹配、查询条件不对之类的诡异问题,第一件事先看SQL,九成能定位出问题方向。项目上线时记得关闭这个日志,避免SQL信息直接触发敏感数据暴露的风险。