ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

SpringBoot+Vue3商城毕设源码全解析:从数据库到前后端联调部署

SpringBoot+Vue3商城毕设源码全解析:从数据库到前后端联调部署

简介:这是一份面向计算机专业毕业设计使用的商城购物网站项目,基于SpringBoot+Vue3.0+MySQL实现前后端分离架构,包含完整Java源码与SQL数据库脚本,覆盖前台购物与后台管理两大模块。前台支持用户注册登录、商品浏览查询、下单购买、个人资料与地址管理;后台包含用户、商品类型、商品、订单、评论及系统设置管理,权限角色清晰,适合学习SpringBoot与Vue3整合开发的中级学习者。资源包共314个文件,以Java后端代码、Vue前端组件、CSS样式和JavaScript逻辑文件为主,另附SQL脚本与项目配置文档,压缩包仅910KB,轻量易用。目前已有184人学习下载,可直接导入开发工具运行,也可作为课程设计或毕业设计参考。

1. 商城购物网站这套毕设,到底能帮你证明什么能力

很多本科毕业设计最终都会落在 SpringBoot+Vue3.0+MySql 商城购物网站这个组合上。它不新,但它把后端接口、前端页面和数据库脚本三条线分得很清楚,刚好适合在答辩时讲明白:你不需要证明自己发明了什么,只需要让老师看到一套能完整跑通的交易链路。注册登录、商品列表、加入购物车、下单、支付状态模拟,这几条线走通,项目就能立住。

我照着这类“完整源代码+数据库脚本”的商城改过三轮方案,最深的体会是,真正卡进度的往往不是页面好看不好看,而是数据库脚本能不能一次导入、前后端联调时接口能不能一次通过。下面这份拆解,从目录结构讲到后端、前端、数据库脚本,最后落到打包部署和避坑,新手按顺序复现,熟手可以直接跳到你关心的章节补参数。

2. 目录结构与请求链路:拿到源码先看哪几个文件,加购请求如何走完整条链路

2.1 商城源码包的标准目录:backend、frontend、sql 三块缺一不可

一份典型的 SpringBoot+Vue3 商城源码包,目录通常长这样。拿到手之后不要急着启动,先按这个结构对一遍,缺哪块补哪块,然后再谈跑通。

shopping-mall/ ├── backend/ # SpringBoot 后端 │ ├── pom.xml │ └── src/main/ │ ├── java/com/mall/ │ │ ├── controller/ # 接口层,只做参数接收和结果返回 │ │ ├── service/ # 业务层,订单、购物车逻辑都在这 │ │ ├── mapper/ # 数据访问层,MyBatis-Plus 的 Mapper │ │ ├── entity/ # 实体类,和数据库表一一对应 │ │ ├── config/ # 拦截器、跨域、MyBatis-Plus 配置 │ │ └── common/ # Result 包装、全局异常、JWT 工具 │ └── resources/ │ ├── application.yml │ ├── mapper/ # 自定义 XML 文件(可留空) │ └── static/ # 打包后的前端文件,最后要用 ├── frontend/ # Vue3 前端 │ ├── package.json │ ├── vite.config.js │ └── src/ │ ├── api/ # 按模块拆接口,如 product.js、cart.js │ ├── views/ # 页面组件 │ ├── router/ # 路由,含登录守卫 │ ├── store/ # Pinia 状态 │ └── utils/ # axios 封装 └── sql/ └── mall.sql # 建库建表 + 初始数据

我一般会先看 sql 目录,再看 backend 的 application.yml,最后才打开前端页面。原因很简单:商城这类项目的表关系不复杂,但错一个字段名,后端 Mapper 映射就会全乱。先看数据库脚本能建立全局关系,看代码的时候就不至于纠结“这个 categoryId 哪来的”。

常见做法是前后端分两个目录,但也有毕设源码把前端文件直接放在后端 resources/templates 里,用 Thymeleaf 渲染。这里不推荐那种写法,因为你的标题写的是 Vue3.0,Vue 干的是前后端分离的活。保持分离,答辩时你可以讲“前端通过 Restful 接口与后端交互”,这是加分项。

