SpringBoot+Vue电商商城系统实战:前后端分离与部署全解析
2026/9/9 15:14:14 网站建设 项目流程

做电商类项目的朋友应该都有同感:SpringBoot + Vue 几乎成了国内电商商城系统的默认技术栈。我在开源社区存过一套这样的完整源码——基于SpringBoot与Vue开发的电商商城系统,前后端分离、带管理后台、含完整源码。这套东西最大的价值在于,你可以把它当作一个可运行的业务蓝本:从用户注册登录、商品浏览、购物车、下单支付,到后台的商品管理、订单处理、数据统计,一整条电商核心链路都完整跑通。它特别适合三类人:准备做毕业设计的学生、想快速搭一套自营商城的中小团队、以及想搞懂“前后端分离项目到底怎么串起来”的进阶开发者。

但别被“源码”两个字误导。拿到源码能启动,和真正理解这套系统,中间差着好几个坑。我之前为了把这套项目从本地跑到云端服务器,前后折腾了大半个月,中间踩过跨域、文件上传、路由刷新404、时间序列化这类非常典型的问题。这篇文章不打算重复README里的启动步骤,而是想从架构设计、后端核心实现、前端工程化、前后端联调、环境搭建、部署上线这几个维度,把一套SpringBoot + Vue电商商城的完整技术链路讲透。你跟着走一遍,获得的是一套真正能落地的电商系统,而不只是一堆能运行的代码。

1. 架构边界与模块划分:这套商城系统到底包含什么

1.1 为什么选前后端分离,而不是SpringBoot模板渲染

很多做单体项目的老同学会问,SpringBoot自带Thymeleaf,为什么还要前后端分离,额外搭一套Vue工程?这个问题的答案,等你把项目部署上线后就彻底清楚了。

前后端分离带来的第一个好处是开发并行。前端不用等后端完整接口出来,只要前后端先定好接口文档——路径、入参、返回结构这三件事对齐——前端就可以用mock数据先搭页面。我实际体验下来,一个商城的前端页面量不少,首页、列表页、详情页、购物车、结算、订单中心、后台十几个管理界面,如果等后端全部写完再动前端,整个周期至少要拉长一倍。

第二个好处是部署解耦。前端构建后就是一堆静态文件,丢Nginx就行;后端是独立Java进程,任意一个更新不影响另一个。改个页面样式不用重新打jar包重启服务,这在生产环境里是很爽的事情。

第三个好处是多端复用。我最初只用它做了Web商城,后来想加小程序端时,后端接口几乎直接复用,只需要新增一套小程序前端。你要是用过Thymeleaf模板渲染就明白,那套东西想再输出给小程序,基本等于重写。

当然,前后端分离也有代价:跨域问题、鉴权方式变化、部署链路变长。这些问题不是没有解决方案,只是需要你在每个环节多留个心眼。后面几章我会逐个讲。

1.2 用户端与后台管理的功能清单

这套商城系统从用户视角和管理员视角,大致可以分为下面这些功能模块:

模块核心功能
用户端账号体系注册、登录、JWT鉴权、个人信息查看
用户端商品浏览分类导航、商品列表分页、关键词搜索、商品详情
用户端购物车加入购物车、修改数量、选中/取消选中、删除
用户端订单结算提交订单、收货地址填写、在线支付(可用模拟支付)
用户端订单中心订单列表、订单详情、取消订单、确认收货
管理端仪表盘商品数量、订单数量、销售额统计
管理端商品管理商品新增/编辑/上下架、库存管理、图片上传
管理端分类管理商品分类的树形维护
管理端订单管理订单列表、发货操作、退款处理入口
管理端用户管理用户列表、禁用/启用账号

这基本是一个标准的B2C商城最小可用集。你可以在这个基础上加优惠券、积分、秒杀、物流跟踪,但核心链路就是上面这些。做毕业设计或者接外包项目时,把这张表里的功能稳稳落地,交付质量就已经超过大多数同类项目了。

1.3 数据库设计:核心表与关键字段

数据库设计是这个项目最容易被低估的部分。很多初学者上来就建一张商品表、一张订单表,越做到后面越别扭。我按这套系统的实际落地情况,把核心表结构梳理一下。

