简介:基于Spring Boot与Vue的校园二手商城系统完整源码包,面向JavaWeb学习者、毕业设计与课程设计人群,提供了一个典型的前后端分离校园交易平台实现方案。项目采用Spring Boot、Vue.js与MySQL构建,涵盖二手商品发布、浏览、交易管理等核心场景,可帮助理解RESTful接口设计、Vue组件化开发、Maven项目组织,以及Lombok简化实体类等实用技巧。资源共609个文件,压缩包约7.9MB,包含Java后端源码、Vue前端页面、SQL数据库脚本、Maven及yml配置;其中87个Java文件承担业务逻辑,130余个JavaScript配合CSS、HTML、SCSS/LESS等构建前端交互与页面样式,图片和gif演示素材便于核对界面效果与操作流程,另含layui、font-awesome等常见样式库资源。目前已有5345人学习下载。通过该包可完成本地环境部署,对照源码梳理用户登录、商品上架、订单管理等模块的编码思路,借助SQL脚本快速初始化数据库,整体目录结构清晰,适合需要完整校园二手交易课题方案的读者直接参考与二次开发。
1. 从"校园二手商城"到 Springboot+Vue 的前后端分离落地
把"校园二手商城"拆开看,最容易被低估的是订单状态和用户身份:谁在卖、谁在买、交易走到哪一步,这些比页面样式重要得多。用 Springboot+Vue 来落地,本质上是让后端只提供 REST API,前端只负责渲染和交互,两边通过 JSON 通信。这个组合非常适合课程设计、毕业设计,也适合想在正式做小程序之前先跑通 Web 原型的校内项目。后端要管的是登录令牌、商品生命周期、订单确认;前端要管的是路由、表单、商品列表。两条线可以并行开发,最后合并部署,这就是最常见的 Spring Boot Vue 前后端分离项目形态。
2. 先把骨架立起来:Spring Boot 后端分层与 Vue 前端工程
2.1 为什么选前后端分离而不是模板渲染
传统做法是用 Thymeleaf 或 JSP 在后端直接渲染 HTML。对于只有几个页面的管理后台,这种方式写起来很快,但校园二手商城有比较强的交互性:用户要刷新商品列表、发布商品后立即看到新状态、在订单列表里切换标签页。这些操作一旦全部改成 Ajax,后端模板就会慢慢变成一堆th:each加th:if的混合体,接口和页面耦合在一起,改动成本会变高。
前后端分离后,Spring Boot 只暴露/api/**接口,Vue 通过 Axios 拿到数据再渲染组件。前端可以单独用 mock 数据调试,后端用 Postman 或 Swagger 调试接口,两个任务的边界很清楚。代价是多了跨域、登录态透传、打包部署等步骤,这也是后文要解决的几个关键点。
2.2 后端工程的最小目录结构
创建 Spring Boot 项目时,版本选择要留意。如果 JDK 是 8 或 11,建议直接选 Spring Boot 2.7.x;如果是 Seata、Sa-Token 这类依赖对 3.x 还不完全熟悉的场景,也先别急着升版本。工程内按下面的方式分层,基本够一个校园二手商城使用:
src/main/java/com/campus/market ├── MarketApplication.java ├── config │ └── WebConfig.java # 跨域配置、拦截器注册 ├── controller │ ├── AuthController.java │ ├── ProductController.java │ └── OrderController.java ├── entity │ ├── User.java │ ├── Product.java │ └── Order.java ├── mapper │ ├── UserMapper.java │ ├── ProductMapper.java │ └── OrderMapper.java ├── service │ ├── AuthService.java │ ├── ProductService.java │ └── OrderService.java └── common ├── Result.java # 统一返回体 └── JwtUtil.java # 登录令牌生成与校验这个结构里,entity对应数据库表,mapper只做数据访问,service写业务规则,controller只做参数接收和路由分发。最容易犯的错误是让 Controller 直接操作 Mapper,等后面加了库存、订单状态流转时,相同的代码要在多个接口里复制。按层拆好后,改业务逻辑时只需要定位到对应的 service 方法,排查问题也能少花一半时间。
2.3 Vue 前端工程初始化与依赖配置
Vue 侧我习惯用 Vite 创建 Vue 3 项目。先跑初始化命令:
npm create vite@latest campus-market -- --template vue cd campus-market npm install npm install vue-router@4 axios pinia npm run dev第一条命令会生成一个带 Vite 配置的 Vue 3 工程;npm install安装基础骨架的依赖;vue-router负责路由,axios负责发 HTTP 请求,pinia负责跨组件共享登录态和用户信息。npm run dev启动后,前端默认跑在 5173 端口,Spring Boot 默认跑在 8080 端口,联调之前必须先把第 5 章要讲的跨域转发配置写对。
校园二手商城的技术选型可以归纳成下面这张表:
| 模块 | 技术选型 | 说明 |
|---|---|---|
| 后端框架 | Spring Boot 2.7 | 提供 REST API,内嵌 Tomcat |
| ORM | MyBatis-Plus | 简化单表 CRUD,分页插件成熟 |
| 数据库 | MySQL 8.0 / H2 | 开发阶段可用 H2,部署用 MySQL |
| 前端框架 | Vue 3 + Vite | 组合式 API,开发启动快 |
| 路由 | Vue Router 4 | 管理页面跳转与下单参数传递 |
| 状态管理 | Pinia | 保存 token、用户信息等全局状态 |
| HTTP 客户端 | Axios | 统一处理请求头与异常码 |
3. Spring Boot 后端核心接口与数据模型:从表结构到可复现的业务逻辑
3.1 二手商城最少需要哪几张表
一个没有在线支付、没有购物车结算的校内二手商城,最少需要三张表:用户表、商品表、订单表。商品图片可以放在商品表的image_url字段里,先不要单独建图片表;如果需要多图,再加关联表。下面是推荐的表结构:
CREATE TABLE user ( id BIGINT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, nickname VARCHAR(50), phone VARCHAR(20), avatar VARCHAR(255), role TINYINT DEFAULT 0, -- 0 普通用户,1 管理员 created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE product ( id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id BIGINT NOT NULL, title VARCHAR(100) NOT NULL, description TEXT, price DECIMAL(10,2) NOT NULL, status TINYINT DEFAULT 0, -- 0 在售,1 已售出,2 下架 image_url VARCHAR(500), created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE biz_order ( id BIGINT AUTO_INCREMENT PRIMARY KEY, product_id BIGINT NOT NULL, buyer_id BIGINT NOT NULL, seller_id BIGINT NOT NULL, price DECIMAL(10,2) NOT NULL, status TINYINT DEFAULT 0, -- 0 待确认,1 交易完成,2 已取消 created_at DATETIME DEFAULT CURRENT_TIMESTAMP );biz_order取这个名字是为了避免和 SQL 保留字order冲突。订单表里的price字段在创建订单时从商品表拷贝一份,而不是下单后实时去商品表读取。这样即使卖家在交易期间修改了价格,订单里保存的仍然是买家下单时看到的金额。状态字段用TINYINT并加注释,比直接存中文状态更好扩展。
3.2 用 MyBatis-Plus 写仓储层
实体类加上@TableName注解后,Mapper 接口继承BaseMapper就能获得基础的增删改查。商品实体类是这样:
@Data @TableName("product") public class Product { private Long id; private Long userId; private String title; private String description; private BigDecimal price; private Integer status; private String imageUrl; private LocalDateTime createdAt; }Mapper 接口定义一个带关键词和状态筛选的分页方法:
@Mapper public interface ProductMapper extends BaseMapper<Product> { IPage<Product> selectPageByKeyword(IPage<Product> page, @Param("keyword") String keyword, @Param("status") Integer status); }对应的 XML 文件里写动态 SQL:
<select id="selectPageByKeyword" resultType="com.campus.market.entity.Product"> SELECT * FROM product <where> <if test="keyword != null and keyword != ''"> AND (title LIKE CONCAT('%', #{keyword}, '%') OR description LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="status != null"> AND status = #{status} </if> </where> ORDER BY created_at DESC </select>这段 SQL 的关键点在于<where>标签会自动处理条件前的AND。如果只传status不传keyword,生成的语句是SELECT * FROM product WHERE status = 0 ORDER BY created_at DESC。分页插件会拦截带有IPage参数的方法,自动追加LIMIT,所以方法里不需要手写LIMIT。
3.3 商品发布、浏览与订单状态机的接口设计
Controller 层保持薄的状态,只做三件事:接收参数、调用 service、返回统一结果。统一返回体Result的结构是{ code, message, data }。商品相关的接口如下:
@RestController @RequestMapping("/api/product") public class ProductController { @Autowired private ProductService productService; @PostMapping public Result<Long> createProduct(@RequestBody @Valid ProductVO vo) { Long productId = productService.create(vo); return Result.success(productId); } @GetMapping("/{id}") public Result<ProductVO> detail(@PathVariable Long id) { return Result.success(productService.getDetail(id)); } @PostMapping("/{id}/off") public Result<Void> offShelf(@PathVariable Long id) { productService.offShelf(id); return Result.success(); } }ProductVO是视图对象,与实体类分开,避免把userId、createdAt这类字段直接暴露给前端。前端提交商品时只需要传title、description、price、imageUrl,后端从登录态里取当前用户 id 并填入实体,这是一定要在 service 里完成的。
订单状态流转是整个系统最容易失控的地方。常见做法是让订单状态只允许特定方向变化:待确认 0 → 交易完成 1,或 0 → 已取消 2。用 switch 把逻辑收拢在一个方法里:
public void updateStatus(Long orderId, Integer targetStatus) { Order order = orderMapper.selectById(orderId); if (order == null) { throw new BusinessException("订单不存在"); } switch (order.getStatus()) { case 0: if (targetStatus == 1 || targetStatus == 2) { order.setStatus(targetStatus); orderMapper.updateById(order); } break; case 1: case 2: throw new BusinessException("订单已结束,不能变更"); } }这样可以保证前端不能随便传一个status值就把订单改成任意状态。商品上架、下架、售出之间的联动也要放在同一个 service 方法里。比如买家下单成功后,商品状态要立刻从"在售"改成"已售出",否则同一件商品会被两个人同时下单,这一步建议用数据库事务包住。
4. Vue 页面与 API 联调:登录态、商品列表和下单环境
4.1 用 Axios 统一封装请求,把 Spring Boot 接口接进来
前端所有对后端的请求最好都走同一个 Axios 实例,这样可以在拦截器里统一加 token、统一处理登录失效。新建src/api/request.js:
import axios from 'axios' import { useUserStore } from '@/stores/user' import router from '@/router' const request = axios.create({ baseURL: '/api', timeout: 10000 }) request.interceptors.request.use(config => { const userStore = useUserStore() if (userStore.token) { config.headers.Authorization = `Bearer ${userStore.token}` } return config }) request.interceptors.response.use( res => { const body = res.data if (body.code === 401) { router.push('/login') } return body }, err => { return Promise.reject(err) } ) export default requestbaseURL设为/api,开发时通过 Vite 的server.proxy配置把/api开头的请求转发到 8080 端口,部署时由 Nginx 做同样的路径映射。请求拦截器从 Pinia 里读 token,放到Authorization请求头;响应拦截器统一处理code。这里要特别注意:不要在每次请求前手动写headers: { Authorization: ... },一旦在某个页面漏掉,登录态就会静默失效。
4.2 商品列表与详情页的前端状态处理
商品列表页用 Vue 3 的组合式 API 写,核心逻辑是页面加载时请求第一页,滚动到底部时追加下一页。下面的代码放在script setup中:
<script setup> import { ref, onMounted } from 'vue' import { getProductPage } from '@/api/product' const list = ref([]) const page = ref(1) const pages = ref(1) const loading = ref(false) async function loadProducts() { if (loading.value) return loading.value = true const res = await getProductPage({ page: page.value, size: 10 }) list.value.push(...res.data.records) pages.value = res.data.pages page.value += 1 loading.value = false } onMounted(() => { loadProducts() }) </script>合并列表时用push(...res.data.records)而不是list.value = res.data.records,这样才能在翻页场景下保留之前已经渲染的商品。pages字段表示总页数,页面底部可以判断page > pages时显示"没有更多了",用来阻止无意义的重复请求。每个商品卡片上用router.push跳到详情页,路由参数就带上id:
router.push({ path: `/product/${item.id}` })在详情页里用route.params.id接收,再请求/api/product/{id}接口。
4.3 下单与我的订单:跨组件数据流
校园二手商城的下单不需要购物车。商品详情页里一个"联系购买"按钮,点击后带着productId和商品价格创建订单。这个流程里,productId通过路由参数传递,当前用户信息和 token 存在 Pinia 中。页面刷新后要从localStorage恢复 Pinia 里的用户信息,否则刷新页面登录态会丢。
订单列表页面要区分买家视角和卖家视角,后端可以分别提供两个接口:
| 页面入口 | 后端接口 | 说明 |
|---|---|---|
| 买家订单 | GET /api/order/buyer | 当前用户作为 buyer_id 的订单 |
| 卖家订单 | GET /api/order/seller | 当前用户作为 seller_id 的订单 |
前端把状态码翻译成文字:0是"等待卖家确认",1是"交易完成",2是"已取消"。卖家端对0显示"确认交易"按钮,买家端对1显示"确认完成"按钮。不要在前端直接维护订单状态逻辑,状态机必须放在后端,前端只做展示和动作触发。
5. Spring Boot + Vue 的部署验证与 3 个容易踩的坑
5.1 Vite 开发服务器的跨域转发配置
开发环境不需要 CORS 注解,更省事的是让 Vite 把/api开头的请求转发到后端。在vite.config.js中写入:
export default defineConfig({ server: { host: '0.0.0.0', port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })配置完成后,重启npm run dev,前端代码里请求/api/product/1时,开发服务器会转发到http://localhost:8080/api/product/1。浏览器控制台不再报跨域错误,也不需要在 Spring Boot 里额外放开所有跨域来源。
5.2 完整验证一条交易链路
后端启动MarketApplication,前端启动 Vite 后,按下面顺序验证:
- 注册一个用户 A,再注册用户 B;
- 用户 A 发布一件商品,设置价格和图片;
- 用户 A 退出,登录用户 B,在列表页搜索商品标题并打开详情;
- 用户 B 下单,订单状态变成"待确认";
- 重新登录用户 A,进入卖家订单列表,完成交易;
- 查看数据库中
product表的状态是否已经从 0 变成 1。
如果第 4 步商品状态没有被同步改成"已售出",说明下单事务没有包住商品更新,要在OrderService的createOrder方法上加@Transactional。
5.3 三个让新手卡住的点
第一个是 Spring Boot 3.x 的包名变化。Spring Boot 3 把javax.servlet换成了jakarta.servlet,很多老教程里的代码直接复制会编译失败。解决方案是使用 2.7.x 起步,或者把所有javax的 import 改成jakarta。
第二个是 Vue Router 的 history 模式刷新 404。使用createWebHistory后,前端路由就不再是真实的文件路径,线上部署时如果只配置静态资源映射,刷新/product/3会返回 404。Nginx 里需要加try_files $uri $uri/ /index.html;,让前端路由回退到首页。
第三个是 MyBatis-Plus 分页不生效。只引入mybatis-plus-boot-starter还不够,必须注册分页拦截器:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }没有这个配置时,selectPage返回的分页对象里records是全部数据,pages计算也错误。加了拦截器之后,SQL 才会自动带上LIMIT,同时这个配置对 Spring Boot 2.7 和 3.x 都适用。
本文还有配套的精品资源,点击获取