2.2 后端 application.yml 的四个参数:数据源、时区、驼峰映射、日志输出

后端能不能连上 MySQL,99% 的问题都集中在 application.yml。我建议你拿到的第一个文件不是 Controller,而是它。一份能跑通的配置大概长这样:

server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/mall?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: 123456 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai mybatis-plus: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.mall.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

先说 driver-class-name。MySQL 8.0 和 5.7 都能用com.mysql.cj.jdbc.Driver,但如果你遇到“Class not found”或者驱动版本太旧,需要确认 pom.xml 里引的是不是mysql-connector-j。再强调一次 url 里的serverTimezone=Asia/Shanghai,不加它,MySQL 8.0 会直接报时区错误,这是最常见的翻车点。

map-underscore-to-camel-case: true解决的是数据库字段create_time到 Java 属性createTime的自动映射。很多源码里实体类不写驼峰映射,导致前端拿到的时间字段全为 null,排查起来非常隐蔽。log-impl建议打开,它能让你在控制台直接看到每次执行的 SQL,联调时能少走很多弯路。

开发阶段数据源用 root 没问题,但演示环境我建议你新建一个专用账号,只授权 mall 库,免得启动时提示密码不对你也分不清是密码错还是权限错。

2.3 一次“加入购物车”请求的完整链路:前端带 Token 过来,后端用事务兜底

把目录结构和配置讲完,再走一条最核心的链路:加购。前端会这样调用接口:

POST /api/cart/add?productId=3&quantity=1 Header: Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...

后端负责接收这个请求的 Controller 长这样:

@RestController @RequestMapping("/api/cart") public class CartController { @Resource private CartService cartService; @PostMapping("/add") public Result<Void> add(@RequestAttribute("userId") Long userId, @RequestParam Long productId, @RequestParam(defaultValue = "1") Integer quantity) { cartService.addToCart(userId, productId, quantity); return Result.ok(); } }

注意参数里的@RequestAttribute("userId")。这个值不是前端传的,是后端 JWT 拦截器解析 Token 后塞进请求里的。这样设计的好处是 Controller 里不需要反复查用户表,也避免前端改一个 userId 就能越权加购。

真正的业务逻辑在 Service 层,核心代码是这样:

@Service public class CartServiceImpl implements CartService { @Resource private CartMapper cartMapper; @Resource private ProductMapper productMapper; @Override @Transactional(rollbackFor = Exception.class) public void addToCart(Long userId, Long productId, Integer quantity) { // 1. 检查商品是否存在且上架 Product product = productMapper.selectById(productId); if (product == null || product.getStatus() != 1) { throw new BizException("商品不存在或已下架"); } // 2. 按 userId + productId 查购物车 Cart cart = cartMapper.selectOne(new LambdaQueryWrapper<Cart>() .eq(Cart::getUserId, userId) .eq(Cart::getProductId, productId)); // 3. 不存在则新增,存在则叠加数量 if (cart == null) { cart = new Cart(); cart.setUserId(userId); cart.setProductId(productId); cart.setQuantity(quantity); cart.setChecked(true); cartMapper.insert(cart); } else { cart.setQuantity(cart.getQuantity() + quantity); cartMapper.updateById(cart); } } }

这段代码包含两个关键点。第一是@Transactional(rollbackFor = Exception.class),加购虽然只有一次 insert 或 update,但在完整商城里,这个事务会延伸到“下单同时减库存”,没有事务就会出现扣了库存但订单没写进去的问题。第二是 LambdaQueryWrapper 的eq(Cart::getUserId, userId),它生成的条件是user_id = ? AND product_id = ?。这里用 Lambda 而不是字符串,是为了防止字段改名后编译不报错、运行才报错的情况。

加购请求的完整流向是:前端 Axios 把 Token 放进请求头 → Spring Boot 拦截器校验 Token 并把 userId 放进 RequestAttribute → Controller 接收参数 → Service 查商品、查购物车、执行写操作 → MyBatis 执行 SQL → MySQL 返回结果 → Result 对象统一返回给前端。这条链路你答辩时能一口气讲完,项目就成功了一半。

3. 后端功能实现:SpringBoot 版本怎么选、商品查询怎么写、登录权限怎么拦

3.1 pom.xml 依赖与版本组合:用 Java 8 配 SpringBoot 2.7.18,拒绝“版本翻车”

后端代码能不能编译过,第一关就是 pom.xml 的版本组合。我见过不少同学拿到源码第一件事就是把 SpringBoot 升到最新版,然后所有代码开始报错。这里给一个保守但稳定的组合:JDK 1.8 + Spring Boot 2.7.18 + MyBatis-Plus 3.5.3.2 + JJWT 0.11.5。

<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.2</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.5</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency> </dependencies>

为什么不推荐 Spring Boot 3?因为 3.x 强制要求 JDK 17,并且把javax.*换成jakarta.*。很多基于 Spring Boot 2.x 写的拦截器、Servlet 代码拿过来全要改包名。对毕设来说,运行稳定比版本新更重要。选择 2.7.18 的原因是它是 2.x 最后一个稳定版本,既有新修复又保持老写法,网上搜得到的代码几乎都能直接抄。

MySQL 驱动这里只写mysql-connector-j是因为 Spring Boot 2.7 的版本管理已经帮你指定版本,不需要自己写版本号。如果手动指定了过高的驱动版本,反而会和 MySQL 5.7 的服务端认证插件冲突,那个报错信息非常绕,根本看不懂。

3.2 商品列表分页查询:MyBatis-Plus 条件构造器 + 排序 + 逻辑删除

商城最常用的接口是商品分页查询,条件通常包含关键字、分类、上下架状态,还要按发布时间排序。用 MyBatis-Plus 的 LambdaQueryWrapper 写最省事。

@Service public class ProductServiceImpl extends ServiceImpl<ProductMapper, Product> implements ProductService { @Override public Page<Product> pageQuery(int page, int size, String keyword, Long categoryId) { LambdaQueryWrapper<Product> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(Product::getStatus, 1); // 只查上架商品 if (StringUtils.hasText(keyword)) { wrapper.like(Product::getName, keyword); } if (categoryId != null) { wrapper.eq(Product::getCategoryId, categoryId); } wrapper.orderByDesc(Product::getCreateTime); // MySQL 排序,新商品在前 return this.page(new Page<>(page, size), wrapper); } }

这段代码里StringUtils.hasText来自 Spring 框架,区分了 null、空串和全空格字符串。搜索框如果传了空字符串,hasText会返回 false,就不会拼出LIKE '%%'这种全表查询,这一点对 MySQL 性能很友好。

this.page(new Page<>(page, size), wrapper)是 MyBatis-Plus 内置分页。但注意,光写这段不会生效,必须配置分页插件:

@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }

没有这个拦截器时,分页查询会变成全表查询,再把内存里的 List 切一段,数据量大了后页面非常卡。把DbType.MYSQL写成数据库类型,分页方言才会拼出正确的 LIMIT 语句。这个配置很多人漏掉,属于典型的“代码抄了但行为不对”。

3.3 JWT 登录和拦截器:把用户信息从 Token 里取出来,Controller 不再反复查库

商城系统的用户登录一般用 JWT 做无状态认证。用户登录成功后,后端生成一个包含 userId 的 Token,前端保存,后续每次请求都带上。我用 JJWT 0.11.5 写了一个简化版工具,足够毕设使用:

@Component public class JwtUtil { @Value("${mall.jwt.secret}") private String secret; @Value("${mall.jwt.expire}") private Long expire; public String createToken(Long userId, String username) { return Jwts.builder() .setSubject(username) .claim("userId", userId) .setExpiration(new Date(System.currentTimeMillis() + expire)) .signWith(Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)), SignatureAlgorithm.HS256) .compact(); } public Long getUserId(String token) { Claims claims = Jwts.parserBuilder() .setSigningKey(Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8))) .build() .parseClaimsJws(token) .getBody(); return claims.get("userId", Long.class); } }

