做购物系统这个需求,在技术社区里几乎是常青树。我刚用一个多月时间,把一个基于SpringBoot+Vue的网购平台从零搭到能跑通下单流程,数据库用了MySQL,持久层用MyBatis,整体前后端分离。本文打算把设计思路、核心模块拆解、还有那些只有真正写完整个项目才会发现的坑,一次性讲清楚。如果你正准备拿类似选题做课程设计、放在简历里当项目经历,或者单纯想搞懂一个电商后台的全貌,这篇内容应该能省下你不少试错时间。
1. 从需求到技术选型:为什么不选微服务,而用单体会话式架构
很多人一说购物平台,第一反应就是微服务、分布式事务、消息队列。但在实际做课设或中小型企业内部系统时,这些往往是过度设计。我更倾向于先想清楚业务复杂度,再决定技术形态。
1.1 需求边界:管理后台和商城前台是两个世界
网购平台从用户视角看,有浏览商品、加购物车、下单、支付、查看订单这些动作;从管理员视角看,有商品分类维护、商品上下架、库存管理、订单状态管理、用户管理、数据概览。真正落地的时候,这其实是两套交互逻辑完全不同的子系统。前台面向普通用户,界面要轻快,操作要直观;后台面向运营人员,核心是表格、表单、状态流转,要求信息密度高、操作效率高。
我设计时把项目拆成两个前端入口:用户端和后台管理端,分别对应不同的Vue实例和路由配置。也有人用一套前端加一个dashboard路由来合并,但从后期维护和权限控制的角度,分开更清晰。管理端和后端接口要做权限校验,普通用户token访问管理端接口直接返回403,这个在后端过滤器里统一处理,前端再用路由守卫拦一道,属于双保险。
1.2 前后端分离的取舍:Vue3还是Vue2,这得看团队基础
标题里写的是SpringBoot+Vue,具体版本我选的是SpringBoot 2.x + Vue 2。有人会问为什么不用Vue3,我的理由很简单:周边生态。Vue2的Element-UI组件库、各类后台模板、网上的踩坑记录,量级和成熟度远高于Vue3对应版本。对于课设和大部分企业管理系统,Vue2 + Element-UI是当前最稳妥、最容易找到参考资料的技术组合。SpringBoot 2.7.x作为后端基础框架,内置Tomcat、自动配置、Starter机制,能极大减少配置工作量。
不选微服务的原因更加直白:这个项目涉及的核心模块就那么几个——商品、分类、购物车、订单、用户、库存。单体应用把所有模块放在同一个工程里,编译快、启动快、调试方便,一个SpringBoot应用就能承载全部接口。分布式那一套引入后,光是服务注册、配置中心、链路追踪,就会让整个项目的复杂度翻倍,但对于这个体量的业务,收益几乎为零。选型这事,必须向业务复杂度看齐。
1.3 MyBatis和MySQL的组合逻辑
持久层选MyBatis,核心原因是SQL可控性强。电商系统里的查询经常涉及多表关联和复杂条件拼接,比如商品列表的筛选,可能同时包含分类、价格区间、关键词、上下架状态。MyBatis允许直接写SQL,动态SQL的能力也很成熟,配合<where>、<if>标签能很优雅地拼条件。相比之下,JPA虽然开发快,但碰到复杂查询时,要么写JPQL,要么用Specification,抽象层级多,排查问题时不直观。
MySQL这边,隔离级别我用的是默认的REPEATABLE READ。对于订单金额这类关键数据,靠事务和行锁保证一致性。表引擎统一用InnoDB,支持事务和行级锁,这是电商场景的底线。
2. 数据库设计:13张表如何撑起订单主链路
数据模型是整个系统的地基。我设计时坚持一个原则:订单和商品的数据要能追溯到每一次变动,不能只存当前状态。这个思想贯穿了订单表、订单商品表和商品表的设计。
2.1 核心表结构与外键策略
订单相关三张表:orders(订单主表)、order_items(订单商品明细)、cart_items(购物车)。商品相关两张表:products(商品信息)、categories(分类)。用户权限相关三张表:users(用户)、roles(角色)、user_roles(用户角色关联)。再加上addresses(收货地址)、product_images(商品图片)、comments(商品评论)、inventory(库存表),合计13张表。
外键处理上我刻意做了放弃:不在数据库层面建立物理外键,全部通过应用层保证逻辑关联。这样做的原因有两个,一是高并发场景下物理外键会带来额外的锁开销,二是分库分表、数据迁移时物理外键会是灾难。但逻辑外键必须配索引,比如order_items.order_id、cart_items.user_id这类高频关联字段,都加上普通索引,查询性能能稳住。
orders表的设计里,有一个字段特别容易忽略:order_no,订单编号。很多人直接用自增ID当订单号,但对外展示的订单号一旦暴露了业务量,是很敏感的信息。我用时间戳(精确到毫秒)+ 用户ID后四位 + 随机三位数拼接成订单号,既能保证唯一性,又不会暴露真实交易量。这个订单号要在unique索引上,防止重复下单时生成流水号冲突。
2.2 商品表与库存表为什么要分离
商品表和库存表分开,是电商系统的普遍做法。商品表存的是静态属性:名称、简介、价格、上架状态、分类ID。库存表存动态数字:总库存、锁定库存、已售数量。为什么分开?下单的时候要UPDATE inventory SET locked_stock = locked_stock + 1 WHERE product_id = ? AND available_stock >= ?,这样的操作很频繁,如果库存字段和商品描述字段在同一张表,每次行锁的粒度会覆盖整条商品记录,影响其他字段的更新操作。拆开之后,库存表独立操作,锁的范围更小。
商品价格在order_items表里也要冗余一份快照。注意,这个快照价格是下单那一刻的商品单价,后续商品改价不影响历史订单的结算金额。这个设计在真实电商里是底线要求,财务对账全靠它。
2.3 表结构设计上容易翻车的三个细节
第一个是金额字段类型。商品价格、订单总金额必须用DECIMAL(10, 2),不能用FLOAT或DOUBLE。浮点数在MySQL里是近似存储,金额计算出现0.30000000000000004之类的结果,财务上没法交代。
第二个是时间字段。创建时间、更新时间统一用DATETIME,Java实体对应LocalDateTime,通过MyBatis的jdbcType映射。TIMESTAMP类型有2038年问题,虽然考试不会考,但设计时留个心眼没坏处。
第三个是状态字段注释。orders.status字段用TINYINT,0待付款、1待发货、2待收货、3已完成、4已取消。每次建表都必须写COMMENT,不然三个月之后连你自己都记不得1是待发货还是待收货。
3. 后端模块拆解:认证、商品、购物车、订单的代码实现思路
后端工程我按业务模块分包:controller、service、mapper、entity、dto、vo、config、util。每个业务模块再在controller层建一个类,比如ProductController、OrderController、CartController。这样的分包方式直观,新人拿到代码也能快速定位。
3.1 基于JWT的认证流程,如何做到无状态授权
登录接口的逻辑是:接收用户名密码,校验通过后生成JWT字符串返回给前端。前端把token存在localStorage,每次请求在Authorization头里带上。后端通过一个JwtAuthenticationFilter过滤器解析token,把用户信息放进SecurityContext,接口里通过@AuthenticationPrincipal或工具类直接取当前用户ID。
JWT用io.jsonwebtoken实现,密钥配置在外置application.yml里,过期时间设为2小时。实际使用中发现,token过期后用户如果正在下单操作,体验很不友好。折中方案是前端在axios响应拦截器里捕获401状态码,再调用一个刷新接口拿到新token,不对用户弹登录框。刷新接口本身也发旧token,后端只要旧token还没过期超过7天,就签发新token。这套逻辑写起来不复杂,但能让体验质感提升一档。
权限控制上,Spring Security + JWT的组合,SecurityConfig里定义哪些路径不需要认证:比如登录接口、商品列表接口、商品详情接口。管理端接口统一前缀/admin/**,要求拥有ROLE_ADMIN角色才能访问。这种粗粒度的角色控制,对课设和管理系统够用,细粒度的按钮权限可以通过Vue的自定义指令去控制。
3.2 商品列表的分页与条件查询,MyBatis动态SQL实战
商品列表是访问频率最高的接口,既要分页,又要支持多条件筛选。前端传pageNum、pageSize、categoryId、keyword、priceMin、priceMax、isSale等参数。我用了PageHelper插件,它会自动拦截MyBatis的SQL,生成LIMIT语句。需要注意的是,PageHelper的startPage方法必须在Mapper查询之前调用,而且一个查询方法只能配一次,否则分页参数会串到别的查询上。
动态SQL核心片段大致长这样:
<select id="selectProductList" resultType="com.example.vo.ProductVO"> SELECT p.*, c.name AS categoryName FROM products p LEFT JOIN categories c ON p.category_id = c.id <where> <if test="keyword != null and keyword != ''"> AND p.name LIKE CONCAT('%', #{keyword}, '%') </if> <if test="categoryId != null"> AND p.category_id = #{categoryId} </if> <if test="priceMin != null"> AND p.price >= #{priceMin} </if> <if test="priceMax != null"> AND p.price <= #{priceMax} </if> <if test="isSale != null"> AND p.is_sale = #{isSale} </if> </where> ORDER BY p.id DESC </select>LEFT JOIN categories的目的是在列表页直接显示分类名称,避免前端拿到categoryId再二次查询。这里用LEFT JOIN是因为商品可能没有分类,INNER JOIN会漏数据。>和<是XML转义,虽然<在MyBatis里也允许,但我在SQL里习惯一律转义,避免后续同事复制SQL到XML里报错。
3.3 购物车加减库存与订单生成的事务边界
购物车接口相对简单:加购、修改数量、删除、查询列表。加购时要去查库存,如果当前库存小于加购数量,直接提示库存不足。但这里有个并发隐患:库存的判断和扣减如果分两步走,可能出现超卖。所以扣库存的SQL必须写成一个带条件的更新语句。
UPDATE inventory SET locked_stock = locked_stock + #{num}, available_stock = available_stock - #{num} WHERE product_id = #{productId} AND available_stock >= #{num}这个SQL利用WHERE available_stock >= #{num}做条件更新,如果库存不足,影响行数为0,应用层判断影响行数等于0就回滚事务并给用户提示。这是我处理超卖问题的核心思路,不用悲观锁,也不用Redis分布式锁,靠一条SQL的原子性解决,简单且有效。
订单生成是个多步骤操作:从购物车查询选中的商品明细、生成订单主记录、批量插入订单商品明细、扣减库存、清空对应购物车记录。整个过程是一个事务,任何一步失败都要全部回滚。我用的@Transactional(rollbackFor = Exception.class),注意一定要写rollbackFor,因为Spring默认只回滚运行时异常,如果某一步抛出检查异常,事务不会回滚,数据就会错乱。
这里还有个细节:订单总金额的计算必须在后端根据数据库价格重新累加,不能信任前端传来的价格。前端的数量可以用,但单价必须查库。同时下单接口要幂等,最简单的方式是前端在提交订单时生成一个requestId,后端用select ... for update检查订单号是否已存在,存在就直接返回已有订单,避免用户重复点击产生重复订单。
4. 前端页面组织与状态管理:Vue + Element-UI的实战编排
前端我把项目分成src/api、src/router、src/store、src/views、src/utils几个目录。src/api按后端controller对应拆分,每个文件里封装axios请求,统一导出;src/store用Vuex管理用户的token、购物车数量、用户信息这些全局状态。
4.1 前端路由守卫和后端接口的双重权限控制
router.beforeEach里做登录校验:如果访问的路径需要认证(路由的meta.requiresAuth为true),且本地没有token,就跳转到登录页,并带上redirect参数,登录成功后回到原页面。管理端路由统一加meta.requiresAdmin,前端判断角色不是管理员,直接跳403页面。这种前端拦截只能提升用户体验,真正防止越权必须靠后端,前端的路由守卫只是减少无效请求。
axios封装上,我做了两件事。第一是请求拦截器,从localStorage读token并加到header。第二是响应拦截器,根据后端返回的统一响应体{ code, message, data }判断:code为200就正常返回数据,401跳转登录,500统一弹出错误提示。这里拦截器的顺序很关键,请求拦截器在request.use里,响应拦截器在response.use里,写错位置会导致重复触发。
4.2 商品展示页的图片懒加载与价格筛选
商品列表页用的Element-UI的el-card栅格布局,图片用el-image的lazy属性实现懒加载。价格筛选用el-slider双向绑定,拖动结束触发查询接口。这里要防一个坑:滑动过程中会频繁触发change事件,所以要在@change里限流,我用了一个简单的定时器方案,用户停止滑动500毫秒后才发起请求,避免短时间内连续打接口。
商品详情页展示大图、价格、库存、加入购物车按钮。加购成功后,Vuex里的cartCount加1,顶栏购物车图标上的小红点(el-badge)实时更新。这个数据流的逻辑很简单,关键是cartCount在刷新页面时要从后端重新拉一次,不能在初始化时直接取本地状态,否则刷新后显示的数字就是0。
4.3 购物车和结算页的状态同步
购物车页面每一行有数量步进器(el-input-number),修改数量会立即调用后端接口更新购物车。这里我做了乐观更新:先改本地数据,再请求后端,如果请求失败,把本地数据改回去并提示错误。这样做的好处是界面响应快,不会有等待感;代价是代码里要多一个恢复操作,但购物车这种场景值得。
结算页从购物车勾选的状态生成订单,提交按钮绑定了handleSubmitOrder方法,提交前做二次确认。曾经有个学生项目的教训:下单按钮没做防重复点击,用户连点三次,生成了三笔相同订单。后来我在方法入口加了一个isSubmitting标志,提交期间把按钮设为loading并禁用,事件触发后不进入重复逻辑,这个改动成本极低但非常关键。
5. 冰点排障:事务失效、跨域配置和库存并发问题
开发过程中遇到的最头疼的问题往往不是功能本身,而是那些看了报错信息也找不到原因的环境层面问题。这里记录三个最值得展开的坑。
5.1 @Transactional 没生效:自调用、异常被吞、非公开方法
事务失效是Spring开发里高频踩坑点,我在这个项目里排查过一次,印象深刻。场景是在OrderServiceImpl里,createOrder方法调用了同类里的deductStock方法,deductStock上有@Transactional注解,但发现库存被扣了多次,说明事务没有生效。
原因很典型:Spring的事务代理基于AOP,this.deductStock()是对象内部自调用,绕过了代理对象,注解自然不生效。解决办法是把deductStock的逻辑和createOrder合并到一个事务方法里,或者通过OrderServiceImpl注入自身代理来调用,或者把扣库存操作拆到另一个InventoryService里,通过Spring容器调用。
另一个坑是try-catch吞异常。某段代码里扣库存的方法内部做了try { ... } catch (Exception e) { log.error(...); },出异常直接打日志而没有重新抛出,事务感知不到异常,就不会回滚。正确姿势是catch里手动throw new RuntimeException(e),或者干脆不捕获让@Transactional兜底。注意rollbackFor要设置,否则检查异常不回滚。
5.2 跨域配置的本地端口错位
前后端分离意味着前端跑在8080,后端跑在8081,浏览器默认会拦截跨域请求。在SpringBoot里写一个WebMvcConfigurer的addCorsMappings,允许来源http://localhost:8080和http://127.0.0.1:8080,注意两个地址都要配,很多人只配了localhost,结果用IP访问时照样报跨域错误。
跨域的报错信息在浏览器控制台经常是泛泛的“CORS error”,具体原因要看Response Header里有没有Access-Control-Allow-Origin。如果后端配置了还是报错,检查是否走了Spring Security,因为Security的过滤器链会先于CORS配置生效,需要在SecurityConfig里也开启CORS支持,否则请求在认证之前就被拦截了。
5.3 库存并发扣减的压测验证
开发时接口单测通过,但用JMeter开50个线程同时下单,发现库存超卖。原因就是前面说的分步扣减,没有把判断和更新合并成一条SQL。我用条件更新SQL后,再跑压测,数据稳定在“扣减数量 + 剩余库存 = 初始库存”。这里有一个验证技巧:压测结束后,用SELECT SUM(locked_stock), SUM(available_stock) FROM inventory做校验,任何不一致都能立刻暴露。
万一线程真的发生死锁,查看SHOW ENGINE INNODB STATUS,里面会显示锁等待和持有信息。大部分死锁是因为更新库存和更新订单时加锁顺序不一致导致的。解决办法是让所有事务都按同一个顺序访问资源:先更新库存,再插入订单,最后清购物车,统一顺序后死锁概率基本归零。
6. 源码结构、配置文件与运行部署的完整指南
标题里强调“完整源码”,那源码的可读性和可运行性就是整个项目的生命线。我整理工程时花了大量时间做三件事:目录结构导航、配置文件剥离、部署文档补全,这一部分也应是你拿这个项目去展示时最有说服力的地方。
6.1 后端源码目录和关键依赖清单
后端是Maven工程,核心依赖就那些:spring-boot-starter-web、spring-boot-starter-security、mybatis-spring-boot-starter、mysql-connector-java、jjwt(JWT解析)、pagehelper-spring-boot-starter、lombok。lombok能省掉大量getter/setter代码,但要注意IDEA里需要安装插件,否则编译报找不到getXxx()方法。我遇到过很多次这种问题,不是代码错了,是开发环境缺插件。
目录按功能分包:config放Security配置、WebMvc配置、MyBatis配置;controller按模块拆;service层写业务逻辑,service.impl里实现具体代码;mapper层写接口,对应的XML放在resources/mapper/目录。有一个容易出错的地方:application.yml里的mapper-locations路径必须和XML实际位置一致,我用的是classpath:mapper/*.xml,那XML就得放在resources/mapper下,放错位置启动的时候MyBatis会直接报找不到绑定语句。
6.2 前端启动步骤和代理配置
前端安装依赖用npm install,如果网络不好,设置淘宝镜像源会快很多。启动命令是npm run serve。开发环境里前端请求后端接口,我在vue.config.js里配置了devServer代理:所有/api开头的请求都转发到http://localhost:8081。这样前端代码里只写/api/product/list,不写完整域名,部署到服务器时只需要改代理配置。生产构建用npm run build,产物是dist目录,放到Nginx里指向即可。
需要提醒的是,vue.config.js里的代理只对开发服务器生效,打包后的dist文件是纯静态资源,如果直接双击打开,接口请求全都会404。部署时要把dist目录交给Nginx或后端静态资源映射来处理,在Nginx里还要配置location /api的反向代理到SpringBoot服务,前后端才能连通。
6.3 数据库初始化脚本和演示账号设计
项目里我附带了一个init.sql,包含库表创建和基础数据。系统里预置两个账号:一个普通用户角色,一个管理员角色,分别用来演示前台购物流程和后台管理功能。密码我用BCrypt加密存储,不是明文,Spring Security的PasswordEncoder要选BCryptPasswordEncoder,登录时比对加密后的哈希值,确保数据库泄漏也不至于直接暴露密码。
初始化数据里放了10个分类、每个分类下3到5个商品、少量评价和管理员操作日志。这些数据不是随手编的,每个商品的价格、库存、图片网络地址都做了合理设定,让首页一打开就有内容可看,而不是空空如也。演示时如果能看到完整的数据链路展示,比任何文档都有说服力。
7. 项目演示的节奏控制和功能边界:从启动到验收的完整路径
代码能跑起来只是第一步,向别人演示这个项目时,节奏和关注点很重要。如果是一头扎进代码乱点,很容易暴露一些无关紧要的瑕疵。我总结了一个常规展示顺序,你可以直接抄作业。
7.1 演示流程:从前台体验到后台管理
先从前台首页开始,展示轮播图、商品分类、商品列表,然后点击一个商品进详情页,加入购物车,再去购物车页修改数量、选中商品、提交订单。订单提交后,为了展示完整链路,你还需要在后台把这个订单的状态往后续推。
这时候切换管理员账号登录后台,先看数据概览页的统计图表,包括商品总数、订单总数、总销售额、待发货订单数。再去“订单管理”找到刚才那笔订单,执行发货操作,然后回到用户端刷新订单列表,状态从“待发货”变成“待收货”。完成收货后,用户端可以对商品进行评价,后台就能看到评论数变化。这个流程闭环,能覆盖商品流、订单流、状态流和评价流四个核心场景。
7.2 刻意设置的边界:哪些功能我没做
购物系统不可能无限扩展,合理界定边界是工程能力的一部分。这里我明确没做的包括:支付对接(支付宝/微信)、秒杀系统、消息队列异步通知、分布式会话、ElasticSearch搜索。支付对接需要商户号,个人很难申请,所以我做了“模拟支付”按钮,点击后直接更新订单状态为已支付。秒杀这类高并发场景需要单独设计,放在单体项目里意义不大,反而会引入额外复杂度。
把这些边界提前说清楚,演示的时候就不用担心有人问“为什么没接入真实支付”。你要展示的是架构设计和代码实现能力,而不是一个商业化的成品系统。通讯录里如果有人说“加个微信登录”,你可以说这是一个可以预见的扩展点,挂第三方OAuth2即可,项目预留了users.union_id字段,后续接入开放平台时不需要改表结构。
7.3 答辩或汇报时的常见问题应对
这个系统很大概率会出现在答辩场合。评委老师常问的几个问题,提前准备好话术。第一个是“你为什么选择MyBatis而不是MyBatis-Plus,甚至JPA”,回答要点是MyBatis的SQL可控性最适合复杂业务查询,MyBatis-Plus虽然开发快,但不够“贴近SQL”;这里别贬低任何技术,说清楚场景差异就行。
第二个是“你如何解决超卖问题”,回答要点是条件更新SQL加事务,保证原子性,然后说这是单体应用下最轻量的方案,如果未来量级上来,可以引入Redis+Lua脚本或消息队列。第三个是“如果用户支付了但订单状态没更新怎么办”,回答要点是支付回调的幂等性处理和手动对账接口,可以通过一个对账定时任务去扫描超时未支付的订单,这种问题体现的是工程意识而不是八股文背诵。
8. 写在最后:改造这个项目还能往哪个方向走
如果你拿到这套系统,最值得做的一件事不是改样式,而是把“库存扣减”从下单时扣改为“支付时扣”。现在很多平台是下单锁定库存、支付真正扣减,超时未支付自动释放锁定库存。实现方式也很简单:订单表增加expire_time字段,定时任务每5分钟扫描一次,把超时的待支付订单取消,释放锁定库存。这个改动会让你的项目逻辑质感明显提升,也是面试官大概率会追问的点。
另一个建议是给管理端增加一个简易的Excel导入导出功能,通过EasyExcel插件把商品列表导出为表格,或者批量导入初始商品数据。电商系统里这个需求非常真实,运营人员天天要用,代码量大概两三百行,但写在简历里比写“熟悉增删改查”要有说服力得多。
最后说一句实在话:整个项目里真正花时间的不是CRUD,而是数据一致性的设计和异常流动的排查。动手实现一遍,比任何形式的阅读都有效。遇到问题别急,按先日志、后数据、再代码的顺序排查,八成问题都能在一个小时内解决。