简介:一份基于SpringBoot与Vue的网上书城完整项目,配套MySQL数据库脚本,主要面向计算机相关专业毕业设计学生及需要项目实战练习的Java学习者,解决选题难、系统不完整、无法运行等痛点。压缩包共627个文件,涵盖Java后端源码、Vue前端页面(html/css/js)、SpringBoot配置XML、依赖JAR包、实体类与数据库SQL脚本,以及图片、字体等静态资源,整体体积109.8MB,目录结构清晰,便于直接导入IDE运行。系统涵盖图书分类展示、关键词检索、购物车管理、订单生成与后台数据管理等核心模块,界面美观、操作简单,管理功能齐全,具有较高的实际应用价值。已有1068人学习下载,项目经严格调试确保可运行,可直接作为毕业设计交付或二次开发基础。附带项目文档、数据库脚本与开发工具,能帮助快速理解设计思路与表结构关系,便于二次扩展和维护。
1. 基于SpringBoot+Vue的网上书城毕业设计:先看懂这个 zip 再动手
拿到“网上书城源码+数据库”的 zip,第一反应最好不要是双击解压然后一股脑拖进 IDEA。大量毕业设计项目的 zip 里同时存在src、sql、doc、dist、README甚至target目录,真正要看的只有三块:后端 SpringBoot 源码、前端 Vue 源码、数据库脚本。如果搞混启动顺序,最常见的报错不是代码崩了,而是后端连不上数据库,前端请求打不到后端。
这个项目的技术栈几乎是一致的:SpringBoot 做 REST API,Vue 3 + Vite 做页面,MySQL 8 存数据,Redis 可选做缓存。所谓“网上书城”,核心功能逃不开图书分类、商品列表、购物车、订单、会员登录这五件事。适合拿来练手的人不只是毕业生;刚工作一两年的后端想理解前后端分离怎么交付,也可以从这套结构里找到答案。这篇就把“解压 zip 之后到你能在本地浏览器看到书城首页”中间的所有环节拆开。
2. 解压后第一件事:看清“源码+数据库”放在哪
2.1 网上书城项目 zip 的典型目录结构
常见毕业设计交付包不是单项目工程,而是多模块混合。我一般会先用dir或ls看一眼顶层,再决定下一步怎么启动。
| 目录/文件 | 常见内容 | 是否要改 |
|---|---|---|
bookstore-server | SpringBoot 后端,含pom.xml | 改application.yml |
bookstore-web | Vue 前端,含package.json | 改.env.development |
bookstore.sql | MySQL 建库、建表、初始数据 | 导入前按需改库名 |
数据库设计.md | 表结构说明与 ER 图 | 答辩用 |
README.md | 启动步骤 | 先读这个 |
有一些 zip 会把前端编译产物dist也打进去,或者包含target、node_modules。不要把node_modules放进自己的代码仓库,target目录在别人电脑上也没有意义。你要保留的最小交付结构,是后端全部源码、前端全部源码、一份可执行的 SQL 脚本、一份启动说明。
2.2 用 MySQL 命令行或 Navicat 把数据库跑起来
网上的书城 SQL 脚本通常一次性包含建库、建表、插入书籍样例数据。先打开命令行确认 MySQL 服务正在运行,然后执行:
mysql -uroot -p进入 MySQL 后执行:
SOURCE D:/downloads/bookstore.sql; SHOW DATABASES; USE bookstore; SHOW TABLES;说明:
SOURCE后面是 SQL 文件的绝对路径,\换成/更不容易转义出错。- 如果脚本里没有
CREATE DATABASE bookstore;,需要手动先建库:CREATE DATABASE IF NOT EXISTS bookstore DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; utf8mb4一定要坚持用,否则书籍简介里出现 emoji 字符时会报Incorrect string value。
用 Navicat 的图形界面更简单:新建连接后,右键“运行 SQL 文件”,选中脚本即可。跑完脚本后重点检查book、user、order、order_item四张表是否都带数据。书城项目最容易出现的问题不是表结构缺失,而是book表封面图片字段存的还是本地磁盘路径,改天换了电脑图片就加载不出来,这一步先记住。
2.3 SpringBoot 数据源配置:application.yml 的 5 个必调参数
后端启动之前,先打开bookstore-server/src/main/resources/application.yml,确认数据源指向。常见写法:
server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/bookstore?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: root password: 123456 mybatis-plus: configuration: map-underscore-to-camel-case: true global-config: db-config: id-type: auto参数含义:
url里的bookstore要和 SQL 脚本建的库名一致。serverTimezone=Asia/Shanghai用来阻止 JDBC 时区报错。MySQL 8 与 SpringBoot 2.x 搭配时,报The server time zone value 'Öйú±ê׼ʱ¼ä'就是没有这个参数。allowPublicKeyRetrieval=true是 MySQL 8.0 使用 caching_sha2_password 认证时的常见配置项,不写可能在连接时报Public Key Retrieval is not allowed。map-underscore-to-camel-case: true负责把数据库book_name自动映射到 Java 实体类字段bookName,省掉大量@TableField注解。- 密码不要提交到 GitHub。如果要提交,可以用环境变量或
jasypt做密文配置,这点答辩时是加分项,网上搜索“SpringBoot yml 密文”可以找到成熟方案。
3. SpringBoot 后端:书城的登录、分页、下单是如何串起来的
3.1 实体类、Mapper 与 MyBatis-Plus 的约定
书城项目的用户、图书、订单三类实体通常和表同名。后端开发中最省事的做法是引入 MyBatis-Plus,而不是再手写一堆BaseMapper实现。举个例子,图书实体的常见写法:
@Data @TableName("book") public class Book { @TableId(type = IdType.AUTO) private Integer id; private String bookName; private String author; private String publisher; private BigDecimal price; private Integer stock; private String cover; private String description; private Integer categoryId; private Integer sales; }说明:
@TableName("book")指定表名,防止实体类名与表名不一致时找不到表。@TableId(type = IdType.AUTO)对应数据库主键自增。有些源码会用IdType.INPUT,那是应用层传 ID,如果你的book表没数据但前端添加图书时报主键重复,多半是这里写错。- 字段命名遵循驼峰,配合 2.3 节配置可以省掉写 MapUnderscoreToCamelCase。
Mapper 层更简单:
@Mapper public interface BookMapper extends BaseMapper<Book> { }不需要写 XML 也能完成单表增删改查。只有多表关联统计时才需要自定义 SQL,比如按分类统计销量。
3.2 图书分页查询接口的完整链路
网上书城首页几乎都要做“分类 + 关键词 + 分页”的搜索。Controller 层我会这样写:
@RestController @RequestMapping("/api/books") public class BookController { @Autowired private BookService bookService; @GetMapping public Result<Page<Book>> page( @RequestParam(defaultValue = "1") Integer pageNum, @RequestParam(defaultValue = "12") Integer pageSize, @RequestParam(required = false) Integer categoryId, @RequestParam(required = false) String keyword) { return Result.success(bookService.queryPage(pageNum, pageSize, categoryId, keyword)); } }Service 层实现通过 LambdaQueryWrapper 完成动态条件拼接:
@Override public Page<Book> queryPage(Integer pageNum, Integer pageSize, Integer categoryId, String keyword) { Page<Book> page = new Page<>(pageNum, pageSize); LambdaQueryWrapper<Book> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(categoryId != null, Book::getCategoryId, categoryId) .and(keyword != null && !keyword.isEmpty(), w -> w.like(Book::getBookName, keyword) .or().like(Book::getAuthor, keyword)) .orderByDesc(Book::getSales); return bookMapper.selectPage(page, wrapper); }代码逻辑要点:
eq(categoryId != null, ...)表示只有条件成立时才拼接 SQL,有效防止空参数把分类过滤掉。and套or是为了让(book_name like ? OR author like ?)作为整体,再用and拼到分类条件后面,不会出现or把查询条件边界打破的情况。orderByDesc(Book::getSales)是“按销量排序”,这是书城首页最常用的排序规则;如果需要按价格、上架时间排序,可以扩展一个sort参数。
3.3 登录鉴权:JWT 与拦截器的实现
书城项目的高频答辩问题是“用户下单时怎么识别身份”。常见方案是登录成功后签发 JWT,前端把 Token 存到 localStorage,后续请求在请求头带Authorization: Bearer <token>。服务端拦截器负责校验 Token 并放行:
@Component public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (request.getMethod().equals("OPTIONS")) { return true; } String token = request.getHeader("Authorization"); if (token != null && token.startsWith("Bearer ")) { Claims claims = JwtUtil.parseToken(token.substring(7)); if (claims != null) { request.setAttribute("userId", claims.get("userId")); return true; } } response.setStatus(401); response.getWriter().write("{\"code\":401,\"msg\":\"未登录或登录已过期\"}"); return false; } }细节说明:
- 为什么先放行
OPTIONS?前后端分离时,浏览器发送跨域预检请求是OPTIONS,如果拦截器把它拦了,前端会看到 CORS 错误。 Bearer前缀是行业惯例,解析时用substring(7)去掉。- 把
userId放进requestattribute,Controller 里直接(Integer) request.getAttribute("userId")就能拿到当前用户,下单逻辑里不需要前端再传 userId,避免越权风险。
JwtUtil 的核心只有两个方法,生成 Token 时设置过期时间,解析 Token 时捕获异常:
public static String generateToken(Integer userId, String username) { return Jwts.builder() .setSubject(String.valueOf(userId)) .claim("username", username) .setExpiration(new Date(System.currentTimeMillis() + 7 * 24 * 3600 * 1000)) .signWith(SignatureAlgorithm.HS256, SECRET_KEY) .compact(); }3.4 下单扣库存:事务和乐观锁缺一不可
书城的订单表至少有关联字段:order_id、total_price、status、user_id。下单接口里如果只有新增订单,没有同步扣库存,下单后库存不减少,这就是功能缺陷。正确的做法是在事务方法内先查库存、再扣库存、最后插入订单明细:
@Transactional(rollbackFor = Exception.class) public Integer createOrder(IList<CartItem> items, Integer userId) { Order order = new Order(); order.setUserId(userId); order.setStatus(0); orderMapper.insert(order); for (CartItem item : items) { Book book = bookMapper.selectById(item.getBookId()); if (book == null || book.getStock() < item.getNum()) { throw new BizException("库存不足"); } int rows = bookMapper.deductStock(book.getId(), item.getNum()); if (rows == 0) { throw new BizException("库存已被抢完"); } OrderItem orderItem = new OrderItem(); orderItem.setOrderId(order.getId()); orderItem.setBookId(book.getId()); orderItem.setNum(item.getNum()); orderItem.setPrice(book.getPrice().multiply(BigDecimal.valueOf(item.getNum()))); orderItemMapper.insert(orderItem); } return order.getId(); }注意这行 SQL:
UPDATE book SET stock = stock - #{num} WHERE id = #{id} AND stock - #{num} >= 0这里没有用select ... for update锁行,而是用“条件更新”保证并发安全。bookMapper.deductStock返回影响行数,如果库存不够,影响行数为 0,直接抛出异常让事务回滚,订单也不会残留。@Transactional(rollbackFor = Exception.class)必须带上,默认只回滚 RuntimeException,自定义BizException如果继承 Exception,不加 rollbackFor 会导致订单插入成功但库存扣减失败。
3.5 端口冲突和后端无法启动的排查顺序
SpringBoot 启动失败时,优先看控制台第一行APPLICATION FAILED TO START,而不是滚动中间的一长串异常。常见三类:
Port 8080 was already in use:netstat -ano | findstr 8080,结束对应 PID 的进程,或者改server.port。Failed to configure a DataSource: 'url' attribute is not specified:说明application.yml没被加载,检查文件是否在resources目录下,文件名有没有拼错。java.sql.SQLSyntaxErrorException:SQL 脚本里的表名和实体@TableName不一致,优先打开数据库看真实表名。
4. Vue 前端:从依赖安装到路由守卫让页面真正可访问
4.1 用 npm 初始化 Vue 3 项目并安装依赖
如果 zip 里没有node_modules,前端必须先安装依赖。使用 Vite 创建的项目,package.json里已经有脚本,直接执行:
cd bookstore-web npm install npm run dev参数解读与踩坑:
npm install如果报 ERESOLVE 错误,可以先加--legacy-peer-deps重试,很多网上书城源码是 Vue 2 时代遗留的依赖树,放到 Vue 3 环境会需要降级 npm 的严格解析。npm run dev启动后控制台显示Local: http://localhost:5173/,浏览器打开这个地址。- 如果刚装完依赖打开页面一片空白,先按 F12 看 Console,多半是
vite.config.js没配置server.host或代理。
4.2 配置 Vite 代理:把 /api 转发到 SpringBoot
开发环境下前端端口 5173 与后端 8080 不同,直接发 axios 请求会跨域。最省事的方案是让 Vite 开发服务器代理请求:
// vite.config.js import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins: [vue()], server: { host: '0.0.0.0', port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })说明:
- 请求路径写
/api/books,浏览器看到的请求还发给 5173,Vite 再把请求转发给8080。这样前端代码不会出现localhost:8080硬编码。 changeOrigin: true会把请求头里的Host改写成后端地址,避免部分后端鉴权时校验 Host 失败。- 生产部署时不能依赖这个代理,需要让 Nginx 把
/api反向代理到 SpringBoot,或者在 SpringBoot 里配置跨域允许白名单。
4.3 前端路由与登录守卫的配合
网上书城项目通常有首页/index、图书详情页、购物车、后台管理多个模块。Vue Router 的路由表里要对需要登录的页面做守卫:
import { createRouter, createWebHistory } from 'vue-router' const router = createRouter({ history: createWebHistory(), routes: [ { path: '/', component: () => import('@/views/Home.vue') }, { path: '/cart', component: () => import('@/views/Cart.vue'), meta: { requiresAuth: true } }, { path: '/admin', component: () => import('@/views/Admin.vue'), meta: { requiresAuth: true, admin: true } } ] }) router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.meta.requiresAuth && !token) { next({ path: '/login', query: { redirect: to.fullPath } }) } else { next() } })这里有一个前端的常见误区:路由守卫只能拦住不会抓包的用户。真正的权限校验必须以后端接口返回 401/403 为准,不能因为前端有守卫就忽略后端鉴权。
4.4 axios 封装与商品列表页的数据对接
项目里的request.js通常会封装 axios 实例,统一抛出错误、附带 Token。核心代码:
import axios from 'axios' import { ElMessage } from 'element-plus' const request = axios.create({ baseURL: '/api', timeout: 10000 }) 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 && error.response.status === 401) { localStorage.removeItem('token') window.location.href = '/login' } else { ElMessage.error(error.response?.data?.msg || '网络异常') } return Promise.reject(error) } ) export default request调用商品分页接口时就很干净:
const res = await request.get('/books', { params: { pageNum: 1, pageSize: 12, categoryId: this.categoryId, keyword: this.keyword } }) this.bookList = res.data.records要点:
baseURL: '/api'与代理配置成一套,前端代码切换环境时不用改接口路径。- 拦截器统一处理
401,比每个页面单独判断更省事。 res.data.records对应第 3 章后端Result<Page<Book>>的 JSON 结构,如果后端返回结构是{code, msg, data},这里必须取data.records。
4.5 商品图片加载不了的常见解决顺序
书城项目列表页经常出现图片裂开,排查思路按优先级排序:
| 现象 | 原因 | 解决 |
|---|---|---|
图片路径是http://localhost:8080/upload/1.jpg | 后端静态资源映射没有配置 | 在 SpringBoot 加WebMvcConfigurer,把/upload/**映射到本地磁盘目录 |
图片路径是相对路径/upload/1.jpg | 前端代理只处理/api,不处理/upload | 在 Vite proxy 里再加一条/upload代理 |
| 数据库封面字段值完全为空 | SQL 脚本导入时丢了非空列默认值 | 补UPDATE book SET cover='/images/default.png' WHERE cover IS NULL |
5. 答辩前收尾:用最小成本验证书城项目的完整性
5.1 用 curl 直接打穿后端接口
浏览器页面能看到不代表接口没有隐藏问题。我建议答辩前用命令行验证三个关键接口:
curl http://localhost:8080/api/books?pageNum=1&pageSize=5 curl -X POST http://localhost:8080/api/auth/login \ -H "Content-Type: application/json" \ -d '{"username":"test","password":"123456"}' curl http://localhost:8080/api/orders \ -H "Authorization: Bearer <替换成上面返回的token>"第一条返回records数组说明分页正常;第二条返回带token的 JSON 说明登录正常;第三条如果返回 200 而不是 401,说明登录态传递成功。如果第三条报 401,先检查前端 axios 头是Authorization还是token,再检查后端拦截器读取的是同一个头名称。
5.2 三个容易被问倒的改进点
毕业设计源码可以朴素,但你要能说出下一步怎么做。面试官问“你这个项目有什么不足”,可以从这几个角度接:
- 图书搜索目前是 SQL
like模糊查询,数据量上千条后性能下降,改进方向是接入 Elasticsearch 或 MySQL 全文索引,索引字段选book_name和author。 - 用户密码如果直接明文存在数据库,属于安全缺陷。升级方案是 BCrypt 加盐哈希,Spring Security 自带
BCryptPasswordEncoder,改动量不大。 - 订单表没有分表策略,高并发下单会全部打到一张表。可以提前在订单表加
create_time索引,回答时先说“目前通过事务和库存条件更新保证一致性,未来可引入消息队列和分库分表”。
5.3 交付前把 zip 重新打包成干净版本
最后建议重新整理交付包:删除target、node_modules,保留一份建库脚本,把 README 里的数据库密码改成引导用户自己修改。用压缩软件选择 zip 格式时,注意不要把整个外层目录重复包进去,解压后应该是bookstore-server和bookstore-web平级,而不是套一层以 zip 命名的文件夹。这个细节虽然不起眼,但改完后整个项目在另一台电脑上从解压到跑通的成功率会高很多。
本文还有配套的精品资源,点击获取