注意两个细节。第一是secret不能太短,HS256 要求密钥至少 256 位,也就是 32 个字节以上。写成mall.jwt.secret=abcdefghijklmnopqrstuvwxyz123456这种长度才够,太短启动时会报“Signing key must be at least 256 bits”的错。第二是expire用毫秒,mall.jwt.expire=86400000表示一天过期。

拦截器负责在请求进入 Controller 前解析 Token:

@Component public class JwtInterceptor implements HandlerInterceptor { @Resource private JwtUtil jwtUtil; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String auth = request.getHeader("Authorization"); if (StringUtils.hasText(auth) && auth.startsWith("Bearer ")) { Long userId = jwtUtil.getUserId(auth.substring(7)); request.setAttribute("userId", userId); return true; } response.setStatus(401); return false; } }

再在 WebMvcConfig 里注册拦截器,并放行登录注册接口:

@Configuration public class WebMvcConfig implements WebMvcConfigurer { @Resource private JwtInterceptor jwtInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(jwtInterceptor) .addPathPatterns("/api/**") .excludePathPatterns("/api/user/login", "/api/user/register", "/api/product/**"); } }

这里的.excludePathPatterns("/api/product/**")意味着商品列表和详情不需要登录就能看,这是商城前台的基本逻辑。后端接口只要带/api都被拦截,未登录返回 401,前端看到 401 再统一跳登录页。这套结构比在每个 Controller 里写 Session 判断干净得多。

4. 前端 Vue3.0 商城页面:创建项目、封装 Axios、用 Pinia 管住登录态

4.1 用 Vite 初始化 Vue3 项目并接入 Element Plus

前端部分我用 Vite 而不是 Vue CLI,原因是 Vite 启动快,开发时改代码热更新几乎无感。打开终端执行:

npm create vite@latest frontend -- --template vue cd frontend npm install npm install element-plus @element-plus/icons-vue axios pinia vue-router

--template vue生成的是 Vue3 + JavaScript 版本,不是 TypeScript。如果你的毕设要求里没有强制 TS,用 JavaScript 就够了,代码量更少,报错也更容易看懂。

生成后在 main.js 里接入 Element Plus:

import { createApp } from 'vue' import { createPinia } from 'pinia' import ElementPlus from 'element-plus' import 'element-plus/dist/index.css' import App from './App.vue' import router from './router' const app = createApp(App) app.use(createPinia()) app.use(router) app.use(ElementPlus) app.mount('#app')

这里把 Pinia、Router、Element Plus 全部全局挂上。按需引入 Element Plus 能减小打包体积,但对毕设项目来说,全量引入更省心,答辩演示时不会因为漏掉某个组件样式而翻车。

4.2 Axios 统一封装:环境地址切换、请求头带 Token、401 自动踢回登录页

整个前端只维护一个 axios 实例,这件事能避免你在 20 个页面里重复写请求头。封装在src/utils/request.js里:

import axios from 'axios' import { ElMessage } from 'element-plus' import { useUserStore } from '../store/user' import router from '../router' const request = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || '/api', timeout: 10000 }) request.interceptors.request.use(config => { const store = useUserStore() if (store.token) { config.headers.Authorization = `Bearer ${store.token}` } return config }) request.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { ElMessage.error(res.msg || '请求失败') return Promise.reject(new Error(res.msg)) } return res }, error => { if (error.response && error.response.status === 401) { const store = useUserStore() store.logout() router.push('/login') } ElMessage.error('网络异常') return Promise.reject(error) } ) export default request

baseURL用了 Vite 环境变量import.meta.env.VITE_API_BASE_URL。在项目根目录的.env.development和.env.production里分别写:

VITE_API_BASE_URL=/api

开发时 Vite 会把/api代理到后端 8080,生产打包后直接请求同域/api。这样前端代码里不需要写死 IP 和端口,换机器部署也不会改出乱子。

注意响应拦截器里的 401 处理。后端拦截器返回的是 HTTP 401,前端这里做的不是弹窗而是直接清空登录态、跳转登录页。这种“会话失效自动踢出”的逻辑,在答辩演示时很加分,它说明你考虑到了异常状态。

