直接开始正文。
1. 这个项目的定位:先想清楚校园网上店铺到底要解决什么
最近把一套SpringBoot+Vue的校园网上店铺管理系统完整地撸了一遍,从前端页面到后端服务,从数据库建表到线上部署,所有代码都是用Java+MySQL+MyBatis这套经典组合写的。做之前我以为这不过是个普通电商系统的简化版,真正动手之后才发现,校园场景下的网上店铺和外面那些电商平台,痛点完全不一样。如果你也准备做类似题目,或者正在为毕业设计、课程项目找一套完整可复现的源码思路,这篇文章基本能把整条链路讲透。
先说这套系统要解决什么问题。校园里的买卖需求其实一直存在:学生之间倒腾二手教材、闲置数码产品,社团售卖文创周边,校内小卖部和学生创业团队想把线下生意搬到线上。这些需求的特点是:用户范围明确(校内师生)、商品体量小(几百件量级)、交易信任度高(基本都是校园内见面交易)、对功能的诉求集中(浏览商品、下单、结算、订单管理),不太需要复杂的推荐算法和营销系统。所以系统本质上是一个轻量的B2C店铺系统,面向两类角色——买家和卖家(管理员可以归入卖家一侧做统一管理)。
明确了定位之后,功能边界就清楚了,这也是做项目最忌讳一上来就写代码的环节。我最后敲定的功能模块包括:
- 用户模块:注册、登录、个人信息维护,买家与商家的角色区分;
- 商品模块:商品的增删改查、上下架、分类浏览、关键词搜索;
- 购物车模块:加入购物车、修改数量、勾选结算;
- 订单模块:下单、付款模拟(因为校园场景一般不接真实支付)、订单状态流转、取消订单;
- 管理后台:商品管理、订单处理、用户管理、数据看板。
可千万别小看这个功能清单,它几乎覆盖了一个完整Web系统的所有通用能力:表关联查询、登录鉴权、文件上传、事务操作、前后端分离协作。做完这一套,你对Java后端和Vue前端的理解深度,和只对着教程敲demo是完全不同的。
2. 技术选型没有秘密:为什么是SpringBoot+Vue+MySQL+MyBatis这支组合
很多同学面对技术选型容易犯两个极端:要么老师定什么就用什么,根本不问为什么;要么盲目追新,今天微服务明天分布式,把一个校园小店项目整得比大厂中台还重。我选择SpringBoot+Vue+MySQL+MyBatis,不是因为它新,而是因为它在“快速交付、容易维护、我能彻底讲清楚原理”这三个维度上是最优解。
后端选SpringBoot的理由其实已经被说烂了,但真正用起来才能体会到它的价值。SpringBoot的自动配置让项目启动从繁琐的XML配置变成了一两个注解的事;内置Tomcat让部署变得非常简单;生态成熟,Spring Security、Spring Data、Validation这些组件随便拿一个出来都有大量实践案例。对于单机部署的校园店铺系统,SpringBoot的默认配置基本就能满足需求,不需要做任何过度设计。
MyBatis和JPA之争也是老话题。我这次坚持用MyBatis,原因是校园店铺系统的SQL查询较为复杂,尤其是商品多条件筛选、订单状态统计、用户购物车多表关联这些场景,MyBatis能让我直接掌控SQL语句,写起来放心,查起来也方便。MyBatis的XML映射虽然比注解啰嗦,但它的好处是复杂SQL一眼就能看到,出了问题也容易排查。如果想要一份完整源码做参考,XML版本的MyBatis配置会比全注解版本更有教学价值。
前端选Vue就更没什么悬念了。Vue的渐进式设计对小型团队非常友好,想要简单就用原生响应式加模板语法,想要规范就配合Vue Router和Pinia做工程化管理。Vue 3的组合式API在逻辑复用上比选项式API舒服得多,我这套系统就是基于Vue 3 + Vite + Element Plus搭建的。Element Plus的表格、表单、弹窗组件非常贴合后台管理页面的需求,省去了大量写CSS的精力。
这里顺便说一句,技术选型的核心原则是:你能讲清楚为什么选它,比选了什么本身更重要。面试官或者评审老师最反感的就是“大家都用所以我用”。你只要能说出SpringBoot解决了配置繁琐、Vue解决了DOM操作的开发效率、MySQL满足事务需求、MyBatis利于SQL优化,这套选型就已经有说服力了。
3. 后端从零到一:数据库设计与核心接口实现
后端是这套系统的心脏。我的建议是绝对不要先写接口,先把数据库表结构设计清楚。表结构设计不好,后面所有代码都会绕远路。
3.1 数据库表设计:五张核心表怎么串起来
我最终设计了五张核心表:用户表、商品分类表、商品表、购物车表、订单表(加上订单明细表一起共六张)。
用户表字段不多,但有两个点值得注意。一个是role字段,我用tinyint存,0表示普通买家,1表示商家/管理员,而不是直接用字符串。这样做的原因是查询效率更高,也方便以后扩展更多角色;另一个是密码字段,我建议大家不要存明文,哪怕是个课程设计也应该用BCrypt加密。别觉得麻烦,这是基本的安全素养。
商品表比较关键,字段包括:id、category_id、seller_id、name、description、price、stock、cover_image、status、create_time。其中status用于上下架控制,0为下架,1为上架。设计的时候一定要给category_id和seller_id建索引,因为商品列表页最常见的查询场景就是按分类查和按卖家查。
订单相关的表设计我认为是整个项目的难点。如果只用一张订单表,那么一个订单包含多种商品的情况就没办法表达。所以必须拆成订单主表和订单明细表。订单主表记录订单编号、下单用户、订单总金额、订单状态、创建时间;订单明细表记录每个商品的下单快照,包括商品名称、单价、数量、小计。记住这里一定要做快照,因为商品价格是会变的,订单生成之后哪怕商品改价,历史订单也不能受影响。
把表关系梳理一下:
| 表 | 核心字段 | 关联关系 |
|---|---|---|
| user | id, username, password, role, nickname | 下单、卖货 |
| category | id, name, sort | 一对多商品 |
| product | id, seller_id, category_id, name, price, stock, status | 多对一分类/用户 |
| cart | id, user_id, product_id, quantity | 用户购物车项 |
| order | id, order_no, user_id, total_amount, status, create_time | 一对多明细 |
| order_item | id, order_id, product_id, product_name, price, quantity | 多对一订单 |
3.2 MyBatis映射的坑与配置
MyBatis的ORM能力很基础,它不是一个全自动ORM框架,SQL还是要自己写。好处是灵活,坏处是字段映射容易出错。比如数据库字段create_time和Java实体属性createTime之间,一定要开启驼峰映射,否则查询结果里会有大量字段是null。
在application.yml里加一行配置就能解决这个问题:
mybatis: configuration: map-underscore-to-camel-case: true mapper-locations: classpath:mapper/*.xml type-aliases-package: com.campus.shop.entity另一个常见问题是LocalDateTime的映射。很多老教程还在用java.util.Date,但Java 8时代的LocalDateTime更加安全易读。使用时要确保MyBatis版本在3.4.5以上,并且在实体类上配合@JsonFormat注解处理前后端的时间格式:
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") private LocalDateTime createTime;3.3 核心业务接口:从商品列表到下单的事务边界
后端接口我按照RESTful风格组织,模块清晰。商品列表接口是最常用的,也最容易被人忽略性能问题。我刚开始做的时候直接在Mapper里写了三层循环查分类、查商品、查库存,数据量小的时候没问题,一旦商品超过几百条就明显卡顿。后来改成一条SQL搞定列表查询,配合PageHelper分页,才真正顺畅。
多说一句,学习项目最容易忽略的就是分页。很多人做商城,商品一多页面直接卡死,就是因为没有分页。SpringBoot整合PageHelper非常简单,引入依赖之后在Service层调用PageHelper.startPage(pageNum, pageSize)再执行查询即可。
下单接口是这个系统里最能体现工程能力的部分,因为它的逻辑链路长:检查商品是否存在且上架、检查库存是否充足、计算总价、扣库存、生成订单主记录、生成订单明细。这些操作必须放在同一个事务里,否则就会出现扣了库存没生成订单、或者订单生成但库存没扣的情况。
事务边界的正确写法是:不要在Controller层加@Transactional,应该在Service层的方法上加。因为Controller负责接收参数和返回结果,Service层才负责业务逻辑。比如:
@Transactional(rollbackFor = Exception.class) public Long createOrder(OrderCreateDTO dto) { // 1. 校验商品 // 2. 计算金额 // 3. 扣减库存(通过 UPDATE product SET stock = stock - #{quantity} WHERE id = #{id} AND stock >= #{quantity}) // 4. 插入订单主表 // 5. 插入订单明细表 }这里有个我亲手踩过的坑:扣库存如果用先查询再更新的方式,并发情况下会超卖。正确的做法是把库存判断放进UPDATE语句的WHERE条件里,这样数据库层面就保证了原子性。虽然校园店铺并发量不高,但这是一个非常好的经验点,面试也常问。
Controller层的代码要尽量薄,只做参数校验和统一返回。我习惯用一个Result<T>封装类统一返回格式,前端拿到之后只需要判断code字段是否为200即可,不用每个接口都去解析不同的结构。
4. 前端Vue落地:页面结构、API封装与权限细节
后端接口只是能力,前端才是用户真正看得见摸得着的东西。我用Vue 3 + Vite + Element Plus + Pinia搭建了前端工程。这里重点说几个实操细节,都是连跑Demo和直接改代码时容易卡住的地方。
4.1 前端项目结构与API封装
项目目录我划分得很清楚,src/api放接口请求,src/views放页面组件,src/router放路由配置,src/stores放状态管理,src/utils放工具函数。很多同学喜欢把所有请求直接写在页面组件里,图一时方便,等页面一多就后悔了。API单独封装的好处是:接口地址一旦变化,只需要改一个文件;而且便于统一处理token注入和错误拦截。
我封装了一个request.js,本质上是基于Axios的实例,核心逻辑包括:
- 请求拦截器里从localStorage取token,放进请求头
Authorization; - 响应拦截器里遇到401状态码就清除登录态并跳转登录页;
- 遇到业务错误码(比如库存不足)就用Message组件统一弹提示。
import axios from 'axios' import { ElMessage } from 'element-plus' 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( response => { const res = response.data if (res.code !== 200) { ElMessage.error(res.message || '请求失败') return Promise.reject(new Error(res.message)) } return res.data }, error => { ElMessage.error(error.response?.data?.message || '网络异常') return Promise.reject(error) } )这样做完之后,每个API模块就只需要写具体的URL和参数类型,非常清爽:
// api/product.js export const getProductList = (params) => request.get('/product/list', { params }) export const getProductDetail = (id) => request.get(`/product/${id}`)4.2 路由守卫和角色权限:不要让前台页面掺和权限判断
校园店铺有买家、商家两种角色,所以路由需要区分。我在Vue Router里给需要登录的页面加了meta.requiresAuth标记,然后写全局前置守卫。这里有个容易被忽视的点:前端的权限控制只是用户体验层面的,真正的权限保护一定要在后端接口做。顾客的接口和商家的接口要分开鉴权,前端隐藏按钮只是好看,后端拦住才是根本。
后端我采用了简单的拦截器方案。定义一个AuthInterceptor,在preHandle里解析token并判断当前用户角色,然后把用户ID塞进request的attribute里,Controller层直接用@RequestAttribute拿当前登录用户。别小看这个设计,它省去了每个接口自己解析token的重复劳动。
权限相关的一个好习惯是:商家删除商品、修改商品这类敏感操作,后端一定要校验当前登录的sellerId和商品归属是否一致,防止水平越权。这个点在我的系统里是加了校验的,也正是很多课程设计会漏掉、但评审老师特别爱追问的地方。
4.3 购物车状态管理:用Pinia解决跨页面数据同步
购物车功能如果只是简单地把数据存在页面组件里,那用户在商品详情页加购物车,跳转购物车页面时就拿不到数据了。虽然可以通过路由传递参数解决一部分,但最佳实践还是用全局状态管理。我用Pinia写了一个购物车store:
export const useCartStore = defineStore('cart', { state: () => ({ items: [], isLoaded: false }), getters: { totalCount: state => state.items.reduce((sum, item) => sum + item.quantity, 0), totalPrice: state => state.items.reduce((sum, item) => sum + item.price * item.quantity, 0) }, actions: { async fetchCart() { const data = await getCartList() this.items = data this.isLoaded = true }, async addItem(productId, quantity) { await addCartItem(productId, quantity) await this.fetchCart() } } })这样设计的好处是:主页、详情页、购物车页只要调用useCartStore()就能共享同一份购物车数据;用户修改数量或删除商品后,所有相关页面会同步刷新。购物车接口最好是在用户登录之后才允许操作,后端通过token识别用户身份,前端在store初始化时判断登录状态再拉取购物车。
商品列表页的建议是采用卡片式布局,Element Plus的el-card配合图片、价格、标题,加上分页组件就能达到很好的视觉效果。图片没有专门的文件服务器,前台页面展示相对路径的图片,开发环境用Vite代理转发到后端的静态资源目录。这样最简单,对于课程设计级别的项目完全够用。
5. 部署联调阶段踩到的坑:四个老问题一次说清
项目跑通只是第一步,真正折磨人的是联调和部署。这一节我把实操中反复出现的四个问题全部列出来,每一个都是我亲自排查过、反复确认过的,希望能帮你省下几天时间。
5.1 跨域问题:百分之八十的前后端分离项目都卡在这
前后端分离最经典的问题就是CORS。开发环境我直接用Vite的代理配置解决,在vite.config.js里这样写:
server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }这样做的好处是开发环境前端请求/api/product/list会转发到后端的http://localhost:8080/api/product/list,完全不会碰跨域问题,而且后端不用做任何CORS配置。
但如果你要把前后端分开部署,比如前端用Nginx,后端用SpringBoot独立运行,那就必须处理后端跨域。我在后端写了一个CorsConfig,配置允许的来源、请求头和方法。生产环境的域名变了,也能通过配置灵活调整,不用改代码。
5.2 数据库时区问题:为什么时间显示差了8小时
前后端我明明用的是LocalDateTime,但页面上显示的时间和数据库存的时间差了8个小时。排查后发现两个原因叠加:一是MySQL连接串里没有配置serverTimezone=Asia/Shanghai,二是系统默认时区和数据库会话时区不一致。解决办法是在JDBC连接串里显式加上:
url: jdbc:mysql://localhost:3306/campus_shop?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false顺便说,时间的统一规范是:永远别在前端自己做时间加减,统一交给后端格式化。前端只需要展示后端返回的字符串,后端在实体上用@JsonFormat指定格式即可。这样无论部署在哪个服务器,展示出来都是好消息。
5.3 事务失效的隐蔽场景:当@Transactional遇到同类调用
下单接口加事务之后,我一度以为万事大吉。后来测试一个稍微极端的场景:故意让订单明细插入失败,发现库存竟然还是被扣了。定位代码后发现,原来是Service层的下单方法被同类中的另一个内部方法直接调用了,而Spring的事务机制是基于AOP代理的,同类内部直接调用this.createOrder()不会走代理,事务拦截器根本没生效。
解决办法有两个:一是把下单逻辑拆到独立的Service类,Controller层调用独立Service的方法,让事务注解生效;二是在同一个类里通过注入代理对象的方式调用。最推荐的是第一种方式,代码也更清晰。这个问题非常经典,一般教程里几乎不会提,但面试里出现的概率极高。
5.4 库存并发:用UPDATE原子操作代替先查后改
刚才在核心接口部分提到过,这里展开说。如果代码逻辑是:查库存(SELECT stock)→ 判断充足 → 扣减(UPDATE stock = stock - 1),那么在两个请求同时并发的时候,两个请求都查到了库存为1,都通过判断,然后都执行UPDATE,最终库存变成-1。这是典型的超卖。
MyBatis的XML里可以写成这样:
<update id="deductStock"> UPDATE product SET stock = stock - #{quantity} WHERE id = #{productId} AND stock >= #{quantity} </update>这条SQL把“判断库存充足”和“扣减库存”合并成了一个原子操作。如果影响行数为0,说明库存不足,业务层直接抛异常回滚。这就是数据库层面解决并发的神仙操作,不用引入Redis分布式锁,对校园店铺的并发量足够用了。
6. 个人复盘:这套系统做到什么程度才算真正合格
项目收尾的时候,我给自己列了一份验收清单,对照检查哪些是必须做好的,哪些是可以后续再扩展的。我建议正在做类似项目的读者也准备这么一份清单,别让项目停留在“能跑”的层面。
必须做好的部分,我按优先级排列:第一是基础CRUD完整且接口风格统一,这个是骨架;第二是登录鉴权闭环,注册、登录、退出、token刷新都要走通;第三是核心业务链路(浏览商品、加购、下单、扣库存、生成订单)不能有逻辑漏洞;第四是数据安全底线,密码加密存储、越权检查必须做;第五是前端交互反馈,所有操作都要有加载状态和明确提示,不能让用户点了按钮以为死了。
这个项目跑完之后,我对SpringBoot的自动配置原理、MyBatis的映射机制、Vue的组件通信和生命周期、以及前后端联调时“接口约定优先”的价值都有了新的理解。以前看教程总觉得那些注解和配置就是背下来的,自己从头到尾搭一遍才发现:每一条配置都有它要解决的问题,每一个报错背后都对应着一个原理盲区。
如果你现在正准备复现这套校园网上店铺系统,我的建议是:先用两天时间把表结构和接口约定文档写好,哪怕不写文档也必须在脑子里过一遍;然后按照后端优先的顺序,先把所有接口调试通过,再去做前端页面。千万不要同步开工,前后端联调时你一半API改了又改,另一半天页面跟着推倒重来,那种痛苦我替你先踩过了。