简介:一份基于Spring Boot与Vue前后端分离架构的网上书店项目代码,面向Java全栈学习者、计算机相关专业在校生及准备毕业设计或课程设计的人员。后端采用Spring Boot整合Spring Data JPA、MySQL、Redis与Shiro,覆盖数据持久化、缓存与权限控制;前端使用Vue配合ElementUI和Axios,实现页面交互与接口请求,并接入阿里云对象存储处理文件上传场景。压缩包共105个文件,约2.7MB,包含37个Java后端源码、23个Vue前端页面、数据库SQL脚本、配置文件及项目说明文档,目录结构清晰,便于对照学习与二次开发。代码经过运行测试,bookshop目录为打包前端静态资源后的后端项目,可独立部署并运行于8443端口,便于快速启动查看效果;在此基础上修改即可拓展功能,无论用于项目入门还是毕设参考都有实际价值。目前已有143人学习下载。
1. 网上书店这套“前后端分离”源码,为什么值得拿来直接起步
手头要交一套网上书店的项目作业,或者公司想快速搭一个图书销售演示站,最怕的就是从零开始写——用户、图书、订单、购物车,每个模块都是体力活。这个标题打包的就是一整套可运行的资产:SpringBoot负责把接口和业务逻辑立住,Vue负责把页面和数据交互做出来,数据库SQL把表结构和测试数据一起准备好。反直觉的一点是:图书检索、下单这些核心业务其实不难,大部分翻车都发生在环境配置与数据库连接上,而这套方案最值钱的部分,正是把“跑起来”这件事提前替你踩平了。它适合要交作业的学生,也适合想用最短路径上手“基于SpringBoot和Vue的项目”的工程师。
2. 项目骨架与核心模块:SpringBoot和Vue各管哪一块,选型怎么定
2.1 为什么选“前后端分离”而不是JSP单体架构
网上书店这个业务,以前最常见的写法是SpringMVC + JSP,页面写在服务端,后端既要处理数据又要渲染HTML。只要导航栏改个文字、列表加一列,哪怕只是改个标签,也得重启应用才能看到效果,前端工程师和后端工程师还会因为改同一个JSP文件打架。
换成SpringBoot + Vue之后,分工变得很清楚:SpringBoot只输出JSON接口,Vue在浏览器里负责页面跳转、表格渲染、表单校验。书单查询、下单选中的是“哪个接口”,页面长什么样、点击之后做什么,全部交给前端。对网上书店这个规模来说,前后端分离最大的好处是“修改互不牵连”:后端改了接口字段,前端只要同步调整请求参数;前端调整页面布局,后端根本不用重启。
选型上,常见做法是SpringBoot 2.7.x配Java 8或11,Vue选Vue 3(如果用Vue CLI创建,路由用vue-router 4,状态管理用Pinia),数据库用MySQL 8.0。SpringBoot版本不建议一上来就追3.x,3.x要求JDK 17,很多教学资料和旧版插件还在用JDK 8,版本差带来的报错会让人误以为是自己代码的问题,这个坑我在帮别人排查项目时见过太多次。
2.2 从零初始化:后端和前端的目录结构长什么样
后端用Spring Initializr生成,前端用Vue CLI创建,两条命令就能把骨架搭起来。常见做法是后端命名bookstore-backend,前端命名bookstore-frontend,放在同一个目录下,互不干扰但方便一起提交,交付给对方时直接发整个源码目录即可。
# 后端:在 start.spring.io 里选 Maven、Java 8/11、SpringBoot 2.7.x # 依赖勾选 Spring Web、MyBatis Framework、MySQL Driver # 生成后导入 IDEA,目录大致是这个形态: # bookstore-backend/ # ├── src/main/java/com/example/bookstore/ # │ ├── controller/ # 接口层,只做参数接收和结果返回 # │ ├── service/ # 业务层,事务、库存扣减在这里写 # │ ├── mapper/ # MyBatis 数据访问接口 # │ ├── entity/ # 与数据库表对应的实体类 # │ └── config/ # 跨域配置、拦截器 # └── src/main/resources/ # ├── application.yml # └── mapper/ # MyBatis 的 XML 文件(如果用 XML 写 SQL) # 前端:用 Vue CLI 初始化,随后安装路由与状态管理依赖 npm install -g @vue/cli vue create bookstore-frontend cd bookstore-frontend npm install vue-router@4 axios pinia目录拆分是有讲究的:controller里不写业务规则,service里不直接操作数据库,mapper只负责SQL。网上书店虽然小,但如果一开始就把SQL写在Controller里,后面加一个“下架图书不参与查询”的需求,就会牵一发动全身。前端安装的vue-router@4对应Vue 3,axios负责HTTP请求,pinia替代Vuex来管理登录态和购物车数据。安装依赖时注意Node版本,Vue CLI 5要求Node 12以上,Vite则要求14.18以上。
2.3 核心模块拆分:用户、图书、购物车与订单
网上书店的业务边界可以拆成四个模块,每个模块对应一张核心表、一组后端接口、一个前端页面。拆模块时不要凭空造概念,就拿用户购书这条主链路说事:用户必须登录才能下单,登录后可以浏览图书、把书加入购物车、从购物车结算生成订单,订单生成后库存要扣减。
| 模块 | 后端职责 | 前端页面 | 核心表 |
|---|---|---|---|
| 用户 | 注册、登录、签发Token | 登录页、注册页 | user |
| 图书 | 分类查询、关键字搜索、详情 | 首页、搜索页、详情页 | book |
| 购物车 | 加入、修改数量、删除(也可纯前端实现) | 购物车页 | cart(可选) |
| 订单 | 创建订单、扣库存、查询订单 | 结算页、订单列表 | orders、order_item |
购物车这一栏很多人会纠结要不要单独建表。纯前端方案是把购物车数据存localStorage,刷新不丢、登录后合并也方便,适合演示型项目;要求“换设备购物车还在”,就必须建cart表。我一般建议先做纯前端购物车,把后端精力集中在订单和库存上,等主流程跑通再决定是否加表,这也是数据库设计的“最小可用”思路。
3. 用SpringBoot把网上书店后端立起来:配置、实体与核心接口
3.1 application.yml里的三个必调配置:端口、数据源、MyBatis-Plus
后端项目拿到手,第一步不是写接口,而是把配置文件调对。网上书店的SpringBoot配置里,最关键的是数据源。MySQL 8.0的JDBC驱动是com.mysql.cj.jdbc.Driver,很多人还写着旧版的com.mysql.jdbc.Driver,启动时直接报ClassNotFoundException。
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/bookstore?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.bookstore.entityURL里四个参数一个都不能少:characterEncoding=utf8保证中文不乱码;serverTimezone=Asia/Shanghai处理MySQL 8时区报错;useSSL=false避免本地连接时SSL握手警告;allowPublicKeyRetrieval=true解决MySQL 8的Public Key Retrieval is not allowed。这最后一个参数,我见过有人卡了两天,全是在Stack Overflow上翻答案翻出来的。
log-impl配置成StdOutImpl,会在控制台打印每条SQL,这在联调时几乎是作弊器——前端说“接口报错了”,一看后端日志,SQL直接打在屏幕上,是字段对不上还是参数没传,一目了然。MyBatis-Plus的type-aliases-package可以不写,写了之后XML里resultType可以直接写Book而不是全限定类名。
3.2 从实体到Controller:图书分页查询的最小实现
图书查询是网上书店最核心的只读接口。用MyBatis-Plus干活,实体类用@TableName和@TableId标注,Mapper接口继承BaseMapper<Book>,就能直接获得selectById、insert、updateById这些基础方法,不用手写任何SQL。分页查询单独配置一个分页插件,否则Page对象不生效,这是MyBatis-Plus最容易踩的配置坑。
@RestController @RequestMapping("/api/book") public class BookController { @Autowired private BookService bookService; @GetMapping("/page") public Result page(@RequestParam(defaultValue = "1") Integer page, @RequestParam(defaultValue = "10") Integer size, @RequestParam(required = false) String keyword) { IPage<Book> p = bookService.pageBooks(page, size, keyword); return Result.success(p); } @GetMapping("/{id}") public Result detail(@PathVariable Long id) { return Result.success(bookService.getById(id)); } }Service里只做一件事:构造查询条件。关键字为空就查全部,不为空就按书名或作者模糊查询,按创建时间倒序。分页参数page从1开始,size默认10,前端每页展示多少条由这个值决定。返回结果用统一的Result包装,包含code、message、data三个字段,前端axios拦截器只看code,省去每次判断状态码的重复劳动。
这里有一个值得注意的设计:@PathVariable Long id接收路径参数,@RequestParam接收查询参数。前端请求/api/book/3是详情,请求/api/book/page?page=1&size=10&keyword=Java是列表。如果把详情接口也设计成/api/book/detail?id=3,语义上不够直观,前后端对接时也更容易产生歧义。
3.3 下单接口:事务与库存扣减为什么必须绑在一起
网上书店最容易出业务漏洞的是下单。常见的错误写法是先插入订单,再扣库存,两步之间没有任何保护——一旦扣库存失败,订单已经落库,用户支付了钱但图书实际没货。正确做法是用@Transactional把“创建订单主表、插入订单明细、扣减库存”绑成一个原子操作,其中任何一步失败,整个事务回滚。
@Transactional(rollbackFor = Exception.class) public Long createOrder(OrderDTO dto, Long userId) { Long orderId = saveOrder(dto, userId); // 插入 orders 主表 for (OrderItemDTO item : dto.getItems()) { int updated = bookMapper.deductStock(item.getBookId(), item.getCount()); if (updated == 0) { throw new BusinessException("库存不足或图书已下架"); } orderItemMapper.insert(item); } return orderId; }bookMapper.deductStock对应一条UPDATE语句:UPDATE book SET stock = stock - #{count} WHERE id = #{bookId} AND stock >= #{count}。这里用stock >= #{count}作为条件,MySQL会锁住这一行直到事务提交,两根手指同时下单同一本书时,后执行的那一方会拿到updated == 0,直接抛出业务异常。这个方案比“先SELECT查库存再UPDATE”安全得多,后者在并发场景下会出现超卖。
rollbackFor = Exception.class必须写。@Transactional默认只回滚RuntimeException,如果你抛的是自定义的BusinessException,而且它继承的是Exception,不写rollbackFor的话事务不会回滚,订单照样落库。这是一个“黑匣子”一样的坑,排查时看日志一切正常,但数据库里就是多了一笔脏数据。
3.4 登录鉴权:JWT拦截器与前端路由守卫的配合
登录是网上书店的门槛。常见做法是用户登录成功后,后端用JWT签发一个token,前端存到localStorage,每次请求带上Authorization: Bearer <token>。后端用拦截器统一校验,只放行/api/user/login和/api/user/register,其余接口全部校验token。
public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if ("OPTIONS".equals(request.getMethod())) { return true; } String token = request.getHeader("Authorization"); if (token != null && token.startsWith("Bearer ")) { Long userId = JwtUtil.parse(token.substring(7)); if (userId != null) { request.setAttribute("userId", userId); return true; } } response.setStatus(401); return false; } }拦截器里记得放行OPTIONS请求,否则前端跨域预检请求会直接撞在401上。userId放到request attribute里,Controller通过@RequestAttribute Long userId拿到当前登录用户,下单接口就能直接把userId传入Service,避免前端把userId当参数传上来——传上来的userId是不可信的,这属于基本的安全边界。
4. 用Vue把前端搭起来:路由、请求封装与联调代理
4.1 vue.config.js代理:为什么后端开了CORS还不够
前端项目开发时的默认端口是3000,后端接口跑在8080,浏览器直接请求http://localhost:8080/api/book/page会发生跨域。后端开启@CrossOrigin可以解决浏览器层面的跨域,但每次调试都要把devServer地址改成全路径,非常麻烦,而且生产环境下前端静态资源和后端接口部署在不同服务器时,这种写法也撑不住。
更干净的做法是在vue.config.js里配devServer代理,让前端开发服务器把/api开头的请求转发到后端:
const { defineConfig } = require('@vue/cli-service') module.exports = defineConfig({ devServer: { port: 3000, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })配置生效后,前端页面里axios.get('/api/book/page')实际上请求的是http://localhost:3000/api/book/page,devServer以服务端身份把请求转发给http://localhost:8080/api/book/page,浏览器认为自己始终在与同源服务器通信,跨域问题从根源消失。
changeOrigin: true必须保留,它会把请求头里的Host字段改成target地址,避免后端服务器在虚拟主机环境下把请求不当拒绝。设置完之后记得重启npm run serve,vue.config.js的修改不会热更新,这是常见误用。
4.2 axios封装:统一携带token、统一处理后端错误
网上书店的几乎所有页面都要发起请求,如果不封装axios,每个页面都要写一遍请求头设置、错误弹窗代码,时间长了会形成一堆重复代码。我一般会创建src/utils/request.js,导出一个配置好的axios实例,并在拦截器里加入token和错误处理逻辑。
import axios from 'axios' import router from '@/router' const service = axios.create({ baseURL: '/api', timeout: 10000 }) service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) service.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { alert(res.message || '请求失败') return Promise.reject(new Error(res.message)) } return res }, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('token') router.push('/login') } else { alert('网络异常,请稍后重试') } return Promise.reject(error) } ) export default servicebaseURL: '/api'配合上一节的代理,所有请求路径只需写/book/page,环境切换时只改一处。响应拦截器里code !== 200时直接弹窗提示,这样页面代码里就不用写那么多if (res.data.code === 200)判断。401统一跳转登录页,避免每个页面单独做token失效处理。
4.3 路由与登录态:从首页到结算页的跳转链
前端路由是用户操作的地图。网上书店至少要包含首页、图书详情页、购物车、订单列表、登录注册这几个页面。Vue Router 4的写法与Vue 2版本差别不大,但动态路由参数在详情页跳转时要注意传参方式。
const routes = [ { path: '/', component: () => import('@/views/Home.vue') }, { path: '/book/:id', component: () => import('@/views/BookDetail.vue') }, { path: '/cart', component: () => import('@/views/Cart.vue'), meta: { requiresAuth: true } }, { path: '/orders', component: () => import('@/views/Orders.vue'), meta: { requiresAuth: true } }, { path: '/login', component: () => import('@/views/Login.vue') } ] router.beforeEach((to) => { if (to.meta.requiresAuth && !localStorage.getItem('token')) { return '/login' } })/book/:id是动态路由,首页点击图书卡片时用router.push({ path: '/book/' + id })跳转,详情页里通过route.params.id取参数。这里有一个新手常犯的错:把整本书的对象用query传过去,比如?bookId=3&title=书名,不仅URL冗长,刷新页面后参数还会丢失。正确做法是只传id,详情页用自己的接口重新拉数据。
路由守卫的requiresAuth标记了哪些页面需要登录。购物车页面在用户未登录时跳转登录页,登录成功后再回跳。后端JWT拦截器是第一道防线,前端路由守卫是第二道,两层配合才能保证未登录用户既看不到页面,也请求不到受保护的接口。
5. 数据库SQL设计与避坑:建表语句、外键与五类翻车现场
5.1 四张核心表的建表SQL,字段与类型怎么定
数据库是网上书店最容易忽视却最难改的部分。表结构设计得好,后端接口写起来行云流水;设计得烂,后面加个字段要改Entity、Mapper、XML、前端表单四处。下面这组SQL是这套方案最常见的初始表结构,直接导入MySQL即可。
CREATE TABLE user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, nickname VARCHAR(50), create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE book ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(200) NOT NULL, author VARCHAR(100), price DECIMAL(10,2) NOT NULL, stock INT NOT NULL DEFAULT 0, category VARCHAR(50), cover VARCHAR(255), description TEXT, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_category (category), KEY idx_title (title) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE orders ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE, user_id BIGINT NOT NULL, total_amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_orders_user FOREIGN KEY (user_id) REFERENCES user(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE order_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, book_id BIGINT NOT NULL, book_title VARCHAR(200), price DECIMAL(10,2) NOT NULL, count INT NOT NULL, CONSTRAINT fk_item_order FOREIGN KEY (order_id) REFERENCES orders(id), CONSTRAINT fk_item_book FOREIGN KEY (book_id) REFERENCES book(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;订单表命名用orders而不是order,因为ORDER是SQL关键字,直接SELECT * FROM order会报语法错误。用反引号`order`包起来虽然也能跑,但每写一条SQL都要小心避让,不如一开始就换个表名。
order_item里冗余了book_title和price字段,这是有意为之的:订单生成后,图书可能改名、可能涨价、可能下架删除,订单明细必须保存下单那一刻的快照。查询订单详情表时不用再关联book表,也避免了图书删除导致订单明细联查失效。这个设计不是过度冗余,而是订单系统的常见做法。
5.2 五类真实翻车现场:现象、原因、解决
翻车一:导入SQL文件时一直报错,位置还莫名其妙。
现象:把SQL文件拖进Navicat执行,弹出一堆红色报错,通常停在某个字段定义上,比如create_time附近报check the manual that corresponds to your MySQL server version。
原因:SQL文件里混入了MySQL 8.0不支持的注释语法,或者是用Word/WPS编辑SQL文件导致引号被换成中文全角引号。
解决:用Notepad++或VS Code打开SQL文件,把中文标点替换成英文标点;更稳的方法是新建一个空数据库,在Navicat里右键“运行SQL文件”,而不是直接复制粘贴执行。编码选择UTF-8,避免中文注释导致乱码报错。
翻车二:连接MySQL时提示Access denied for user 'root'@'localhost',密码明明是对的。
现象:用户名密码确认无误,但JDBC连接就是被拒绝;用命令行登录却可以。
原因:MySQL 8.0默认的认证插件是caching_sha2_password,老版本的JDBC驱动不支持这个协议,驱动在握手阶段就失败了。
解决:把JDBC驱动升级到mysql-connector-j8.0.x以上,或者在URL里加allowPublicKeyRetrieval=true。不要顺手把root密码改成mysql_native_password,这个插件在MySQL 8.1以上版本已经被标记为废弃,治标不治本。
翻车三:接口返回的图书价格变成9.989999或者10.000001,显示在页面上非常难看。
现象:图书价格在设计时用了FLOAT,比如9.99,查询出来变成9.989999999999,页面直接渲染成一串很长的小数。
原因:FLOAT和DOUBLE是浮点数,二进制无法精确表示十进制小数,价格这种需要精确保存的字段根本不该用浮点类型。
解决:设计表时价格一律用DECIMAL(10,2),在Java实体中用BigDecimal接收。如果已经有浮点字段的存量数据,执行ALTER TABLE book MODIFY COLUMN price DECIMAL(10,2) NOT NULL一次性修正。顺便检查订单总金额有没有被浮点污染。
翻车四:删除一条图书数据,数据库一直卡住,最后报外键约束失败。
现象:图书列表里删除一本没有订单引用的书,竟然报Cannot delete or update a parent row: a foreign key constraint fails。
原因:看似没有订单引用,实际上order_item表里存在该书的旧记录,fk_item_book外键约束不允许删除被引用行的父记录。
解决:确认业务规则后,先删子表数据,再删主表数据;或者在外键定义时加ON DELETE CASCADE。但CASCADE要谨慎,订单属于财务数据,图书删除连带着把订单明细一起干掉会出大问题。稳妥做法是执行DELETE FROM order_item WHERE book_id = ?,再删除book记录。
翻车五:启动后端项目时提示Table 'bookstore.book' doesn't exist,但数据库里明明能看到表。
现象:MySQL库里表存在,Navicat也查得到数据,但SpringBoot启动跑MyBatis的SQL时报表不存在。
原因:MySQL表名区分大小写。Linux环境下表名是Book,代码里写的是book,或者反过来。Windows开发环境通常不区分大小写,一上线Linux立刻暴露问题。
解决:约定所有表名、字段名一律小写,实体类@TableName("book")显式指定表名。同时检查lower_case_table_names配置,生产环境的MySQL通常需要设为1(不区分大小写),但设置它必须发生在初始化之前,跑起来后再改会导致已有数据无法访问。
6. 把源码变成能演示的项目:最小启动顺序与两小时验证清单
6.1 最小启动顺序,照着做就能跑
拿到这套网上书店的源码,不要急着看代码,按顺序操作一台干净环境上十分钟就能起来。第一步是创建数据库,执行CREATE DATABASE bookstore DEFAULT CHARSET utf8mb4,然后导入SQL文件。第二步是检查application.yml里的数据库用户名密码,改成你本机的值。第三步先启动后端SpringBoot,看到控制台打印Tomcat started on port(s): 8080确认成功。第四步启动前端,npm install装依赖后npm run serve,浏览器打开http://localhost:3000。至此项目已经可以演示了。
一个常见现象是后端启动成功但前端页面报错,先在浏览器F12的Network面板看请求状态:如果是500,说明后端SQL或参数有问题,把IDEA控制台里的SQL日志拉出来看;如果是404,检查代理配置和后端@RequestMapping路径是否一致;如果是纯前端报错,八成是路由路径里/book/:id的id没传递成功。整个排查链路按这个顺序走,基本不会绕弯路。
6.2 用curl做两小时验收,别只依赖页面点来点去
页面点着看起来正常,不代表接口真的健壮。我习惯用curl把主链路快速过一遍:注册用户、登录拿token、查询图书、下订单。比如登录接口,返回的JSON里应该有token字段,这个值能持续证明后端鉴权是否生效。用curl的好处是能精确看到HTTP状态码和响应体,不经过前端组件层的干扰,问题定位更干净。
验证完接口,再验证事务:在图书表里把某本书库存改成5,然后并发调用下单接口两次,每次买3本,第二次应当收到“库存不足”的异常,数据库里该书的库存应停在0而不是变成负数。这一步能确认@Transactional和stock >= #{count}条件是否真的在保护你的数据。走完这条验收线,这套网上书店才真正放在你怀里了。
我早年最常翻车的就是“页面能打开,但一登录就白屏”,排查半天发现是后端8080端口被占用,Tomcat实际启动在8081,代理还指向8080。后来养成习惯:每次启动项目先看控制台端口日志,再说话。希望帮到你,少走这一步,能省一整晚。
本文还有配套的精品资源,点击获取