4.3 用户登录态与购物车徽标:Pinia 状态管理为什么比 localStorage 直接读更稳

前端商城一定会有一个“登录后显示用户名”和“购物车数量角标”的交互。如果只用 localStorage,你得每次手动 getItem,而且组件间不会自动响应。用 Pinia 管理:

import { defineStore } from 'pinia' export const useUserStore = defineStore('user', { state: () => ({ token: localStorage.getItem('mall_token') || '', nickname: localStorage.getItem('mall_nickname') || '' }), actions: { setLogin(token, nickname) { this.token = token this.nickname = nickname localStorage.setItem('mall_token', token) localStorage.setItem('mall_nickname', nickname) }, logout() { this.token = '' this.nickname = '' localStorage.removeItem('mall_token') localStorage.removeItem('mall_nickname') } } })

这里的关键是把 localStorage 当成持久化工具,把 Pinia 当成内存中的响应式数据源。state初始化时读一次 localStorage,之后组件里都从store.token读,任何一个组件调用setLogin,其他组件立刻重新渲染。如果直接改 localStorage,页面上的用户名和购物车角标不会自动更新,非要刷新页面才正常。

购物车数量可以用一个单独的cartStore管理:

export const useCartStore = defineStore('cart', { state: () => ({ count: 0 }), actions: { setCount(count) { this.count = count } } })

页面加载时调用后端购物车数量接口,把结果塞进cartStore,导航栏的角标绑定cartStore.count,加购成功后只更新这个数字,不用整页刷新。

5. 数据库脚本与避坑指南:MySQL 建表设计、导入步骤、五个必踩项目坑

5.1 核心表结构设计:用户、分类、商品、购物车、订单与明细

商城系统的数据库脚本是整个项目的根基。如果导师要求你交mall.sql,这张表设计必须能自圆其说。我建议至少包含这五张表:用户、分类、商品、购物车、订单和订单明细。下面是核心建表语句,可以直接当作初始化脚本的一部分。

