很多人做校园项目,第一反应就是做个管理系统,但说实话,管理系统练不到什么真东西,无非就是增删改查。我这次做的是校园网上店铺系统,前后端分离,后端 SpringBoot + MyBatis + MySQL,前端 Vue 生态,把注册登录、商品发布、购物车、下单交易、订单管理、后台审核这些真实电商场景全部走了一遍。对想拿来做毕业设计、或者简历里需要一个完整项目的人来说,这套东西的价值在于它不只是一个 CRUD demo,而是一套能跑通的完整业务流。
这个系统以校园为场景,商品以二手书、闲置电子产品、生活用品为主,用户角色划分为买家、卖家和平台管理员。买家可以逛店、搜索商品、加购物车、下单付款(模拟支付);卖家可以发布商品、处理订单;管理员负责审核商品和维护用户。整个流程覆盖了真实电商平台最核心的状态流转:订单状态、库存扣减、权限区分,每一个环节都有实际业务逻辑要处理,而不是简单查个表。
适合谁?我建议两类人参考。第一类是正在做毕业设计或课程项目、需要一个完整可演示系统的人;第二类是初级 Java 开发或转正人员,想系统走一遍前后端分离开发流程的人。当然,如果只是为了应付作业,这套代码也能直接拿去改改用,但我更推荐跟着文章把关键模块自己敲一遍,收获会大得多。
1. 项目概述与需求拆解
1.1 这个项目解决的是什么问题
校园里其实有天然的闲置交易需求:大四离校要处理教材,毕业生淘汰键盘显示器,换宿舍翻出各种用不上但扔了可惜的东西。校园论坛、微信群是主要渠道,但信息太散,没有统一的商品展示、搜索和交易流程,交易基本靠私聊约时间,没有平台做约束和管理。
这个系统的定位就是给校园场景做一个轻量级的网上店铺平台。注意,它不等同于闲鱼那种 C2C 全开放平台,而是有平台审核机制、有固定用户群体、有简化交易流程的校内闭环。卖家发布商品之后,商品要先经过管理员审核才能上架,这就解决了校园交易里“不知道卖东西的人靠不靠谱”的核心痛点;买家下单后,订单状态全程可见,不用再靠微信聊天记录回忆交易进度。
从业务架构上讲,主要拆成几个模块:用户模块负责注册登录和个人信息维护;商品模块负责发布、审核、浏览、搜索和上下架;交易模块负责购物车、下单、支付模拟和交易状态流转;后台模块负责用户管理、分类管理和数据统计。每个模块内部都有独立的 service 层和数据库表,相互之间的依赖通过接口完成,这也为后面做前后端分离打好了基础。
1.2 核心角色与业务闭环
整个系统有三个角色,权限由 user 表的 role 字段区分。
- 普通用户(买家):浏览商品、关键词搜索、分类筛选、加入购物车、提交订单、模拟支付、查看订单列表、管理收货地址。
- 卖家:在普通用户的基础上,增加商品发布、图片上传、商品编辑、上下架操作、订单发货、查看自己的商品列表和简单销售统计。
- 管理员:登录独立后台,可以审核卖家发布的商品,支持通过或驳回;可以禁用违规用户;可以管理商品分类;查看平台维度的交易数据概览。
核心业务闭环是这样的:注册登录 -> 浏览搜索商品 -> 加入购物车 -> 提交订单 -> 模拟支付 -> 卖家发货 -> 买家家确认收货 -> 订单完成。这个闭环听起来简单,但每一步拆开后都有细节。比如购物车勾选和库存校验、下单时的地址选择、订单状态的流转记录、支付回调的幂等性处理,这些才是项目真正有含金量的地方。
当时我把这个业务闭环写在设计文档里的第一页,因为后面所有表设计、接口设计、前端页面都是围绕这个闭环展开的。先想清楚流程,再写代码,比边写边想省事得多。
2. 技术选型:为什么是这套组合
2.1 前后端分离到底带来什么好处
传统做法是后端用 Thymeleaf 渲染页面,页面和接口在一个工程里,开发调试都会互相影响。前后端分离之后,前端工程和后端工程完全独立,可以并行开发,只要定好接口文档,前端用 mock 数据先开发页面,后端专注写接口,最后联调时统一对接,效率会高很多。
部署上分离也更灵活。后端打包成 jar 包,前端打包成静态文件,可以放在同一台服务器的不同目录,也可以把静态文件放到 Nginx,后端只提供 API,哪边出问题就单独处理哪边,不用把整个项目重新打包。对校园项目的服务器配置来说,这种解耦也意味着前端静态资源能走 Nginx 的高性能静态文件服务,后端 Tomcat 只处理动态请求,压力可以分摊。
但分离也带来一个绕不开的问题:跨域。前端 dev 服务器默认在 8080 端口跑,后端接口在 9090 端口跑,浏览器会拦截跨域请求。处理方式有两种:开发环境用 Vite 的 proxy 把/api路径转发到后端,生产环境用 Nginx 反向代理或者直接把前端 dist 目录交给后端托管。这两种方式后面部署章节我都会讲到,尤其是后者,很多新手容易在这里卡壳。
2.2 每个技术组件选型的理由
后端选择 SpringBoot,核心原因是“约定大于配置”。我们要在短时间内搭建一个结构清晰的 Web 项目,如果从零手写 SSM 的 XML 配置,光是 spring、springmvc、mybatis 三个框架的配置文件就要写一大堆,还没有任何业务价值。SpringBoot 帮我自动装配了内嵌 Tomcat、自动配置了数据源,我只需要写自己的业务代码。要注意的是 SpringBoot 版本别选太老,2.7.x 或 3.x 都比较稳,这里我用的 2.7.x,兼容性友好,网上资料也多。
持久层是 MyBatis 而不是 JPA,核心原因是 SQL 可控。校园店铺这种业务,商品列表的分页搜索、订单统计报表,多表关联查询非常频繁,SQL 稍微复杂一点,JPA 的自动生成 SQL 就不好控制了,排错很麻烦。MyBatis 把 SQL 直接写在 Mapper XML 里,性能瓶颈在哪一眼能看出来。加上它支持动态 SQL,用<if>标签就能拼出灵活的分页搜索条件,不用为每一种查询条件单独写方法。
MySQL 是关系型数据库里最主流的选择,没有悬念。校园项目的数据量级通常不会到百万级别,单机 MySQL 性能完全够用,生态工具也成熟,Navicat、DataGrip 都可以直接连上来调试 SQL。MySQL 8.0 的窗口函数、通用表达式在统计场景很好用,不过这个项目里用到的不多,主要用的是索引优化和事务隔离。
前端选择 Vue,理由更简单:组件化开发体验好,生态成熟,上手门槛低。搭配 Element UI 组件库,后台管理页面几乎不用手写样式,表格、表单、弹窗、分页都是现成组件。Vue 的响应式机制让购物车、订单这种频繁变更状态的页面写起来非常舒服,数据一变,视图自动更新。
3. 数据库设计:从表结构看业务
3.1 核心表结构与字段说明
数据库设计是项目的基础,表结构没定好,后面写接口和页面都会很别扭。我这套系统一共设计了 6 张核心表:用户表、分类表、商品表、购物车表、订单表、订单明细表,外加一张收货地址表。
用户表(user)主要字段:id、username、password、nickname、avatar、phone、role、status、create_time。password 存的是 BCrypt 加密后的密文,不是明文;role 用 0 表示普通用户,1 表示卖家,2 表示管理员;status 用 0 表示正常,1 表示禁用。
商品表(goods)的字段要重点关注:id、seller_id、category_id、name、description、price、original_price、stock、cover、images、status、view_count、create_time、update_time。这里有两个设计细节容易被忽略。一个是 images 字段,我用逗号分隔的字符串存多张图片的路径,因为校园项目图片量不大,没必要单独建一张图片表;另一个是 status 字段,我用 0 表示待审核、1 表示在售、2 表示下架、3 表示驳回,后续上架操作只能从 0 或 2 状态转成 1,避免商品在售状态下被随意修改。
订单表(orders)和订单明细表(order_item)是典型的一对多关系,也最见基本功。订单表存订单编号、用户 id、卖家 id、总金额、收货人姓名、电话、地址、订单状态、下单时间、支付时间、发货时间、完成时间。订单明细表存 goods_id、商品名称、商品价格、购买数量、小计金额。注意,明细表里必须冗余存储一份商品名称和价格的快照,因为商品信息可能被卖家修改,下单时的价格和名称要锁定下来,否则后续订单详情会出现价格对不上的情况。
3.2 关键索引、状态机与数据一致性
索引这块,我把三个字段设置了索引:goods 表的 category_id 和 seller_id、orders 表的 user_id 和 seller_id、order_item 表的 goods_id。为什么这么设计?因为业务查询场景基本就是“按分类逛商品”“查某卖家的商品”“查某用户的订单”,这些查询都是高频操作,不加索引全表扫描必然慢。但要注意索引不是越多越好,每个额外索引都会拖慢写入速度,所以只给真正高频的查询字段建索引。
订单状态我用整数状态码来管理,不要用字符串。0 待付款、1 待发货、2 待收货、3 已完成、4 已取消。状态流转是有约束的:待付款只能取消或支付变成待发货;待发货只能发货变成待收货;待收货只能确认收货变成已完成。这些约束在后端 service 层判断,比如取消订单前必须检查 status 是否为 0,避免用户支付后还能取消订单。
数据一致性最经典的问题是库存超卖。下单时直接 UPDATE goods SET stock = stock - #{quantity} WHERE id = #{id} AND stock >= #{quantity},这样利用数据库原子更新和条件判断,天然防止了并发下的超卖。处理购物车的下单事务时,我会在 service 方法上加 @Transactional,扣库存、创建订单、创建订单明细三个操作在同一个事务里,任何一个失败都能回滚,不会出现订单失败但库存被扣掉的情况。
4. 后端核心实现:SpringBoot 与 MyBatis 实战
4.1 分层架构与统一接口规范
后端工程我按标准分包:controller、service、mapper、entity、dto、vo、config、common。实体类直接对应数据库表,DTO 接收前端参数,VO 返回前端数据,避免把数据库实体直接暴露给前端,比如密码字段绝对不能出现在 VO 里。
接口规范方面,我写了一个统一的返回包装类 Result,结构是{ code: 200, message: "success", data: ... }。所有接口都返回这个包装类,前端 axios 拦截器只需要判断 code 是否为 200,其他情况统一弹错误提示。再配合 @RestControllerAdvice 全局异常处理器,业务异常直接抛自定义 BizException,系统会自动转成统一的错误返回,不用每个接口都手写 try-catch。
代码上是这样的:
@Data public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.setCode(200); result.setMessage("success"); result.setData(data); return result; } public static <T> Result<T> error(String message) { Result<T> result = new Result<>(); result.setCode(500); result.setMessage(message); return result; } }登录接口发一个 token,后续请求在 header 里携带,拦截器解析 token 后把用户信息放到 ThreadLocal 里,业务代码随时可以取当前登录用户。这个方案比传统的 Session 更适合前后端分离,因为后端不依赖 Cookie,前端不管用 Web 还是 App,只要在请求头里加一个字段就行。
4.2 MyBatis 动态 SQL 与分页查询
商品列表是项目里最复杂的查询接口,我把它单独拿出来讲。它要支持关键词模糊搜索、分类筛选、价格区间筛选、综合/销量/价格排序,还要分页。如果用传统的 DAO,一个接口配一个 SQL 模板,每个条件都要判断是否为空,代码会非常冗余;但 MyBatis 动态 SQL 能优雅解决这个问题。
下面这段是商品查询 Mapper XML 的核心结构:
<select id="selectGoodsList" resultType="com.demo.vo.GoodsVO"> SELECT g.id, g.name, g.price, g.cover, g.stock, g.view_count, c.name AS category_name, u.nickname AS seller_name FROM goods g LEFT JOIN category c ON g.category_id = c.id LEFT JOIN user u ON g.seller_id = u.id <where> g.status = 1 <if test="keyword != null and keyword != ''"> AND g.name LIKE CONCAT('%', #{keyword}, '%') </if> <if test="categoryId != null"> AND g.category_id = #{categoryId} </if> <if test="minPrice != null"> AND g.price >= #{minPrice} </if> <if test="maxPrice != null"> AND g.price <= #{maxPrice} </if> </where> <choose> <when test="sort == 'price_asc'">ORDER BY g.price ASC</when> <when test="sort == 'price_desc'">ORDER BY g.price DESC</when> <when test="sort == 'sales'">ORDER BY g.sales_count DESC</when> <otherwise>ORDER BY g.create_time DESC</otherwise> </choose> LIMIT #{offset}, #{pageSize} </select>注意几个细节。<where>标签会自动处理首个 AND 关键字,不需要手动拼 1=1;>=和<=是 XML 转义,直接写>=会报错;分页我用手写 LIMIT 而不是 PageHelper,是因为项目体量不大,手写更容易控制,也避免多一条 count 查询的开销。
4.3 登录鉴权与权限控制
登录鉴权我用 JWT 实现,流程是:用户提交用户名密码 -> 后端校验 -> 生成 token 返回前端 -> 前端存 localStorage -> 后续请求在 header 带 token。生成 token 用 jjwt 库,两行代码就行。
String token = Jwts.builder() .setSubject(userId.toString()) .claim("role", user.getRole()) .setExpiration(new Date(System.currentTimeMillis() + 86400000)) .signWith(SignatureAlgorithm.HS256, secretKey) .compact();权限控制我用拦截器加自定义注解。比如卖家发布商品接口标上@RequireRole(1),管理员审核接口标上@RequireRole(2),拦截器里解析 token 后取 role 字段对比,不匹配直接返回 403。这样业务代码不用每次判断“当前用户是不是卖家”,权限逻辑全部收敛到拦截器里,维护起来很清爽。
有一类代码我会单独提示:所有涉及订单、金额的操作,必须做“当前用户与数据归属者一致”的校验。比如买家取消订单,要校验订单里的 user_id 是不是当前登录用户的 id;卖家发货,要校验订单里的 seller_id 是不是当前登录用户的 id。跨权限操作是这类项目最常见的安全漏洞,如果不检验,随便一个买家传一个 orderId 就能操作别人的订单。
5. 前端实现:Vue 应用与接口对接
5.1 前端工程化结构与路由设计
前端我用的 Vue 3 + Vite + Vue Router + Pinia + Element Plus 这套组合,工程结构按功能模块划分,而不是按文件类型堆在一起。
src/ ├── api/ # 接口请求模块 ├── assets/ # 静态资源 ├── components/ # 通用组件 ├── router/ # 路由配置 ├── stores/ # 全局状态管理 ├── views/ # 页面组件 │ ├── home/ # 首页 │ ├── goods/ # 商品详情 │ ├── cart/ # 购物车 │ ├── order/ # 订单 │ ├── seller/ # 卖家中心 │ └── admin/ # 管理后台 └── utils/ # 工具函数路由配置用了懒加载,component: () => import(...),这样首屏不会把所有页面一次性全加载,首页加载速度提升明显。路由守卫里做了权限控制:需要登录的路由加meta: { requiresAuth: true },需要卖家或管理员权限的路由加meta: { role: 1 },在全局前置守卫里判断 token 和用户角色,不满足条件的跳转到登录页。
5.2 axios 封装与跨域联调
axios 封装是前端联调最关键的一环。我在 utils 目录下建了一个 request.js,先创建 axios 实例,设置 baseURL 为/api,然后在请求拦截器里带上 token,响应拦截器里统一处理错误码。
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.data; }, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('token'); router.push('/login'); } ElMessage.error('请求失败'); return Promise.reject(error); } );跨域问题在开发环境通过 Vite 的 proxy 配置解决。vite.config.js 里配置:
server: { proxy: { '/api': { target: 'http://localhost:9090', changeOrigin: true } } }这个配置的原理是:前端请求路径是/api/goods/list,Vite dev server 收到后转发到http://localhost:9090/api/goods/list,浏览器认为请求是同源的,跨域问题直接消失。后端接口路径统一以/api开头,生产环境 Nginx 就继续沿用这个规则做反向代理,前后端环境切换时不需要改前端代码。
5.3 核心业务页面的数据流
购物车页面是 Vue 响应式数据流的典型场景。数据结构是购物车行数组,每行包含商品信息、数量、选中状态,计算属性实时算出总价。用户勾选、修改数量、删除商品,页面通过事件驱动更新数据,不需要手动操作 DOM。
订单提交的流程是这样的:购物车勾选商品后点击结算,进入确认订单页,前端把勾选的购物车 id 列表传给后端,后端在事务里批量创建订单和明细。这样做的好处是订单按卖家拆单,而不是把整个购物车内容合成一个订单。一个校园店铺里可能有多个卖家的商品,合并成一个订单会导致卖家发货逻辑混乱,拆单后每个卖家只处理自己的商品,逻辑清晰很多。
商品列表页的筛选和搜索是另一个难点。我用的方案是 searchParams 对象作为响应式状态,分类、关键词、价格区间、排序方式都在这个对象里维护,每次变化后重新请求接口,页面用骨架屏过渡。为了避免每次输入关键词都触发请求,防抖函数设置 500 毫秒延迟,用户停止输入后才发请求,实测下来请求量减少了好几倍。
6. 前后端联调与部署上线
6.1 本地联调和启动顺序
本地跑起来不是直接把前端后端分别启动就行,有几个前置条件。首先是数据库初始化,我准备了一个 init.sql 脚本,包含建库建表和初始数据,管理员账号、分类数据都在里面,导入即可。然后启动顺序建议是:先启动 MySQL -> 启动后端 -> 启动前端,因为前端首次页面加载需要调用后端接口获取分类数据,如果后端没起来,页面会报错。
后端启动用 IDEA 直接运行 main 方法,或者命令行mvn spring-boot:run。配置文件 application.yml 里要注意数据库连接串的时区问题,MySQL 8 连接时一定要加serverTimezone=Asia/Shanghai,否则会报时区错误。前端启动npm run dev,看到 Local 地址后打开浏览器访问即可。
联调阶段的 debug 技巧:打开浏览器 F12 Network 面板看接口请求和响应,确认请求路径、方法、参数、返回值是否符合预期。关于后端日志,我在 application.yml 里开启了 MyBatis SQL 打印,这样每个 SQL 语句和参数都能在控制台看到,排错效率会高很多。
logging: level: com.example.mapper: debug6.2 生产部署:两种方案对比
生产部署我给两个方案,按项目情况选择。
方案一是完全交给 SpringBoot 托管。前端执行npm run build,生成 dist 目录,然后把 dist 下的文件复制到 SpringBoot 的 src/main/resources/static 目录,重新打包 jar。启动 jar 后,静态资源和接口都在同一个端口,不需要额外配置 Nginx。这个方案最简单,但有个缺点:前端每次改动都要重新打包整个后端,版本管理上不够灵活。
方案二是 Nginx 托管静态文件加反向代理。dist 目录放在 Nginx 的 html 目录,Nginx 配置/指向 dist 的 index.html,/api/路径反向代理到后端应用的 9090 端口。产品上线我推荐这个方案,因为前端资源由 Nginx 高效托管,后端挂掉也不会影响页面加载,后续前端更新只需要替换 dist 目录文件。
方案二的关键配置:
server { listen 80; server_name your-domain.com; location / { root /usr/share/nginx/html/dist; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:9090; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }配置里try_files $uri $uri/ /index.html这一行特别重要。Vue Router 如果用的 history 模式,直接访问/goods/1这类路径刷新时,Nginx 默认会返回 404,因为服务器找不到这个文件。只有配置了 try_files 回退到 index.html,前端路由才能正常工作。这就是很多 Vue 项目部署后刷新页面就 404 的根本原因。
7. 常见问题与踩坑记录
7.1 高频报错排查表
我在整个开发过程中整理了 8 个高频遇到的问题,每个都查了很久才找到原因,整理成了一份排查表。
| 问题现象 | 根本原因 | 解决方案 |
|---|---|---|
| 前端请求接口报跨域 | 前后端端口不同,浏览器拦截 | devServer 配置 proxy 或后端配置 CORS |
| 数据库连接报错 CommunicationsException | MySQL 8 要求显式指定时区 | 连接串加 serverTimezone=Asia/Shanghai |
| Vue history 路由刷新 404 | Nginx 找不到对应文件路径 | 配置 try_files $uri $uri/ /index.html |
| 商品图片上传后访问 404 | SpringBoot 静态资源路径未映射到上传目录 | WebMvcConfigurer 添加资源映射 |
| 列表查询返回所有字段都为 null | Java 实体驼峰命名与数据库下划线未映射 | 开启 map-underscore-to-camel-case |
| 下单后库存不减但订单创建成功 | 事务未生效或方法在同类中调用 | @Transactional 加在 public 方法,类外用代理调用 |
| SpringBoot 3 与旧教程配置不兼容 | jakarta 命名空间替换 javax | 使用 2.7.x 版本或按 3.x API 调整 |
| 前端请求接口一直 404 | 未配置 proxy 或接口前缀不匹配 | 确认 /api 前缀与后端 context-path 一致 |
7.2 我的一些实操心得
最后说几个我个人项目中感触最深的经验。
第一,接口文档一定要提前定。我用的是 postman 的接口集合,或者直接在 Notion 里维护一个接口清单表,字段名、类型、状态码都列出来。前后端分离开发最大的坑不是技术,而是沟通,接口字段临时变更是家常便饭,只有文档提前定好,效率才能上去。
第二,关于图片上传。我用的最简单方案:后端接收 MultipartFile,存到服务器本地的一个 upload 目录,然后返回访问路径。但注意,SpringBoot 默认只暴露 static 目录下的文件,upload 目录要手动加一个资源映射,否则前端拿到路径也访问不到图片。具体配置是:
@Configuration public class WebMvcConfig implements WebMvcConfigurer { @Value("${upload.path}") private String uploadPath; @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/upload/**") .addResourceLocations("file:" + uploadPath + "/"); } }第三,购物车数据到底放前端还是后端?我的答案是购物车基础数据放后端,只会在用户进入购物车页面时通过接口拉取。为什么不用 localStorage?因为购物车里的商品库存和价格是以数据库实时为准的,商品可能被人买光,可能被卖家下架,前端本地存的商品信息可能已经过时,直接从后端重新加载才能保证数据准确。
第四,遇到 Bug 先别急着改代码。把复现步骤记录下来,把日志打印出来,先判断是前端问题还是后端问题,再缩小范围。比如商品列表不显示,先看 Network 面板接口是否报错;接口正常,再看 SQL 是否查询到数据;SQL 正常,再看字段映射是否正确。层层递进,大多数问题都能快速定位。
最后,这个项目我整体下来最大的收获不是把功能做出来了,而是建立了一个完整的业务闭环思维。一个用户从进入页面到下单完成,中间涉及多少状态流转、多少数据校验,在之前单纯写增删改查时根本体会不到。等到你真正独立把这样的项目跑通一次,再去看真实的电商系统架构,很多设计理念就会觉得非常熟悉,这就是这个项目的最大价值。