简介:一份基于SpringBoot与Vue.js的果蔬电商平台完整工程包,面向Java方向毕业设计或课程设计人群,可用于学习前后端分离开发、电商业务建模与部署实践。包内共有751个文件,压缩后大小16.6MB,涵盖Java后端源码、Vue前端页面、FTL模板、CSS样式、SQL数据库脚本、XML配置以及项目说明文档等,结构清晰,便于按需查阅。目前已有29人浏览学习,适合需要快速搭建果蔬商城原型并理解SpringBoot+MySQL技术栈的读者。项目不仅包含可运行的前后端代码和数据库表设计,还附带了README说明与界面素材,能够帮助使用者从数据模型、接口实现到页面展示完整掌握一个轻量级电商平台的构建思路。
1. 为什么果蔬电商要用SpringBoot而不是SSH或者Node
很多人做电商毕设时,第一反应是找一套通用商城代码改改。但果蔬电商有个容易被忽略的特点:库存波动大、保质期短、订单状态变化频繁,最难的不是增删改查,而是并发下单时如何防止库存变负数。这个项目基于SpringBoot 2.x + Vue.js + MySQL,后端利用SpringBoot自动装配和Starter机制减少配置,前端用Vue组件化搭页面,是标准的前后端分离结构。无论是做课程设计、毕业设计,还是入门SpringBoot项目实战,都可以直接拿它当骨架,但前提是先搞懂自动装配、表设计和事务边界。
2. SpringBoot后端核心:自动装配、分层结构与商品接口
2.1 SpringBoot自动装配与项目初始化
SpringBoot最常被提起的就是自动装配。@SpringBootApplication实际上由@SpringBootConfiguration、@EnableAutoConfiguration和@ComponentScan三个注解组合而成。@EnableAutoConfiguration在启动时读取classpath下的META-INF/spring.factories文件,按条件注解判断哪些自动配置类需要生效。比如引入spring-boot-starter-web后,内嵌Tomcat、DispatcherServlet、Jackson消息转换器都会被自动配置好,开发者不用再手动编写web.xml和springmvc.xml。
初始化项目时,常见做法是使用Spring Initializr在IDEA中生成,也可以手工创建Maven工程。下面是一个最小可运行的pom.xml片段:
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.13</version> <relativePath/> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> </dependencies>spring-boot-starter-parent负责统一管理依赖版本,不用手动指定mysql-connector-java的版本,避免多个库之间版本不一致。选择MyBatis-Plus而不是JPA,是因为这个项目的商品列表经常要按分类、价格、销量组合筛选,MyBatis-Plus的LambdaQueryWrapper能把这些条件以类型安全的方式写出来,JPA在这种动态查询场景下反而需要写@Query注解处理。mybatis-plus-boot-starter就是SpringBoot整合MyBatis-Plus的Starter。
注意SpringBoot 2.7.13是基于javax命名空间的,网上大部分资料和SSM课程设计案例都可以直接复用。如果换成SpringBoot 3.x,javax要改成jakarta,很多老代码要调整,这是刚接触SpringBoot的人最容易踩的版本坑。
2.2 SpringBoot分层架构与常用注解
项目后端按controller/service/mapper/entity四层组织,复杂一点的增加dto和vo。Controller层负责接收HTTP请求、做参数校验、返回统一结果;Service层写业务规则和事务边界;Mapper层继承BaseMapper获得通用CRUD能力。切分原则是:Controller不能直接操作数据库,Mapper也不能写业务判断,否则后续扩展时改动面会不可控。
下面是一组在SpringBoot项目里出现频率最高的注解。
| 注解 | 出现位置 | 作用 |
|---|---|---|
| @RestController | Controller类 | 处理HTTP请求并直接返回JSON |
| @RequestMapping | 类或方法 | 映射URL地址 |
| @Autowired | 字段或方法 | 注入Spring容器中的Bean |
| @Service | 业务类 | 标记为业务组件 |
| @Mapper | Mapper接口 | 让MyBatis扫描到该接口 |
| @Transactional | 方法或类 | 声明事务边界 |
| @ConfigurationProperties | 配置类 | 绑定application.yml里的配置项 |
这组注解比网上流行的一段式代码更容易维护。以商品列表接口为例,Controller只负责接收分页参数和分类参数:
@RestController @RequestMapping("/api/product") public class ProductController { @Autowired private IProductService productService; @GetMapping("/list") public Result<List<ProductVO>> list(@RequestParam(defaultValue = "1") Integer page, @RequestParam(defaultValue = "10") Integer size, @RequestParam(required = false) String category) { return Result.success(productService.pageList(page, size, category)); } }@RestController是@Controller和@ResponseBody的组合,方法返回值会被Jackson序列化成JSON。@RequestParam的defaultValue可以让page和size在缺省时分别取1和10,category可空。Result是一个统一包装类,包含code、msg、data三个字段,避免每个接口返回结构不一致。
真正的查询逻辑放在Service实现里:
@Service public class ProductServiceImpl extends ServiceImpl<ProductMapper, Product> implements IProductService { @Override public List<ProductVO> pageList(Integer page, Integer size, String category) { LambdaQueryWrapper<Product> wrapper = Wrappers.lambdaQuery(); if (StringUtils.hasText(category)) { wrapper.eq(Product::getCategory, category); } wrapper.orderByDesc(Product::getCreateTime); Page<Product> p = this.page(new Page<>(page, size), wrapper); return p.getRecords().stream().map(ProductVO::from).collect(Collectors.toList()); } }LambdaQueryWrapper用方法引用替代字符串列名,category为空时不会拼接条件,这样既防SQL注入又不会产生多余的“where 1=1”。orderByDesc保证新品排在前面。ProductVO::from是实体到视图对象的转换方法,可以隐藏数据库字段结构,不让前端感知到内部实现。
2.3 参数配置与商品列表接口调试
application.yml是SpringBoot的配置入口。果蔬电商的本地开发配置一般是这样的:
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/fruit_shop?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8 mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImplurl里的characterEncoding=utf8解决中文乱码,serverTimezone=Asia/Shanghai避免MySQL 8驱动与服务器时区不一致带来的时间偏移,useSSL=false省去SSL证书告警。map-underscore-to-camel-case开启后,数据库的create_time字段会自动映射到实体的createTime属性,不用写一堆ResultMap。log-impl配置成StdOutImpl后,控制台会直接打印SQL,调试列表接口时能很直观地看到limit参数和where条件。
如果要把项目交给其他人跑,建议把配置文件拆成application-dev.yml和application-prod.yml,然后在application.yml里用spring.profiles.active选择环境。默认密码不要用123456,至少改成强密码,后面第五章还会提加密。
接口启动后,可以直接访问http://localhost:8080/api/product/list?page=1&size=10验证。如果返回JSON且data有值,说明数据库连接、SQL映射和Jackson序列化都正常。这时再看控制台SQL日志,可以确认分页查的是哪张表、有没有走索引。
3. Vue前端与SpringBoot的跨域联调:组件化页面与接口对接
3.1 Vue项目结构与组件化页面
前端使用Vue CLI创建,项目内部按职责划分目录。下表是这类前后端分离商城最常见的结构:
| 目录 | 职责 |
|---|---|
| src/api | 按模块封装接口请求函数 |
| src/views | 页面级组件,路由直接对应 |
| src/components | 可复用组件,比如商品卡片、数量选择器 |
| src/router | 路由配置与守卫 |
| src/store | Vuex状态管理,存储购物车和用户信息 |
| src/utils | axios实例、工具函数 |
商品列表页不会把整块UI写在同一个文件里,通常会把商品卡片抽成组件。一个简单的ProductCard组件如下:
<template> <el-card :body-style="{ padding: '0px' }" shadow="never"> <img :src="product.img" class="product-img" /> <div class="product-info"> <div class="product-name">{{ product.name }}</div> <div class="product-price">¥{{ product.price }} / {{ product.unit }}</div> </div> </el-card> </template> <script> export default { name: 'ProductCard', props: { product: { type: Object, required: true } } }; </script>props表示父组件传入的数据,子组件内部不能修改props,只能通过$emit向父组件抛事件。这个单向数据流保证商品对象在多个组件间不会被无意覆盖。果蔬商品必须把单位展示出来,“斤”“盒”“份”直接影响下单数量,这是通用商城很少注意的地方。
3.2 axios封装与接口调用
页面里直接发axios请求会导致错误处理代码重复。常见做法是封装一个request对象,统一维护baseURL、超时时间和拦截器:
import axios from 'axios'; const request = axios.create({ baseURL: '/api', timeout: 10000 }); request.interceptors.request.use(config => { const token = localStorage.getItem('token'); if (token) { config.headers.Authorization = token; } return config; }); request.interceptors.response.use( res => { const code = res.data.code; if (code === 200) { return res.data.data; } return Promise.reject(new Error(res.data.msg)); }, err => Promise.reject(err) ); export default request;baseURL固定为/api,是为了配合开发环境的代理转发,也方便生产环境用Nginx统一加前缀。请求拦截器把登录后保存的token放进Authorization头,响应拦截器把后端包装的Result中code字段解掉,页面拿到的是真实的data,不需要每个页面都重复写if (res.data.code !== 200)这种判断。
接口定义放在src/api/product.js中:
import request from '@/utils/request'; export function getProductList(params) { return request({ url: '/product/list', method: 'get', params }); }这样列表页只需要调用getProductList({ page: 1, size: 10, category: 'fruit' }),参数由组件内部生成,接口模块负责URL和HTTP方法。
3.3 前后端联调的跨域与代理配置
开发过程中前端运行在8081端口,后端运行在8080端口,属于跨域请求。最干净的做法不是在后端写CorsFilter,而是在vue.config.js中配置代理:
module.exports = { devServer: { port: 8081, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, pathRewrite: { '^/api': '' } } } } };注意pathRewrite的写法:前端请求/api/product/list,代理转发到后端时会重写为/product/list,所以后端Controller里不要带/api前缀。很多springboot vue前后端分离项目联调失败,就是因为后端写了/api,代理也保留/api,最后实际请求变成/product/list的两次拼接,返回404。
如果生产环境用Nginx,也要注意proxy_pass是否带斜杠:
location /api/ { proxy_pass http://127.0.0.1:8080/; }这里的斜杠会把/api/去掉,和后端接口路径保持一致。如果漏掉尾部斜杠,Nginx会原样传递/api/...,接口直接404。
除了跨域,还需要处理登录态。前端路由守卫只保证页面能被访问到,真正的权限判断必须由后端接口完成:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token'); if (to.meta.requireAuth && !token) { next('/login'); } else { next(); } });守卫里检查的是本地有没有token,并不能防止用户篡改。所以下单、支付这类接口必须在后端校验token对应的用户身份,前端守卫只用来改善体验。
4. 果蔬电商的数据模型与订单库存处理
4.1 核心数据表结构
果蔬电商的数据表不需要设计得像通用商城那么庞大,核心就是用户、商品、订单、订单项和购物车。下面给出商品表和订单表的建表SQL:
CREATE TABLE `product` ( `id` bigint NOT NULL AUTO_INCREMENT, `name` varchar(100) NOT NULL COMMENT '商品名称', `category` varchar(50) NOT NULL COMMENT '分类:蔬菜/水果/肉类', `price` decimal(10,2) NOT NULL COMMENT '价格', `unit` varchar(10) NOT NULL DEFAULT '斤' COMMENT '单位', `stock` int NOT NULL DEFAULT 0 COMMENT '库存', `sales_count` int NOT NULL DEFAULT 0 COMMENT '销量', `status` tinyint NOT NULL DEFAULT 1 COMMENT '1上架 0下架', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_category` (`category`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `orders` ( `id` bigint NOT NULL AUTO_INCREMENT, `user_id` bigint NOT NULL, `order_no` varchar(32) NOT NULL, `total_amount` decimal(10,2) NOT NULL, `status` tinyint NOT NULL DEFAULT 0 COMMENT '0待付款 1待发货 2待收货 3完成 4取消', `address_snapshot` varchar(255) DEFAULT NULL COMMENT '收货地址快照', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;各表职责如下:
| 表名 | 作用 | 关键字段 |
|---|---|---|
| user | 用户账号信息 | id, username, password |
| product | 商品信息 | price, stock, category |
| orders | 订单主表 | order_no, status, total_amount |
| order_item | 订单明细表 | order_id, product_id, product_name |
| cart | 购物车 | user_id, product_id, count |
价格字段必须用decimal而不是float,否则浮点误差会让对账变成灾难。订单表冗余address_snapshot,是因为用户地址可能修改,但历史订单需要保留下单那一刻的地址。status用数字表示,比字符串节省空间,也方便在代码里写枚举。
4.2 订单流程与事务控制
下单是电商平台最核心的流程:查商品、扣库存、写订单、写订单明细。任何一个步骤失败,前面已经扣掉的库存都要恢复。所以createOrder必须是一个事务方法:
@Transactional(rollbackFor = Exception.class) public Long createOrder(Long userId, Long productId, Integer count) { Product product = productMapper.selectById(productId); if (product == null || product.getStatus() != 1) { throw new BusinessException("商品不存在或已下架"); } int rows = productMapper.deductStock(productId, count); if (rows == 0) { throw new BusinessException("库存不足"); } Orders order = new Orders(); order.setUserId(userId); order.setOrderNo(generateOrderNo()); order.setTotalAmount(product.getPrice().multiply(BigDecimal.valueOf(count))); order.setStatus(0); ordersMapper.insert(order); OrderItem item = new OrderItem(); item.setOrderId(order.getId()); item.setProductId(productId); item.setProductName(product.getName()); item.setPrice(product.getPrice()); item.setCount(count); orderItemMapper.insert(item); return order.getId(); }@Transactional(rollbackFor = Exception.class)是SpringBoot里最容易忽略的参数。Spring默认只对RuntimeException回滚,如果业务代码抛的是检查异常,不加rollbackFor不会回滚,库存就会被白白扣掉。订单明细表保存product_name和price快照,这样商品改价或改名称后,历史订单仍能显示当时的商品信息。
generateOrderNo()一般生成时间戳加随机数,比如yyyyMMddHHmmss加6位随机数。更严格的场景可以引入雪花算法,但在毕设项目里时间戳加随机数加数据库唯一索引已经够用。唯一索引uk_order_no的作用是防止网络重试时同一请求被处理两次。
4.3 库存扣减与高并发场景
事务方法里的“先select再update”其实有并发漏洞。假设库存只剩1件,两个请求同时读到库存为1,都通过判断,然后各自把库存改成0,就会超卖。正确做法是把库存判断和扣减放在一条UPDATE语句里:
<update id="deductStock"> update product set stock = stock - #{count} where id = #{id} and stock >= #{count} </update>这条SQL利用InnoDB的行锁,保证同一时刻只有一个事务能执行扣减。如果影响行数为0,说明库存不够,业务层立刻抛异常回滚。调用逻辑如下:
public interface ProductMapper extends BaseMapper<Product> { int deductStock(@Param("id") Long id, @Param("count") Integer count); }注意这里不能用MyBatis-Plus自带的updateById,因为那种方式会先查一次再组装UPDATE,无法保证stock >= count这个条件。如果项目里没有XML,也可以用UpdateWrapper:
productMapper.update(null, Wrappers.<Product>lambdaUpdate() .setSql("stock = stock - {0}", count) .eq(Product::getId, productId) .ge(Product::getStock, count));两种方式原理相同。如果场景继续升级到秒杀,MySQL扣减扛不住时,才考虑Redis的decr预扣减加异步订单落库。对课程设计和毕业设计来说,数据库条件扣减已经是合格方案。
5. 从毕设到上线:安全加固与部署验证
5.1 防SQL注入与XSS
MyBatis的#{}是PreparedStatement占位符,能挡住绝大部分SQL注入。真正要小心的是${},比如动态排序字段。排序字段不适合用预编译,常见做法是用白名单映射:
private static final Map<String, String> ORDER_MAP = Map.of( "price", "price", "sales", "sales_count", "new", "create_time" ); String orderField = ORDER_MAP.getOrDefault(sort, "create_time");请求里的sort参数只能映射到固定的三个列,传任何其他值都回退到create_time。XSS方面,Vue的插值表达式会自动转义,v-html要尽量少用。如果商城的商品详情允许富文本,前端口罩需要做清理,接口也要过滤script标签。
5.2 敏感配置与打包部署
application.yml里的数据库密码不能明文写在仓库里。可以引入jasypt-spring-boot-starter,用ENC()包裹密文,启动时通过环境变量传入解密盐:
jasypt: encryptor: password: ${JASYPT_SALT}这样即使源码泄露,没有环境变量也解不出密码。打包使用Maven命令:
mvn clean package -DskipTests生成的jar文件位于target目录。部署时用nohup启动,并把日志写到文件:
nohup java -jar target/fruit-shop.jar --spring.profiles.active=prod > logs/app.log 2>&1 &注意SpringBoot 2.7.13对应JDK 8或11,服务器上不要直接装最新版JDK 21。前端构建产物由Nginx静态托管,接口请求走/api代理到后端服务。
5.3 接口验证清单
启动后先验证商品列表:
curl "http://localhost:8080/api/product/list?page=1&size=10"如果返回code为200且data有记录,说明数据库连接和Mapper正常。再验证下单接口的异常回滚:传一个超过库存的count,调用后查询库存和orders表,库存应保持不变,订单表也不会有新数据。用JMeter开100个并发线程对同一个商品下单,请求结束后查product.stock,只要没有负数就说明库存扣减逻辑合格。这些验证做完,这个项目再拿去提交或演示,才算真正站得住。
本文还有配套的精品资源,点击获取