简介这是一份面向计算机专业毕业设计使用的商城购物网站项目基于SpringBootVue3.0MySQL实现前后端分离架构包含完整Java源码与SQL数据库脚本覆盖前台购物与后台管理两大模块。前台支持用户注册登录、商品浏览查询、下单购买、个人资料与地址管理后台包含用户、商品类型、商品、订单、评论及系统设置管理权限角色清晰适合学习SpringBoot与Vue3整合开发的中级学习者。资源包共314个文件以Java后端代码、Vue前端组件、CSS样式和JavaScript逻辑文件为主另附SQL脚本与项目配置文档压缩包仅910KB轻量易用。目前已有184人学习下载可直接导入开发工具运行也可作为课程设计或毕业设计参考。1. 商城购物网站这套毕设到底能帮你证明什么能力很多本科毕业设计最终都会落在 SpringBootVue3.0MySql 商城购物网站这个组合上。它不新但它把后端接口、前端页面和数据库脚本三条线分得很清楚刚好适合在答辩时讲明白你不需要证明自己发明了什么只需要让老师看到一套能完整跑通的交易链路。注册登录、商品列表、加入购物车、下单、支付状态模拟这几条线走通项目就能立住。我照着这类“完整源代码数据库脚本”的商城改过三轮方案最深的体会是真正卡进度的往往不是页面好看不好看而是数据库脚本能不能一次导入、前后端联调时接口能不能一次通过。下面这份拆解从目录结构讲到后端、前端、数据库脚本最后落到打包部署和避坑新手按顺序复现熟手可以直接跳到你关心的章节补参数。2. 目录结构与请求链路拿到源码先看哪几个文件加购请求如何走完整条链路2.1 商城源码包的标准目录backend、frontend、sql 三块缺一不可一份典型的 SpringBootVue3 商城源码包目录通常长这样。拿到手之后不要急着启动先按这个结构对一遍缺哪块补哪块然后再谈跑通。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.0Vue 干的是前后端分离的活。保持分离答辩时你可以讲“前端通过 Restful 接口与后端交互”这是加分项。2.2 后端 application.yml 的四个参数数据源、时区、驼峰映射、日志输出后端能不能连上 MySQL99% 的问题都集中在 application.yml。我建议你拿到的第一个文件不是 Controller而是它。一份能跑通的配置大概长这样server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/mall?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/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 里的serverTimezoneAsia/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?productId3quantity1 Header: Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...后端负责接收这个请求的 Controller 长这样RestController RequestMapping(/api/cart) public class CartController { Resource private CartService cartService; PostMapping(/add) public ResultVoid 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 LambdaQueryWrapperCart() .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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.2/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version0.11.5/version scoperuntime/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 ServiceImplProductMapper, Product implements ProductService { Override public PageProduct pageQuery(int page, int size, String keyword, Long categoryId) { LambdaQueryWrapperProduct 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.secretabcdefghijklmnopqrstuvwxyz123456这种长度才够太短启动时会报“Signing key must be at least 256 bits”的错。第二是expire用毫秒mall.jwt.expire86400000表示一天过期。拦截器负责在请求进入 Controller 前解析 TokenComponent 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 vitelatest 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 Plusimport { 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 requestbaseURL用了 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE category ( id bigint NOT NULL AUTO_INCREMENT, name varchar(50) NOT NULL, sort int DEFAULT 0, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;建表时注意两点。第一是表名不要用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-setutf8mb4 mall.sql输入密码后脚本里的CREATE DATABASE IF NOT EXISTS mall会自动建库建表。如果执行时终端提示中文乱码多半是终端编码不是 UTF-8Windows 下可以先执行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_ci5.7 不认。我的做法是统一在建表语句里写utf8mb4_general_ci在 5.7 和 8.0 上都能跑通。项目代码里的连接参数characterEncodingutf8也只负责连接层和库表排序规则不冲突。MySQL 版本这里多说一句如果你本机装的是 5.7.44驱动要用com.mysql.cj.jdbc.Driver并且在 pom 里注意 mysql-connector-j 版本不要高于 8.0.33否则可能出现认证插件caching_sha2_password不兼容的报错。5.7 默认认证插件是mysql_native_password8.0 默认是caching_sha2_password这属于老生常谈但每年都有人踩。5.3 五个避坑记录时区、端口、乱码、外键、密码加密结合我改过的几轮商城源码下面这五个问题出现频率最高每一条按“现象 → 原因 → 解决”写清楚。1. 启动后端报时区错误现象控制台报The server time zone value Öйú±ê׼ʱ¼ä is unrecognized后端起不来。原因MySQL 8.0 默认时区不是系统时区而 JDBC 连接参数里没有带时区。解决在application.yml的数据库 url 后面加上serverTimezoneAsia/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. 前端页面中文乱码现象页面标题、商品名称全部变成乱码但数据库里的数据看起来正常。原因三层乱码叠加。数据库连接参数没带characterEncodingutf8或者前端页面本身不是 UTF-8 编码又或者后端输出 JSON 时用了系统默认编码。解决按顺序检查四件事。数据库 url 加characterEncodingutf8IDEA 的File → Settings → Editor → File Encodings三个位置都改成 UTF-8后端 Jackson 配置不要手动改编码前端index.html里确认有meta charsetUTF-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 看表再启动后端看接口最后启动前端看页面按层排除。这套顺序能让你少走很多弯路希望帮到你。本文还有配套的精品资源点击获取