ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Spring Boot + Vue 前后端分离网上购物商城从零搭建

Spring Boot + Vue 前后端分离网上购物商城从零搭建 简介这是一份基于SpringbootVue的网上购物商城系统毕业设计项目面向计算机相关专业毕业生及需要课程设计、期末项目的学生。项目覆盖商品展示、购物车、订单管理等典型电商业务前后端分离架构清晰可直接作为毕设演示或二次开发基础。压缩包共745个文件约43.11MB包含java后端源码、vue前端组件、js逻辑、css样式、sql数据库脚本、mp4演示视频、md使用文档及bat部署脚本等类型齐全目录按前后端、数据库、文档和部署脚本分类组织便于按模块查阅。sql脚本内含建表及基础数据bat脚本用于环境安装与启动可大幅降低配置时间。资源在Windows10/11环境反复调试部署教程完整下载后按步骤即可运行并配有演示视频和使用文档。项目已通过导师指导认可答辩评审97分目前已有154人学习适合希望快速搭建高完成度商城系统并深入理解前后端整合的开发者。1. 用 Spring Boot Vue 还原一个能答辩的网上购物商城每年毕业季都能看到同一类选题网上购物商城。这个题目本身不难难的是做出“能答辩、能讲清、能经得起问”的完成度。大多数队伍翻车不是输在功能不全而是输在模块之间没有工程化组织——后端 Service 写了 2000 行、前端每个页面重复请求用户信息、数据库表设计没有冗余字段一问“订单状态怎么流转”就卡壳。这套基于 Spring Boot Vue 的前后端分离方案核心是把商城拆成用户、商品、购物车、订单、支付回调五条链路再用 JWT 登录态、统一响应体、权限拦截和分页组件把链路串起来。适合拿来直接改做毕设也适合刚入职的初级工程师当第一个全栈练手项目下面从表结构开始一步步落。2. 数据库表设计与 Spring Boot 后端工程初始化2.1 从订单模型反推商城表结构先定订单再定其他表。订单是商城的数据中枢它决定了用户、商品、购物车三张表必须长什么样。一张最小可用的订单表至少包含订单号、用户 ID、订单状态、商品快照、总金额、收货地址快照、创建时间。其中“商品快照”和“地址快照”是很多毕设容易漏的字段——下单后商品改了价格、用户改了地址订单里的数据不应该跟着变。表结构如下CREATE TABLE user ( id bigint(20) NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL, password varchar(100) NOT NULL, nickname varchar(50) DEFAULT NULL, phone varchar(20) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE product ( id bigint(20) NOT NULL AUTO_INCREMENT, name varchar(100) NOT NULL, description text, price decimal(10,2) NOT NULL, stock int(11) NOT NULL DEFAULT 0, image_url varchar(255) DEFAULT NULL, status tinyint(4) NOT NULL DEFAULT 1 COMMENT 1上架 0下架, category_id bigint(20) DEFAULT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;商品表里必须留status字段下架不是删数据而是改状态。订单表的价格字段用decimal(10,2)不能在 Java 里用double算钱这是面试和答辩最常被追问的点。继续建订单表和购物车表CREATE TABLE orders ( id bigint(20) NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL, user_id bigint(20) NOT NULL, total_amount decimal(10,2) NOT NULL, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 0待付款 1已付款 2已发货 3已收货 4已取消, product_snapshot text COMMENT 商品快照JSON, address_snapshot varchar(255) COMMENT 收货地址快照, create_time datetime DEFAULT CURRENT_TIMESTAMP, pay_time datetime DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;order_no必须是唯一键而且生成逻辑里要带用户 ID 和时间戳避免并发下撞号。product_snapshot用 TEXT 类型存 JSON 字符串查询时反序列化成对象展示。购物车表更简单用户 ID 加商品 ID 加数量再加一个唯一索引防止同商品重复入车。提示字段注释写清楚答辩时老师会开 Navicat 看表注释就是你的口述提纲。2.2 Maven 依赖版本与 application.yml 参数Spring Boot 版本建议固定在 2.7.18不要追求 3.x。很多同学遇到 “springboot版本太高” 导致的 javax 和 jakarta 包名混乱就是因为跟教程用了不同大版本。2.7.18 稳定、资料多、和 MyBatis-Plus 3.5.x 的兼容性最好。pom.xml核心依赖parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.7/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency dependency groupIdcom.auth0/groupId artifactIdjava-jwt/artifactId version4.4.0/version /dependency /dependenciesapplication.yml里有一处容易踩坑的配置——MyBatis-Plus 的驼峰映射和日志输出spring: datasource: url: jdbc:mysql://localhost:3306/shopping_mall?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: id-type: automap-underscore-to-camel-case打开后数据库的order_no自动映射到 Java 的orderNo不用手写 resultMap。log-impl配成 StdOutImpl 是为了在控制台直接看 SQL排查问题时比盲猜快得多上线前注释掉即可。注意password 一定要加引号纯数字密码在 YAML 里会被解析成数字类型导致数据库连接失败。2.3 MyBatis-Plus 代码生成与分页配置每张表对应的 Entity、Mapper、Service 如果手写半小时就烦了。MyBatis-Plus 的代码生成器可以直接在测试类里跑Test void generateCode() { FastAutoGenerator.create(jdbc:mysql://localhost:3306/shopping_mall?serverTimezoneAsia/Shanghai, root, 123456) .globalConfig(builder - builder.author(yourname).outputDir(/tmp/code)) .packageConfig(builder - builder.parent(com.example.mall) .entity(entity).mapper(mapper).service(service).controller(controller)) .strategyConfig(builder - builder.addInclude(user, product, orders, cart)) .execute(); }生成后要改三处Entity 类继承Model或加上TableName注解确认表名映射Service 接口继承IServiceTController 里的增删改查需要自己补业务逻辑生成器给的只是空壳。分页插件在配置类里注册这是商城列表页的基础。漏掉这一步page方法会查出全表数据Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }注册分页插件后Service 层调用page(new Page(current, size), queryWrapper)就能拿到分页结果返回值是IPage对象内含records、total、current等字段前端只需要records和total。到这里后端骨架搭完接下来做接口。3. 商城核心接口JWT 登录、商品分页与购物车幂等3.1 基于 JWT 的登录与拦截器配置商城系统的登录态不能靠 session 撑因为 Vue 前端和后端大概率不在同一个域名下跨域场景下 session 的 Cookie 处理很麻烦。用 JWT 把用户 ID 和用户名加密进 token前端每次请求放在Authorization头里后端拦截器解析校验。登录接口在 Controller 层只做一件事接收用户名密码调用 Service 校验成功则签发 tokenPostMapping(/login) public ResultString login(RequestBody LoginDTO dto) { // 校验用户名密码成功则返回 token String token userService.login(dto.getUsername(), dto.getPassword()); return Result.success(token); }Service 里的login方法负责查库和生成 token密码用 BCrypt 加密存储不能明文入库。生成 token 用java-jwt库public String login(String username, String password) { LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.eq(User::getUsername, username); User user this.getOne(wrapper); if (user null || !BCrypt.checkpw(password, user.getPassword())) { throw new BusinessException(用户名或密码错误); } // JWT 生成过期时间设为 2 小时 Algorithm algorithm Algorithm.HMAC256(your-secret-key); return JWT.create() .withClaim(userId, user.getId()) .withClaim(username, user.getUsername()) .withExpiresAt(new Date(System.currentTimeMillis() 7200_000)) .sign(algorithm); }拦截器只需要处理两件事放行登录和商品查询接口拦截其余请求并校验 token。用HandlerInterceptor实现再注册到WebMvcConfigurer里Component public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String auth request.getHeader(Authorization); if (auth null || !auth.startsWith(Bearer )) { throw new BusinessException(未登录); } try { Algorithm algorithm Algorithm.HMAC256(your-secret-key); JWTVerifier verifier JWT.require(algorithm).build(); DecodedJWT jwt verifier.verify(auth.substring(7)); request.setAttribute(userId, jwt.getClaim(userId).asLong()); return true; } catch (JWTVerificationException e) { throw new BusinessException(登录状态已过期); } } }拦截器里拿到 userId 后放进 request attributeController 层用RequestAttribute(userId) Long userId接收这样每次请求都能知道当前操作人不需要前端额外传用户 ID。注册拦截器时有三个路径要放行/api/user/login、/api/user/register、/api/product/**。商品列表是公开接口但用户查询购物车和订单必须登录。提示JWT 的密钥固定写在代码里只适合毕设和 demo真实项目要放配置中心或环境变量。3.2 商品多条件分页查询前端传参到 SQL 映射商品列表是商城流量最大的接口需要同时支持关键词、分类、价格区间、上下架状态四个条件。DTO 接收前端参数QueryWrapper 动态拼接 SQLPostMapping(/page) public ResultIPageProduct page(RequestBody ProductQueryDTO dto) { PageProduct page new Page(dto.getCurrent(), dto.getSize()); LambdaQueryWrapperProduct wrapper new LambdaQueryWrapper(); // 关键词模糊匹配名称 if (StringUtils.hasText(dto.getKeyword())) { wrapper.like(Product::getName, dto.getKeyword()); } // 分类精确匹配 if (dto.getCategoryId() ! null) { wrapper.eq(Product::getCategoryId, dto.getCategoryId()); } // 价格区间price between min and max if (dto.getMinPrice() ! null) { wrapper.ge(Product::getPrice, dto.getMinPrice()); } if (dto.getMaxPrice() ! null) { wrapper.le(Product::getPrice, dto.getMaxPrice()); } // 只查上架商品下架的不给前端 wrapper.eq(Product::getStatus, 1); wrapper.orderByDesc(Product::getCreateTime); IPageProduct result productService.page(page, wrapper); // 返回前把 description 置空列表页不需要大字段减少流量 result.getRecords().forEach(p - p.setDescription(null)); return Result.success(result); }LambdaQueryWrapper不用写字符串字段名编译期能发现拼写错误。所有条件都先判空再拼接前端不传某个筛选项时 QueryWrapper 不会拼接对应条件。分页参数让前端传current和size默认值分别是 1 和 10前端只传空对象也能拿到第一页数据。这里有答辩高频问题为什么不直接传 SQL 语句答案是不安全拼接条件的方式 MyBatis 底层用#{}预编译能防 SQL 注入。如果面试官继续追问预编译原理可以说 MySQL 的PreparedStatement会先把 SQL 结构发给数据库解析参数作为纯数据处理不是拼接进语句。3.3 购物车加购与下单的幂等处理购物车表的核心约束是“一个用户对同一商品只能有一行记录”重复加购应该增加数量而不是插入新行。用LambdaUpdateWrapper的setSql实现原子更新public void addToCart(CartDTO dto, Long userId) { // 查询是否已存在该商品 LambdaQueryWrapperCart query new LambdaQueryWrapper(); query.eq(Cart::getUserId, userId).eq(Cart::getProductId, dto.getProductId()); Cart exist this.getOne(query); if (exist ! null) { // 存在则数量 购买数量 LambdaUpdateWrapperCart update new LambdaUpdateWrapper(); update.eq(Cart::getId, exist.getId()) .setSql(quantity quantity dto.getQuantity()); this.update(update); } else { Cart cart new Cart(); cart.setUserId(userId); cart.setProductId(dto.getProductId()); cart.setQuantity(dto.getQuantity()); cart.setChecked(1); this.save(cart); } }setSql(quantity quantity 1)是数据库层原子操作比先查出来加一再更新多一步不会出现并发覆盖。下单选中的购物车商品时后端要做两件事计算总金额、生成订单号。订单号生成规则用时间戳加用户 ID 加随机数长度控制在 32 位以内String orderNo String.format(%d%d%04d, System.currentTimeMillis(), userId, ThreadLocalRandom.current().nextInt(1000));下订单和清空购物车必须在一个事务里否则会出现订单创建成功但购物车没清空的数据不一致。Transactional加在 Service 方法上Transactional(rollbackFor Exception.class) public Long createOrder(ListLong cartIds, Long userId) { // 1. 根据 cartIds 查购物车项 // 2. 循环校验商品状态和库存 // 3. 计算总金额 // 4. 保存订单含商品快照 // 5. 删除对应购物车项 // 6. 返回订单 id return orderId; }rollbackFor Exception.class指定任何异常都回滚否则 Spring 默认只在 RuntimeException 时回滚业务里抛出的受检异常不会触发事务回滚这是很多商城系统库存多扣或订单重复的根源。4. Vue3 Element Plus 前端请求封装、动态路由与页面实现4.1 Vite 项目初始化与 vue 路由参数设计前端用 Vue3 加 Vite不用 vue-cli。初始化命令固定三行npm create vitelatest mall-web -- --template vue cd mall-web npm install装完后必须补四个依赖vue-router4、pinia、axios、element-plus。路由设计上商城系统至少要有首页、商品列表、商品详情、购物车、订单确认、订单列表、个人中心七个页面。其中商品详情页需要接收商品 ID 参数路由配置如下const routes [ { path: /, component: HomeView }, { path: /product/list, component: ProductList }, { path: /product/detail/:id, component: ProductDetail }, { path: /cart, component: CartView }, { path: /order/confirm, component: OrderConfirm }, { path: /order/list, component: OrderList }, { path: /user/profile, component: UserProfile } ] // 商品详情页参数获取 const route useRoute() const productId route.params.id关于 vue 路由参数有两点必须注意。第一从route.params取参数时不要解构到模板里直接用{{ route.params.id }}因为组件复用时参数变化不会触发重新渲染要用watch监听route.params变化再请求新数据。第二列表页到详情页的跳转用router.push传参用路径参数而不是 query 参数这样分享链接时也能直接定位到具体商品。4.2 Axios 实例封装与 Token 刷新时机每个页面单独写axios.get是毕设最容易留坑的地方。统一封装后所有请求自动附带 token响应时统一处理业务码不用在每个页面重复写错误弹窗。在src/utils/request.js里新建实例import axios from axios import { ElMessage } from element-plus import router from ../router const request axios.create({ baseURL: /api, timeout: 10000 }) // 请求拦截器自动附带 token request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) // 响应拦截器统一处理错误码 request.interceptors.response.use( response { const res response.data if (res.code ! 200) { ElMessage.error(res.message || 请求失败) if (res.code 401) { router.push(/login) } return Promise.reject(new Error(res.message)) } return res.data }, error { ElMessage.error(error.message || 网络异常) return Promise.reject(error) } ) export default request后端统一响应体ResultT的 code 字段200 成功、401 未登录、500 业务错误。前端拦截器判断 code 后再做统一提示这样后端抛BusinessException时前端只需要getMessage()弹一下后端返回的消息不需要每个页面各自判断错误分支。注意token 存储用 localStorage 就行毕设不需要考虑 XSS 攻击场景。但千万不要把 token 放进 store 里刷新就丢一定要持久化。4.3 购物车页面与商品列表页的组件交互购物车页面是前后端交互最密集的页面。每勾选一个商品底部栏要同步更新总金额同时“去结算”按钮要把选中的购物车 ID 传给订单确认页。Component 设计上拆成三块CartItem负责单行展示和数量加减CartFooter负责全选和总价父组件维护checkedItems数组。关键代码在数量加减和选中态联动上script setup import { ref, computed } from vue import request from ../utils/request const cartList ref([]) const checkedMap ref({}) // 全选逻辑 const toggleAll (checked) { cartList.value.forEach(item { checkedMap.value[item.id] checked }) } // 计算总价只有选中的才算 const totalPrice computed(() { return cartList.value .filter(item checkedMap.value[item.id]) .reduce((sum, item) sum item.price * item.quantity, 0) .toFixed(2) }) // 修改数量后同步后端 const changeQuantity (item, delta) { item.quantity delta if (item.quantity 1) item.quantity 1 request.put(/cart/quantity, { cartId: item.id, quantity: item.quantity }) } /script数量加减调用的是单独的更新接口不要等结算时才把全部购物车数据提交给后端。computed依赖checkedMap勾选状态一变总价自动更新这是 Vue3 响应式核心特性。商品列表页和购物车页的跳转交互注意一点列表页加入购物车之后直接路由跳转到购物车页不需要弹窗提示成功再点确认。选购行为越顺畅演示效果越好。5. 打包部署前后端分离的三条命令与答辩自测清单5.1 前端编译和后端打包的多环境配置前后端分离部署在毕设场景下不需要 Nginx 做复杂反向代理一条npm run build加一条mvn package就能搞定。前端package.json里的build命令默认调用 Vite 构建产物输出到dist目录把这个目录部署到 Nginx 的html根路径下即可。后端打包要注意测试环境配置和生产环境配置的切换。在pom.xml里配置 Maven Profile让打包时自动选择配置文件profiles profile idprod/id activation activeByDefaulttrue/activeByDefault /activation properties activatedPropertiesprod/activatedProperties /properties /profile profile iddev/id properties activatedPropertiesdev/activatedProperties /properties /profile /profiles对应地在application.yml里通过spring.profiles.active: activatedProperties引入不同环境的配置。dev环境数据库地址指向localhost日志打印 SQLprod环境关闭 SQL 日志数据库地址指向线上服务器。打包命令# 前端构建产物在 dist/ npm run build # 后端打包跳过测试 mvn clean package -DskipTests -Pprod打包后后端的target目录下会生成mall-server.jar通过java -jar mall-server.jar直接启动。前端dist目录放到 Nginx 的根目录并配置反向代理到后端 8080 端口server { listen 80; location / { root /usr/share/nginx/html; index index.html; } location /api/ { proxy_pass http://localhost:8080/api/; } }这段 Nginx 配置的作用是前端页面请求/api/login时Nginx 把请求转发到后端8080端口前后端不在同一个端口但浏览器感知不到跨域。5.2 答辩演示前必查的 6 个功能点演示环节最容易翻车的不是大流程而是小细节。按下单链路逐个验证一遍确认下面的场景都能跑通注册新用户后首次登录商品列表页和个人中心是否正常展示商品无库存时点击加入购物车是否有明确提示且不会生成订单购物车中数量修改到 0 再点击结算接口是否会返回参数校验错误JWT token 过期后请求购物车是否被重定向到登录页且登录后能回跳原页面订单支付流程中刷新订单详情页是否出现重复支付按钮数据库手动改掉某商品价格后已创建的订单总金额是否保持不变第二条到第五条都是评分老师爱提问的边界场景。倒数第二条涉及幂等控制订单状态从“待付款”改成“已付款”的更新操作要在 Service 层判断订单状态是否仍然是“待付款”否则并发请求会把已付款订单再付一次。商品表的价格字段由 MySQL 的 decimal 类型约束Java 侧用 BigDecimal 接收两条规则共同保证金额精度。5.3 用自定义注解统一打印接口耗时答辩讲到项目亮点时性能监控是低成本高收益的加分项。用 AOP 切面给所有 Controller 接口加耗时统计不用改动任何业务代码Aspect Component public class ApiLogAspect { Around(execution(* com.example.mall.controller.*.*(..))) public Object logApiTime(ProceedingJoinPoint pjp) throws Throwable { long start System.currentTimeMillis(); Object result pjp.proceed(); long cost System.currentTimeMillis() - start; String method pjp.getSignature().getDeclaringTypeName() . pjp.getSignature().getName(); System.out.println(method cost cost ms); return result; } }Around切面里的pjp.proceed()是放行原接口调用执行完后打印方法名和耗时。改动成本只有这个类加上Aspect和Component两个注解就能全局生效。答辩被问到“项目有哪些优化点”时这个切面配合前面的事务回滚、JWT 拦截器、MyBatis-Plus 分页插件能讲出三层完整的工程化思路。本文还有配套的精品资源点击获取
返回列表