表名用途关键字段
user用户表id, username, password(BCrypt密文), nickname, avatar, phone, status
category商品分类表id, name, parent_id, sort
product商品表id, category_id, title, cover, images, price, stock, sales, status, description
product_sku商品规格表id, product_id, spec_name, price, stock
cart_item购物车表id, user_id, product_id, quantity, selected
orders订单表order_no, user_id, total_amount, status, address, created_time, pay_time
order_item订单明细表id, order_id, product_id, product_name, price, quantity
payment支付流水表id, order_no, pay_type, pay_status, callback_data

先说两个比较重要的设计决策。

第一个是product和product_sku为什么分开。商品本身的信息(标题、封面、描述)和具体规格(颜色、尺码、版本)是两种粒度。一个“手机壳”商品可能同时有黑色和白色两个SKU,它们的价格和库存是独立的。如果不拆表,库存控制就没法定到规格级别,卖完黑色你没法精确下架。当然了,如果你的商城卖的是无规格商品,SKU表可以先不做,直接在product表上冗余一个spec字段也行,看业务需要。

第二个是订单为什么要有order_item明细表,而且要在里面冗余product_name和price。原因是商品信息会被修改、下架甚至删除,但历史订单必须显示用户下单那一刻的商品快照。如果你下单后在订单表里只存product_id,等商品改名了,订单里的商品名也跟着变了,用户投诉起来非常麻烦。

还有一点值得注意:订单号用单独的order_no,不要用自增id。订单号要暴露给支付回调、物流查询这些外部系统,自增id一方面会让别人通过订单号推断你的销量,另一方面自增id在回调校验时也不安全,容易被遍历猜测。实际项目里我习惯用时间戳加随机数拼一个唯一流水号,或者用雪花算法生成。

2. SpringBoot后端的核心实现:从接口设计到安全认证

2.1 项目结构、依赖选型与版本搭配的坑

后端项目的包结构大致是这样:

com.example.mall ├── MallApplication.java ├── config // 跨域、MyBatisPlus分页、拦截器注册 ├── controller // 前台接口 + 后台管理接口 ├── service // 业务逻辑接口 ├── service.impl // 业务实现 ├── mapper // 数据访问层 ├── entity // 数据库实体 ├── dto / vo // 入参对象 / 返回对象 └── utils // JwtUtils, ResultResponse, 全局异常处理

核心依赖我用的是下面这组:

  • spring-boot-starter-web(基础Web能力)
  • mybatis-plus-boot-starter(ORM增强)
  • mysql-connector-java(数据库驱动)
  • lombok(消除样板代码)
  • jjwt(JWT生成与解析)
  • spring-boot-starter-validation(参数校验)
  • hutool(工具库,避免重复造轮子)

这里要重点说一个版本问题:MyBatis-Plus 3.5.x 和 SpringBoot 2.7.x 是目前最稳定的搭档。如果你手痒用了SpringBoot 3.x,那MyBatis-Plus必须上3.5.3+,而且javax包要换成jakarta包,很多老教程的代码会在这里直接编译报错。说实话,对一个商城系统来说,SpringBoot 2.7 + JDK 8足够用了,没必要为了追新给自己挖坑。

2.2 JWT登录认证:从生成Token到拦截器放行规则

登录认证我用的方案是JWT。流程不复杂:

  1. 用户提交用户名密码
  2. 后端校验账号密码(密码用BCrypt比对,不要明文存储)
  3. 校验通过后生成JWT token返回给前端
  4. 前端把token存到localStorage或Pinia,请求时在请求头加Authorization: Bearer <token>
  5. 后端拦截器解析token,把用户信息放入ThreadLocal,供后续业务直接取

JWT工具类核心代码大概长这样:

@Component public class JwtUtils { @Value("${jwt.secret}") private String secret; @Value("${jwt.expire}") private Long expire; // 单位:秒 public String generateToken(Long userId, String username) { return Jwts.builder() .setSubject(String.valueOf(userId)) .claim("username", username) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + expire * 1000)) .signWith(SignatureAlgorithm.HS256, secret) .compact(); } public Claims parseToken(String token) { return Jwts.parser() .setSigningKey(secret) .parseClaimsJws(token) .getBody(); } }

拦截器里最容易被忽略的就是预检请求放行。前端跨域请求时,浏览器会先发一个OPTIONS预检请求,如果拦截器把OPTIONS也拦了,前端就会看到CORS报错但后端日志里什么都没有。

public class TokenInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行预检请求,这一步不能省 if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { return true; } String token = request.getHeader("Authorization"); if (StringUtils.isNotBlank(token) && token.startsWith("Bearer ")) { try { Claims claims = jwtUtils.parseToken(token.substring(7)); request.setAttribute("userId", claims.getSubject()); return true; } catch (Exception e) { // token 过期或非法,直接走401 } } response.setStatus(401); return false; } }

注册拦截器时,白名单一定要配好。登录接口、注册接口、商品列表、商品详情、分类查询这些公开接口必须放行,否则会出现死循环:用户没登录想访问商品页,被拦截器踢到登录页,但登录接口本身又被拦截器拦住了,永远无法登录。

生产环境我建议后台管理接口独立一套拦截规则,或者在校验token成功后额外校验一下当前用户的role字段是不是admin。这个校验后端必须做死,不能依赖前端隐藏按钮。

2.3 商品、购物车模块的接口设计实践

商品列表接口我设计成GET /api/product/list?categoryId=1&keyword=手机&pageNum=1&pageSize=10&sort=priceAsc,返回分页结果。查询逻辑用MyBatis-Plus的LambdaQueryWrapper写起来非常直观,不需要写一堆XML SQL。

public PageResult<ProductVO> getProductPage(Integer categoryId, String keyword, Integer pageNum, Integer pageSize, String sort) { Page<Product> page = new Page<>(pageNum, pageSize); LambdaQueryWrapper<Product> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(categoryId != null, Product::getCategoryId, categoryId) .and(StringUtils.isNotBlank(keyword), w -> w.like(Product::getTitle, keyword) .or().like(Product::getDescription, keyword)); if ("priceAsc".equals(sort)) { wrapper.orderByAsc(Product::getPrice); } else if ("saleDesc".equals(sort)) { wrapper.orderByDesc(Product::getSales); } productMapper.selectPage(page, wrapper); return new PageResult<>(page.getRecords(), page.getTotal(), pageNum, pageSize); }

购物车接口这块,我的设计思路是以后端存储为准。虽然把购物车放在前端localStorage也能做,但用户换个设备购物车就空了,体验不好。购物车加到数据库很简单,就是一张带user_id的表,查询、修改、删除都按user_id过滤。购物车的数量修改接口,前端传id和quantity,后端更新对应记录。

加购接口的核心逻辑是:先查同一用户是否已经把该商品加进购物车,如果存在就数量累加,不存在就插入一条新记录。很多初学者直接无脑insert,买一件商品加一次购物车记录,购物车里同一个商品出现好几行,后面结算逻辑会写得非常痛苦。

2.4 订单状态机与库存扣减的原子性

订单状态这一块,我把它设计成一张明确的状态流转表:

状态值含义可进入的下一步
0待付款1,4
1待发货2,5
2待收货3,5
3已完成-
4已取消-
5退款/售后中3

提交订单的接口是整个后端最需要谨慎的地方,因为它涉及多步数据变更:校验商品状态和库存、扣减库存、生成订单主表、生成订单明细、清空购物车里已勾选的商品。任何一步失败,前面所有操作都必须回滚,所以这个接口必须加@Transactional注解。

扣减库存这一步有一个很关键的经验:使用带条件的UPDATE语句,而不是先SELECT再UPDATE。

@Transactional(rollbackFor = Exception.class) public Long createOrder(OrderCreateDTO dto) { // 要扣的库存使用原子性SQL,避免并发超卖 for (OrderItemDTO item : dto.getItems()) { int rows = productMapper.deductStock(item.getProductId(), item.getQuantity()); if (rows == 0) { throw new BizException("商品库存不足"); } } // 生成订单主表 // 生成订单明细 // 清空购物车中选中的商品 return order.getId(); }

deductStock对应的SQL是:

UPDATE product SET stock = stock - #{quantity} WHERE id = #{productId} AND stock >= #{quantity}

这个SQL的关键在于用stock >= #{quantity}作为条件,MySQL的UPDATE语句在行锁层面天然具备原子性,只有库存充足时才会更新成功并返回受影响行数。如果你用先查再改的方式,两个并发请求同时查到库存还剩1件,都认为可以买,最后库存就会变成负数——这就是超卖。我见过不少初学者在这里踩坑,等到上线后才发现问题。

关于支付回调,先说明一点:很多毕业设计级的商城为了演示方便,会做成“一键模拟支付”,点支付按钮直接把订单状态改成已支付。这当然可以,但如果你要对接支付宝或微信支付,回调处理的核心逻辑一定要搞清楚:支付平台会异步POST一个回调通知,里面带订单号、金额、签名等信息。后端第一件事是验签,防止伪造回调;第二件事是根据订单号查订单,确认当前状态确实是待支付;第三件事是更新订单状态为已支付,写支付流水;最后返回一个固定字符串(比如success)给支付平台,否则支付平台会认为回调失败并持续重试。

3. Vue前端的工程化搭建与页面核心逻辑

3.1 为什么用Vue 3 + Vite + Pinia + Element Plus

前端部分的技术选型,我推荐Vue 3 + Vite + Pinia + Element Plus的组合。Vue 3的组合式API配合<script setup>语法,同一个页面的逻辑代码组织起来比Vue 2的Options API清爽很多,尤其是商品详情页那种包含图片轮播、SKU选择、数量加减、加入购物车多个交互的页面,Composition API的优势会非常明显。

Vite相比Webpack最大的感受就是“快”。冷启动秒级,热更新几乎无感,开发体验完全不是一回事。如果你拿到的这套源码还是Webpack,可以考虑迁移到Vite,但注意Vite 5要求Node 18+,装依赖时容易因为Node版本太低或太高出现问题,建议直接用nvm管理Node版本。

Pinia是Vuex的替代品,API比Vuex简单太多。不需要mutations那一套,直接在store里定义state、getters、actions就行,TypeScript支持也好。

版本搭配方面给一张表,避免新人卡在环境上:

依赖建议版本备注
Node.js16.20+ 或 18.xVite 5要求更高,建议直接用Node 18
Vue^3.3.x
Vite^4.x 或 ^5.x别盲目上最新,依赖生态要跟上
Pinia^2.x
Vue Router^4.x
Element Plus^2.x
Axios^1.x

npm install时最常遇到的问题是peerDependencies冲突,我实测最有效的解决办法是:

npm install --legacy-peer-deps

这个命令表示跳过peer依赖的严格检查。虽然不优雅,但确实能解决大多数Vue生态的依赖冲突问题。

3.2 路由设计、导航守卫与后端权限的关系

前端路由分两套Layout:用户端路由挂在根路径/下,走一套带顶部导航和底部栏的Layout;管理端路由挂在/admin下,走一套带侧边栏菜单的AdminLayout。这种结构在Vue Router里的实现就是嵌套路由,主Layout里放<router-view>,子路由根据访问路径动态渲染页面。

导航守卫是前端权限控制的核心入口:

router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.meta.requiresAuth && !token) { next({ path: '/login', query: { redirect: to.fullPath } }) return } if (to.meta.role === 'admin' && !isAdmin(token)) { next({ path: '/403' }) return } next() })

登录后跳回原先想访问的页面,这个redirect参数是很多新手的盲区。如果不处理,用户会被强制回到首页,体验很差。

但这里必须强调一句扎心的话:前端路由守卫只是用户体验层的门禁,不是安全边界。真正拦住非法访问的,是后端接口的权限校验。前端隐藏了“商品删除”按钮,不代表用户不能直接通过POST请求调用删除接口。所以后端每个管理接口都必须校验当前用户角色,前端路由守卫只是让正常用户看不见入口而已。

3.3 购物车与订单页的状态管理:Pinia怎么用才顺手

购物车数据到底放Pinia还是每次进页面都从后端拉?我的实践是:进购物车页面时调后端接口拿最新的购物车列表,拿到后放到Pinia里;用户改数量、切换选中、删除时,同时更新Pinia里的本地数据和后端。这样页面跳转回来不用反复请求,数据也不会丢。

一个简单的cartStore示例:

export const useCartStore = defineStore('cart', { state: () => ({ items: [], selectedIds: [], }), getters: { selectedItems: (state) => state.items.filter(i => state.selectedIds.includes(i.id)), totalPrice: (state) => state.items .filter(i => state.selectedIds.includes(i.id)) .reduce((sum, i) => sum + i.price * i.quantity, 0), }, actions: { async fetchCart() { const { data } = await axios.get('/api/cart/list') this.items = data.data this.selectedIds = this.items.filter(i => i.selected).map(i => i.id) }, async updateQuantity(id, quantity) { // 本地立即更新,同时异步同步后端 const item = this.items.find(i => i.id === id) if (item) item.quantity = quantity await axios.put(`/api/cart/update`, { id, quantity }) }, }, })

订单结算页获取的“总价”一定要以后端计算为准,不要直接信任前端提交的金额。前端传的total_amount在请求过程中可以被篡改,后端在下单接口里必须根据订单明细重新计算一遍金额。这是电商系统的基本素养,很多做管理系统的同学转过来做电商时容易忽略。

3.4 axios封装:统一处理Token、状态码与401跳转

axios封装是我在任何前后端分离项目里都会第一个做的事。统一封装的request实例可以解决三个问题:请求头自动带token、响应状态码统一处理、401时自动踢回登录页。

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) { ElMessage.error(res.message) return Promise.reject(new Error(res.message)) } return res }, error => { if (error.response?.status === 401) { localStorage.removeItem('token') router.push('/login') } return Promise.reject(error) } )

这里有一个容易被忽略的细节:baseURL直接写成/api,而不是写死http://localhost:8080。开发环境下,交给Vite的proxy代理转发到后端;生产环境下,交给Nginx反向代理转发到后端。这样前端代码里完全不用区分环境,也不会有跨域问题。等你上线之后就会感谢这个设计。

4. 前后端联调过程中的高频坑点

4.1 跨域问题的完整排查链路

跨域可能是联调阶段遇到最多的报错。典型现象是前端启动后访问登录接口,浏览器控制台报错:

Access to XMLHttpRequest at 'http://localhost:8080/api/user/login' from origin 'http://localhost:5173' has been blocked by CORS policy

我建议新手遇到这个报错,按下面这条链路排查:

  1. 打开浏览器Network面板,看请求是否真的发出、状态码是什么。如果请求在预检阶段就被浏览器拦截,后端甚至可能根本没收到请求,后端日志也不会有任何记录。
  2. 确认跨域根源:前端地址是5173端口,后端是8080端口,origin不一致,浏览器判定为跨域。
  3. 检查后端有没有处理OPTIONS预检请求。跨域时浏览器会先发一个OPTIONS请求来探测后端是否允许该跨域请求,如果后端没有正确响应OPTIONS,后续真正的GET/POST会被浏览器强制拦下。
  4. 检查拦截器是否放行了OPTIONS。回头看看第2.2节的TokenInterceptor,我在里面特意加的OPTIONS放行就是为这个场景准备的。

解决方案有两个,二选一即可,不要叠加使用:

方案一是后端统一配置CORS过滤器:

@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }

方案二是前端用Vite的proxy代理,让浏览器认为所有请求都在同一个源下:

server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, }, }, },