CREATE DATABASE IF NOT EXISTS mall DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE mall; CREATE TABLE `user` ( `id` bigint NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL, `password` varchar(100) NOT NULL COMMENT 'BCrypt加密后的密文', `nickname` varchar(50) DEFAULT NULL, `phone` varchar(20) DEFAULT NULL, `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `category` ( `id` bigint NOT NULL AUTO_INCREMENT, `name` varchar(50) NOT NULL, `sort` int DEFAULT 0, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `product` ( `id` bigint NOT NULL AUTO_INCREMENT, `category_id` bigint NOT NULL, `name` varchar(100) NOT NULL, `subtitle` varchar(200) DEFAULT NULL, `main_image` varchar(255) DEFAULT NULL, `price` decimal(10,2) NOT NULL, `stock` int NOT NULL DEFAULT 0, `status` tinyint NOT NULL DEFAULT 1 COMMENT '1上架 0下架', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_category` (`category_id`), CONSTRAINT `fk_product_category` FOREIGN KEY (`category_id`) REFERENCES `category` (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `cart` ( `id` bigint NOT NULL AUTO_INCREMENT, `user_id` bigint NOT NULL, `product_id` bigint NOT NULL, `quantity` int NOT NULL DEFAULT 1, `checked` tinyint(1) NOT NULL DEFAULT 1, `create_time` datetime DEFAULT CURRENT_TIMESTAMP, `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_user_product` (`user_id`, `product_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `orders` ( `id` bigint NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL, `user_id` bigint NOT NULL, `total_amount` decimal(10,2) NOT NULL, `status` tinyint NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2已发货 3已完成 4已取消', `receiver_name` varchar(50) NOT NULL, `receiver_phone` varchar(20) NOT NULL, `receiver_address` varchar(255) NOT NULL, `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `order_item` ( `id` bigint NOT NULL AUTO_INCREMENT, `order_id` bigint NOT NULL, `product_id` bigint NOT NULL, `product_name` varchar(100) NOT NULL, `product_image` varchar(255) DEFAULT NULL, `price` decimal(10,2) NOT NULL, `quantity` int NOT NULL, PRIMARY KEY (`id`), KEY `idx_order` (`order_id`), CONSTRAINT `fk_order_item_order` FOREIGN KEY (`order_id`) REFERENCES `orders` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

建表时注意两点。第一是表名不要用order,它是 MySQL 的保留字,直接用会报语法错误,所以订单表我命名为orders。第二是金额字段用decimal(10,2),不要用 float 或 double,商品价格是精确计算,用浮点数会出现 19.99 存成 19.9900001 这种细节问题,答辩时被问到会很尴尬。

购物车表的UNIQUE KEY uk_user_product (user_id, product_id)在数据库层面保证同一用户同一商品只有一行记录,配合后端加购逻辑里的 select-then-insert,等于上了双保险。

5.2 数据库脚本导入:命令行导入、IDEA 导出、MySQL 5.7/8.0 兼容细节

拿到mall.sql后,第一件事是导入 MySQL。Windows 上最容易踩的坑是命令行路径没配好,直接执行报“mysql 不是内部或外部命令”。推荐用完整路径,或者先把 MySQL 的 bin 目录加进系统环境变量 PATH 里。

命令行导入:

mysql -u root -p -h 127.0.0.1 --default-character-set=utf8mb4 < mall.sql

输入密码后,脚本里的CREATE DATABASE IF NOT EXISTS mall会自动建库建表。如果执行时终端提示中文乱码,多半是终端编码不是 UTF-8,Windows 下可以先执行chcp 65001切到 UTF-8 再跑导入命令。

如果你想把现有 IDEA 里连好的数据库导出成脚本,也可以在 IDEA 的 Database 面板里右键数据库,选择Export with mysqldump,勾选结构和数据,导出文件同样是 sql 脚本。很多同学反向操作:在 MySQL 8.0 里导出,拿到 MySQL 5.7 里导入,结果报 utf8mb4_0900_ai_ci 排序规则不存在。这是因为 MySQL 8.0 默认排序规则是utf8mb4_0900_ai_ci,5.7 不认。我的做法是统一在建表语句里写utf8mb4_general_ci,在 5.7 和 8.0 上都能跑通。项目代码里的连接参数characterEncoding=utf8也只负责连接层,和库表排序规则不冲突。

MySQL 版本这里多说一句:如果你本机装的是 5.7.44,驱动要用com.mysql.cj.jdbc.Driver,并且在 pom 里注意 mysql-connector-j 版本不要高于 8.0.33,否则可能出现认证插件caching_sha2_password不兼容的报错。5.7 默认认证插件是mysql_native_password,8.0 默认是caching_sha2_password,这属于老生常谈但每年都有人踩。

5.3 五个避坑记录:时区、端口、乱码、外键、密码加密

结合我改过的几轮商城源码,下面这五个问题出现频率最高,每一条按“现象 → 原因 → 解决”写清楚。

1. 启动后端报时区错误

现象:控制台报The server time zone value 'Öйú±ê׼ʱ¼ä' is unrecognized,后端起不来。

原因:MySQL 8.0 默认时区不是系统时区,而 JDBC 连接参数里没有带时区。

解决:在application.yml的数据库 url 后面加上serverTimezone=Asia/Shanghai。也可以在 MySQL 里执行set global time_zone = '+08:00';再重启服务。第一种改法更常见,因为它随项目走,不依赖部署机器。

2. Tomcat 8080 端口被占用

现象:启动 SpringBoot 时提示Port 8080 was already in use。

原因:本地可能有别的 Java 进程占用了 8080,或者开了多个后端实例没关干净。

解决:先确认是不是自己的进程。Windows 下执行netstat -ano | findstr 8080,拿到最后一列的 PID 后taskkill /PID 你的PID /F。如果不想杀进程,直接改application.yml的server.port为 8081,同时记得改前端 Vite 代理目标端口。我一般建议毕设固定一个端口,避免答辩现场换机器后被端口问题纠缠。

3. 前端页面中文乱码

现象:页面标题、商品名称全部变成乱码,但数据库里的数据看起来正常。

原因:三层乱码叠加。数据库连接参数没带characterEncoding=utf8,或者前端页面本身不是 UTF-8 编码,又或者后端输出 JSON 时用了系统默认编码。

解决:按顺序检查四件事。数据库 url 加characterEncoding=utf8;IDEA 的File → Settings → Editor → File Encodings三个位置都改成 UTF-8;后端 Jackson 配置不要手动改编码;前端index.html里确认有<meta charset="UTF-8">。做完这些再刷新页面,乱码基本消失。

4. 删除商品分类时被外键拦住

现象:删除分类表里的一条数据,报Cannot delete or update a parent row: a foreign key constraint fails。

原因:商品表的category_id外键指向分类表,分类下有商品时,数据库默认RESTRICT不允许删除父记录。

解决:这不是代码 bug,是数据完整性约束在起作用。如果你希望“删分类时自动删商品”,建表时写ON DELETE CASCADE;但商城真实场景更推荐逻辑删除,给分类表加status字段,下架不删除。改代码时不要为了删数据把外键直接去掉,答辩时老师会问外键的意义。

5. 数据库里密码是明文,或者登录始终失败

现象:注册后打开 user 表,密码字段直接能看到明文;用对的密码登录又提示密码错误。

原因:注册时没有加密,或者加密算法是简单 MD5,但登录时后端又用另一种方式校验。

解决:用 Spring Security 的 BCryptPasswordEncoder 或 Hutool 的 BCrypt 工具,注册时加密存储密文,登录时用matches(明文, 密文)校验。注意同一个明文每次生成的密文都不同,不要用equals比较,必须用matches。改造方法的核心是后端不要在 Controller 里反复写加密逻辑,把加密器封装成一个组件,注册和登录复用。

6. 从源码到可演示:Vue 打包进 SpringBoot 单端口运行,答辩前过一遍主流程

6.1 前端打包与 static 目录合并:一个端口同时跑页面和 API

开发时前后端两个端口跑没问题,但答辩演示时,如果老师只让你开一个服务,就要把 Vue 打包后的文件放进 SpringBoot 的 static 目录。命令如下:

cd frontend npm run build rm -rf ../backend/src/main/resources/static/* cp -r dist/* ../backend/src/main/resources/static/ cd ../backend mvn spring-boot:run

打开http://localhost:8080就能看到前端页面,API 还是走/api,完全不用跨域。这里有个隐藏点:Vue Router 如果用了 history 模式,点击刷新某个子路由时会 404。解决办法是在 SpringBoot 里加一个转发 Controller,把非/api路径全部转发到/index.html。这段配置是毕设里“没有它也能跑、有了它才完整”的典型代码。

@Controller public class ViewController { @RequestMapping(value = {"/", "/index", "/product/**", "/cart/**", "/order/**", "/login"}) public String forward() { return "forward:/index.html"; } }

注意/api/**不要匹配,否则接口请求会被转发到页面。

6.2 答辩前我用这套顺序做完整验证

我一般会在答辩前一天把数据库删掉重导一遍,再按这个顺序走主流程:注册新用户 → 登录拿到 Token → 浏览商品列表 → 搜索关键字 → 加入购物车 → 查看购物车 → 模拟下单 → 检查订单状态 → 刷新页面确认登录态还在。其中注册和登录走一遍,是为了确认 BCrypt 加密逻辑没有在本地环境失效;刷新页面确认登录态,是检验 Pinia 和 localStorage 有没有配合好。

最后分享一个我自己常做的事:拿到任何一套商城源码,先跑通再改代码,而不是看完架构就动手加功能。很多项目的失败不是代码不行,而是一次性改动太多,出了问题根本不知道是哪一层出的错。我至少有三轮被时区、端口、外键这三件事耽误过半天,后来就养成固定套路——先导入 sql 看表,再启动后端看接口,最后启动前端看页面,按层排除。这套顺序能让你少走很多弯路,希望帮到你。

本文还有配套的精品资源,点击获取

返回列表