生产环境的跨域问题交给Nginx反向代理解决,本质上和方案二一样,都是在中间层做转发。

4.2 文件上传:图片回显的路径统一问题

商城系统里商品图片上传是标配功能,但几乎每个第一次做这个功能的人都会在“图片能上传成功但页面里显示不出来”上耗掉半天。原因很简单:后端把文件保存到了本地某个磁盘目录,数据库里存的路径前端根本访问不到。

正确的做法是:后端把文件保存到固定的本地目录(比如D:/mall-upload/),数据库里存一个相对路径(比如/uploads/商品图片.jpg),然后通过静态资源映射接口让这个路径可访问。

后端需要加这样一段配置:

@Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/uploads/**") .addResourceLocations("file:" + uploadDir + "/"); }

这里有三个实践要点:

第一,uploadDir不要用硬编码的绝对路径,要用配置文件控制。Windows开发机和Linux服务器路径分隔符不一样,硬编码绝对路径会处处碰壁。第二,建议把上传目录放到项目目录之外,比如服务器的/home/ubuntu/mall-upload,避免打包时路径失效,也方便备份。第三,部署时Nginx也要把/uploads/路径代理到后端服务,或者直接把静态文件目录映射出来,否则就会出现“后端管理页面上传成功,但商城前台图片加载失败”这种诡异现象。

4.3 JSON时间格式化:前后端各显示各的

这个坑真的非常隐蔽。Java 8之后的时间类型LocalDateTime在默认Jackson序列化下,返回给前端的格式可能是2025-01-01T12:00:00,也可能是一串数字时间戳,看Jackson版本和配置。前端拿到这种数据直接渲染,用户看到的就是“2025-01-01T12:00:00”这种莫名其妙的东西。

我建议统一在后端解决时间格式问题,前端不用每个页面单独处理:

@Configuration public class JacksonConfig { @Bean public Jackson2ObjectMapperBuilderCustomizer jsonCustomizer() { return builder -> builder .serializers(new LocalDateTimeSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))) .deserializers(new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); } }

前端展示时用dayjs做格式化,双保险。但无论如何,后端输出统一格式是第一原则,否则十个前端页面就有十种时间处理方式,联调时你会疯掉。

4.4 分页参数与返回结构不一致

MyBatis-Plus的Page对象默认使用currentsize作为分页参数,分页结果里的字段是recordstotal。但Element Plus的el-pagination组件默认发的是pageNumpageSize。如果前后端没有约定好,就会出现一种很尴尬的情况:第一页数据正常,点击第二页没反应,或者分页总页数不对。

解决思路是前后端约定一套统一的分页参数结构,然后固定下来。后端业务代码里再做一次映射,把Page对象转成统一PageResult,避免前端直接面对ORM框架的数据结构。

另外一个必须留意的坑:MyBatis-Plus的分页插件如果不注册,selectPage方法并不会真正分页,而是查出所有数据后内存里截取。所以MyBatis-Plus的配置类一定要记得加:

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

这个问题最典型的症状是:接口响应时间很长,返回的数据却只有一页。排查时先看SQL日志,如果发现SQL没有LIMIT语句,基本就是分页插件没生效。

5. 环境准备与本地启动:拿到源码后复现的最小步骤

5.1 本地环境清单和版本搭配建议

本地跑起来的步骤不难,但版本搭配非常关键。下面这套组合是我实测最稳的:

组件推荐版本说明
JDK8 或 11企业老项目大量在用,兼容性最好
Maven3.6+IDEA自带即可
MySQL5.7 或 8.08.0更常见,sql脚本基本通用
Redis任意如果项目只用做缓存,没有也不影响核心链路
Node.js16.20+ 或 18.xVite 5要求更高
开发工具IDEA + VS Code后端IDEA,前端VS Code或WebStorm

再次强调SpringBoot版本问题。如果你的源码是SpringBoot 2.7.x,直接用JDK 8没问题;如果是SpringBoot 3.x,JDK必须升到17,而且依赖包名要从javax改成jakarta,否则项目一启动就报ClassNotFoundException。

5.2 数据库初始化

拿到源码后,先找sql目录下的脚本文件,通常叫mall.sqlinit.sql。在MySQL里执行:

mysql -u root -p < mall.sql

或者用Navicat、DataGrip直接导入脚本。

执行完后,打开后端的application.yml,修改数据库连接信息:

spring: datasource: url: jdbc:mysql://localhost:3306/mall?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: 你自己的密码

serverTimezone=Asia/Shanghai必须加上,否则IDEA启动时会报时区错误。这是国内开发MySQL+Java项目最高频的启动报错之一,没有例外。

5.3 后端启动步骤

用IDEA导入后端项目,等待Maven下载依赖,然后运行MallApplication主类。启动成功后,浏览器访问http://localhost:8080/api/product/list,如果返回JSON数据或401错误码,说明后端已经正常起来。

如果项目集成了knife4j或swagger,也可以直接访问http://localhost:8080/doc.html查看所有接口文档,这个页面在联调阶段非常有用,可以快速验证每个接口的入参和返回。

5.4 前端启动步骤

进入前端项目目录(一般是mall-vuefrontend),执行:

npm install npm run dev

如果npm install报错,用--legacy-peer-deps重试。启动成功后浏览器访问http://localhost:5173,用管理账号登录,完整走一遍“浏览商品→加购→下单→支付→后台发货→确认收货”的流程。

这一步我建议你多走几遍,每走一遍注意看网络请求的调用顺序。你会很直观地理解一件商品从点击购买到订单完成的每一步,前端调用了哪个接口、后端返回了什么结构、数据库里哪些表发生了变化。这个自动化流程走通,本地开发环境就算是彻底搞定了。

6. 部署上线:从开发机到服务器的完整链路

6.1 后端打包与配置外部化

本地开发跑通只是开始,把项目部署到服务器才是真正的考验。后端打包可以在项目根目录执行:

mvn clean package -DskipTests

执行完,target目录下会生成一个可执行的jar包。生产环境不要直接使用application.yml里的默认配置,而是通过application-prod.yml覆盖数据库地址、上传目录、JWT密钥等敏感配置。启动时指定profile:

java -jar mall-server.jar --spring.profiles.active=prod

两个建议:数据库密码不要以明文写死在yml里,用环境变量注入,比如password: ${DB_PASSWORD};JWT的secret也要换掉默认值,否则攻击者可以用默认密钥伪造token。这些都是上线前的基本卫生习惯。

6.2 前端构建与Nginx配置

前端执行:

npm run build

生成dist目录,里面是纯静态文件。把dist里的内容上传到服务器的/var/www/mall目录,然后配置Nginx:

server { listen 80; server_name yourdomain.com; root /var/www/mall; index index.html; # history 路由刷新 404 问题的核心配置 location / { try_files $uri $uri/ /index.html; } # API 反向代理到后端 location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } # 上传图片访问代理 location /uploads/ { proxy_pass http://127.0.0.1:8080; } }

try_files $uri $uri/ /index.html;这一行是Vue Router history模式部署最关键的配置。它的作用是:当用户访问/product/detail/123这样的路径时,Nginx在静态目录里找不到这个文件,于是兜底返回index.html,由前端路由接管渲染。没有这一行,刷新页面就会看到404。

6.3 Docker Compose一键编排

如果服务器环境比较混乱,或者你需要频繁搬家、重装,建议直接用Docker Compose编排。这样MySQL、后端、前端三个服务可以一键启动,非常省事。

后端Dockerfile:

FROM openjdk:8-jdk-alpine WORKDIR /app COPY mall-server.jar app.jar EXPOSE 8080 ENTRYPOINT ["java", "-jar", "-Dspring.profiles.active=prod", "app.jar"]

前端Dockerfile用了多阶段构建,先构建再拷贝到Nginx镜像:

FROM node:18-alpine AS build WORKDIR /app COPY package*.json ./ RUN npm install --legacy-peer-deps COPY . . RUN npm run build FROM nginx:alpine COPY --from=build /app/dist /usr/share/nginx/html COPY nginx.conf /etc/nginx/conf.d/default.conf EXPOSE 80

docker-compose.yml:

version: '3.8' services: mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: root123456 MYSQL_DATABASE: mall volumes: - mysql-data:/var/lib/mysql - ./sql:/docker-entrypoint-initdb.d ports: - "3306:3306" backend: build: ./backend environment: DB_HOST: mysql DB_PASSWORD: root123456 depends_on: - mysql ports: - "8080:8080" frontend: build: ./frontend depends_on: - backend ports: - "80:80" volumes: mysql-data:

这里有一个Docker部署特有的坑必须提醒:容器内部的后端连接数据库,不能再写localhost127.0.0.1,要写服务名mysql。因为每个容器有独立的网络命名空间,localhost指向的是容器自己。初学Docker的人几乎都会在这里卡一下,等看到Connection refused报错时才意识到是网络地址的问题。

这套系统最核心的价值不是某个炫技功能,而是完整串起了一条“数据怎么流转”的链路。从用户注册时密码的BCrypt加密,到商品扣库存时的原子SQL,再到订单状态机的每一步流转,每一环都需要前后端配合才能跑通。我个人的建议是,拿到源码后不要急着改代码,先把本地环境跑起来,用测试账号完整走几遍主流程,再去看代码里对应的实现,理解效率会高很多。

最后分享一个小习惯:给订单表加一个订单日志字段或单独一张订单状态记录表,每次状态变更都记录下来。刚开始做商城时可以不加,但上线后排查问题时,你能直接看到订单什么时候从待支付变成了已支付、谁在什么时候取消了订单。这个小小的设计,会在关键时刻帮你省下大把排查问题的时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询