
好久没系统梳理一个全栈项目了这次刚好把一个企业级网上超市管理系统从头到尾走了一遍技术栈就是标题里那套SpringBoot Vue MyBatis MySQL。这套组合放到现在依然能打尤其适合做毕业设计、课程设计或者你想找一个能写进简历里的完整业务项目来练手。网上超市听起来简单但真正做起来从用户注册登录、商品浏览、加入购物车、下单扣库存到后台的商品维护和订单处理电商系统该有的核心链路基本都覆盖全了。这篇文章我把整个项目的设计思路、数据库建模、后端实现、前端交互、部署运行都拆开讲讲还会把我实际做的时候踩过的坑一并说清楚希望能给你省点时间。1. 项目定位与技术选型思路1.1 企业级网上超市的系统边界很多人一听网上超市就觉得是仿淘宝、仿京东的小demo其实不是。企业级这三个字决定了系统的复杂度并不是单纯能买能卖那么简单。真正的企业级网上超市至少要覆盖三个端用户端负责商品浏览、搜索、购物车、订单、支付模拟管理端负责商品上架下架、分类管理、库存调整、订单处理、用户管理再加上一套支撑这些业务的后端服务处理鉴权、业务逻辑、数据持久化。这还只是业务功能层面工程层面还要考虑统一的返回格式、异常处理、数据库事务、部署配置等。把这些串起来就是一个可以面向真实场景演示和二次开发的项目。我当时做这个系统的时候第一步没有急着写代码而是先把业务边界画清楚。用户角色分两种普通用户和管理员。普通用户在前端商城页面操作管理员进后台管理界面。两个端的数据是同一套只是接口权限不同。这个角色划分决定了后面鉴权模块的设计方式也直接影响数据库表里要不要单独建角色表。1.2 技术选型为什么是SpringBootVueMyBatisMySQL这套技术栈不是随便拼的每一层都有它存在的理由。先看后端。SpringBoot目前已经是Java后端开发的事实标准它解决了传统SSM项目中大量繁琐的XML配置问题内嵌Tomcat打包就是一个可运行的Jar包部署非常省事。尤其对于学生项目或者小团队项目SpringBoot能让你把精力集中在业务代码上而不是在配置环境里挣扎。MyBatis和Spring Data JPA相比更贴近SQL本身。电商这类业务SQL复杂多变尤其涉及多表联查、动态条件查询、库存扣减这种需要精确控制SQL语义的操作MyBatis的半自动映射反而更灵活。而且MyBatis的XML文件里写SQL后期调优、修改查询逻辑非常直观。前端选Vue则是因为它对中小型项目太友好了。Vue 2的Options API上手快组件化思路清晰配Element UI做后台管理界面半天就能把骨架搭出来。商城用户端用Vue同样合适数据绑定和路由切换都比传统的JSP加jQuery方案体验好太多。数据库这块MySQL是开源关系型数据库里最普及的选择无论是学习成本、资料丰富度还是部署便利性都是最优解。企业级网上超市的数据量级以中小型规模为主MySQL配合InnoDB引擎的事务支持和行级锁完全够用。顺手给一个对比表方便你看明白选型逻辑对比维度这套方案替代方案为什么这么选后端框架SpringBootSSM手动配置省去大量XML配置内置容器开发效率高持久层MyBatisJPA/Hibernate电商SQL复杂MyBatis对SQL控制更直接前端VueJSPJQuery前后端分离组件化开发维护成本低数据库MySQLOracle/SQLServer开源免费、资料多、部署简单满足项目量级1.3 功能模块与角色权限的总体划分系统的功能模块大致可以分成前台和后台两块再加上贯穿始终的用户体系。前台商城端包含用户注册与登录、首页商品展示、商品分类检索、关键词搜索、商品详情、购物车管理、确认订单、模拟支付成功后的订单状态流转。后台管理端包含商品分类管理、商品信息维护、库存管理、订单列表查看与发货操作、用户列表管理、首页轮播图配置。这些模块落到代码上对应到后端就是一个个Controller、Service、Mapper的层次关系。明白了功能全景再看后面的数据库设计就知道哪些表是必须的、字段为什么要这么设计。2. 数据库设计与核心业务模型数据库是电商系统的地基表结构设计不好后续写业务代码会非常痛苦。我一直认为看一个项目源码的功力先看表结构设计——字段命名规不规范、主外键关系清不清楚、有没有冗余字段、金额字段用的什么类型这些都直接反映项目质量。2.1 核心表结构的设计思路这个网上超市项目大概需要九张核心表按业务域可以分成四类。第一类是用户域用户表user、角色表role、用户角色关联表user_role。有人觉得角色表多余毕竟就两种角色加个字段就行。但做成完整项目后你会发现单独建角色表扩展性更好以后要加一个运营人员或者仓库管理员角色不用改表结构只加数据就行。第二类是商品域商品分类表category、商品表product。商品表是核心中的核心。关键字段包括商品名称、主图、轮播图、价格、原价、库存量、销量、上下架状态、描述内容。商品和分类是多对一关系外键指向分类表。这里有一点要注意商品价格和原价我建议都用decimal(10,2)绝不用float或double浮点类型在金额计算时会出现精度丢失这在电商系统里是不可接受的。第三类是交易域购物车表cart、订单表orders、订单明细表order_item。购物车表记录用户加入购物车的商品和数量下单成功后清空对应记录。订单表是交易域的主表存订单号、用户id、订单总金额、订单状态、收货人信息、创建时间。订单明细表则把每个订单对应的商品快照存下来包括商品名、单价、数量。为什么存快照而不是直接关联商品表因为商品信息后续可能改动但用户下单那一刻看到的价格和商品名称必须永久保留在订单里这是电商设计的常识。第四类是运营域轮播图表banner用来配置商城首页的滚动图片。虽然不起眼但把这类运营配置做成表比硬编码在代码里优雅得多管理员可以在后台可视化维护。再多说一句用户表的设计。密码字段存的必须是加密后的密文我用的BCrypt加密算法后面详解。另外一定要加status字段做逻辑删除和禁用别直接物理删除用户记录订单、购物车都关联着用户的id物理删除会破坏数据完整性。2.2 容易栽跟头的字段与类型设计细节这些细节很少有人专门讲但恰恰决定了系统能不能跑到上线级别。金额全部用decimal。这是我先强调的订单总金额、商品单价、原价都用decimal(10,2)或者decimal(12,2)。Java实体类里对应BigDecimal类型。MySQL中float和double存在精度丢失问题尤其是在聚合计算和比较运算时会有莫名其妙的小数位这是行业老前辈用事故换来的教训。时间字段统一用datetime。创建时间、更新时间、支付时间都用datetime类型不要用timestamp。datetime的存储范围更大没有时区转换的坑。实体类里用LocalDateTime接收映射关系MyBatis处理得很干净。每个业务表建议都加create_time和update_time两个通用字段where条件做数据筛选的时候非常方便。逻辑删除与唯一约束。逻辑删除字段delete_flag0存在1已删除基本是标配。但加了逻辑删除后要注意唯一约束的冲突问题。比如分类表里分类名称如果要加唯一索引一条被逻辑删除的记录会占住这个名称导致无法新增同名分类。解决方法是唯一索引带上delete_flag字段或者在删除时把名称改成一个带时间戳的副本。我在做这个项目时就遇到了分类重名的问题后面在代码里做了删除时自动追加后缀的处理。订单号生成规则。订单号不能用数据库自增id直接暴露给用户会泄露业务量而且多表交互时需要字符串类型主键更灵活。我用的规则是时间戳yyyyMMddHHmmss 用户id后四位 四位随机数简单且够用。高并发场景可以引入雪花算法但这套项目用时间戳加随机数的方案足以支撑演示和中小规模业务。2.3 建表SQL脚本的执行要点完整的项目源码里通常包含database.sql初始化脚本。拿到脚本后我的建议是别急着直接执行先看一遍表结构和初始化数据。这个项目初始化时会自动创建数据库如果不存在然后切到该库执行建表语句并且插入默认管理员账号和测试商品数据。这样做对使用者非常友好省去了手工建库的步骤。执行的时候有一点要留意MySQL 8.0以上版本对utf8mb4的支持已经很完善建表时我统一把字符集设置成utf8mb4排序规则用utf8mb4_general_ci。这样能完整支持emoji字符和大部分生僻字。在MySQL 5.7上utf8mb4同样可用只是需要注意索引字节数的限制VARCHAR字段长度别太大我在商品描述字段上用的text类型就没这个问题。3. 后端SpringBootMyBatis的核心实现后端是这套系统的控制中枢。SpringBoot负责提供REST接口MyBatis负责跟MySQL打交道业务逻辑全部封装在Service层。3.1 分层工程结构与统一返回格式拿到源码先看包名结构这个项目的组织方式很标准controller层接收前端请求、service层处理业务逻辑、mapper层做数据持久化、entity或者model/pojo承载实体类此外还有config、common、utils等辅助包。Controller层只做参数接收和结果返回不写任何业务SQL。Service层是业务逻辑的集中地事务注解加在Service层方法上。Mapper层只声明接口和SQL映射MyBatis通过代理机制自动实现。这样的好处是职责单一出了问题能快速定位。前后端分离项目一个很重要的设计是统一返回格式。我定义了一个Result类包含code状态码、msg提示信息、data业务数据三个字段。所有接口成功时返回code200业务异常时返回code500或自定义错误码前端axios拦截器统一处理这些返回。如果每个接口都返回裸数据前端就要针对各种情况做判断代码会非常散乱。public class ResultT { private Integer code; private String msg; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.code 200; result.msg 操作成功; result.data data; return result; } public static T ResultT error(String msg) { ResultT result new Result(); result.code 500; result.msg msg; return result; } }统一异常处理也不能少。我加了RestControllerAdvice全局异常捕获业务代码里抛出异常后统一转换成Result.error返回给前端前端弹出一个错误提示就完事不会出现一大段Tomcat默认错误页面的情况。3.2 登录鉴权JWT与拦截器的配合系统分普通用户和管理员两种角色不可能让未登录的人乱调接口。我用的方案是JWT配合Spring拦截器这也是目前企业级项目里广泛使用的做法。登录流程是这样的用户输入用户名和密码后端根据用户名查出用户记录用BCrypt验证密码是否匹配匹配通过后生成一个JWT令牌返回给前端。JWT令牌里塞入了用户id、用户名、角色这些关键信息并且设置了过期时间。前端拿到令牌后存在localStorage里之后每次请求都在请求头里带上Authorization字段。后端拦截器的逻辑是放行登录、注册、首页商品查询等公开接口其余接口全部校验令牌。校验逻辑写在HandlerInterceptor的preHandle方法里先取出请求头里的token解析失败或过期就直接返回401状态码。根据角色再做二次过滤后台管理接口要求必须有管理员角色才能访问。用JWT最大的好处是服务端无状态不需要把会话存在MySQL或Redis里系统水平扩展时不用考虑session同步的问题。有个坑值得说JWT令牌如果泄露在过期之前都能被使用。所以我把过期时间设置成2小时并且在密码修改这个操作上做了让老令牌失效的处理实现方式比较简单就是给用户表加一个密码修改时间戳JWT里也放这个字段校验时对比一下。3.3 下单事务与并发扣库存的实现整个项目里技术含量最集中的就是下单接口。一个下单操作涵盖了校验用户是否登录、校验商品是否存在且上架、校验库存是否充足、计算订单总金额、创建订单主记录、创建订单明细记录、扣减库存、清空购物车中对应商品。这么多步骤任何一步失败都不能留下脏数据所以必须用事务包裹。Transactional(rollbackFor Exception.class) public OrderVO createOrder(OrderCreateDTO dto) { User user userMapper.selectById(dto.getUserId()); if (user null) { throw new BizException(用户不存在); } ListCartItem cartItems cartMapper.selectCheckedItems(dto.getUserId()); if (cartItems.isEmpty()) { throw new BizException(购物车为空); } BigDecimal totalAmount BigDecimal.ZERO; String orderNo generateOrderNo(user.getId()); Order order new Order(); order.setOrderNo(orderNo); order.setUserId(user.getId()); order.setStatus(0); // 待支付 for (CartItem item : cartItems) { Product product productMapper.selectById(item.getProductId()); if (product null || product.getStatus() ! 1) { throw new BizException(商品已下架或不存在); } if (product.getStock() item.getQuantity()) { throw new BizException(商品【 product.getName() 】库存不足); } BigDecimal itemAmount product.getPrice().multiply(new BigDecimal(item.getQuantity())); totalAmount totalAmount.add(itemAmount); OrderItem orderItem new OrderItem(); orderItem.setOrderNo(orderNo); orderItem.setProductId(product.getId()); orderItem.setProductName(product.getName()); orderItem.setProductPrice(product.getPrice()); orderItem.setQuantity(item.getQuantity()); orderMapper.insertOrderItem(orderItem); } order.setTotalAmount(totalAmount); orderMapper.insertOrder(order); cartMapper.deleteCheckedItems(user.getId()); return new OrderVO(order); }这里有个并发安全的关键点。多个用户同时购买同一件库存只剩一件的商品如果不做任何控制两个用户都能通过库存校验最终导致超卖。解决办法是在扣减库存的SQL上做条件更新UPDATE product SET stock stock - #{quantity} WHERE id #{productId} AND stock #{quantity}这条SQL执行后受影响行数为0就说明库存已经不足业务上直接抛异常触发事务回滚。利用InnoDB的行锁确保同一时刻只有一个事务能成功扣减库存这个方案比先查后改更安全也是在实际项目中验证过的高效做法。3.4 后台管理与文件上传的实现细节后台管理端的核心是商品CRUD。新增或编辑商品时涉及图片上传我用的方案是本地存储加虚拟路径映射。前端用Element UI的Upload组件把图片传到后端的/upload接口后端把文件保存到服务器的一个upload目录下然后把可访问的图片URL返回给前端。为了让前端能访问到图片需要配置一个静态资源映射Configuration public class WebConfig implements WebMvcConfigurer { Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/upload/**) .addResourceHandler(file: uploadPath /); } }这个配置自己在本地写很容易忘在开发时总是图片传上去了但页面显示不出来其实就是静态资源路径没映射对。商品列表查询用了MyBatis的分页插件PageHelper传pageNum和pageSize就能拿到分页数据和总条数前端表格组件做分页显示非常流畅。搜索功能支持按商品名称模糊查询、按分类筛选、按下架状态筛选这些动态SQL都写在Mapper的XML文件里用 标签处理条件拼接避免空参数导致SQL语法错误。4. 前端Vue实现的交互与联调后端把接口提供好之后前端的工作就是把这些接口转化成用户可操作的界面。这个项目的Vue部分同样分成用户端和管理端两大块。4.1 前端目录结构与组件拆分我习惯的Vue项目目录是这样组织的views目录放页面级组件比如首页Home.vue、商品列表ProductList.vue、商品详情ProductDetail.vue、购物车Cart.vue、确认订单OrderConfirm.vue、个人中心UserCenter.vue管理端的页面则放在views/admin下面。components目录放可复用的公共组件比如轮播图组件、商品卡片组件、分页组件。router目录维护路由表api目录统一存放接口调用代码store目录是Vuex状态管理主要存放用户登录信息和购物车数量这类全局状态。用户端和管理端用路由模块分隔开管理端的路由组件通过beforeEach守卫做管理员权限校验。这样设计的好处是页面职责单一组件复用率高权限控制集中在一个地方。有一点容易被忽视每个页面的资产、接口、组件如果要全局注册一定要在main.js里按需引入。Element UI虽然好用但如果全部组件全局引入打包体积会大很多。我实际做的时候用了按需引入的方式配合babel-plugin-component打包出来的体积小了不少。4.2 商品搜索、购物车与订单确认的交互细节商品搜索这块有个很容易被忽略的体验问题用户在搜索框里连续打字如果每敲一个字符就发一次请求后端会很累。我在搜索框的监听器里做了防抖处理用户停止输入300毫秒后才会发起接口请求这样既保证了搜索的实时性又不会有大量无效请求。购物车交互要处理好三个操作勾选商品、修改数量、删除商品。勾选状态对订单确认页有直接影响我在购物车页面把勾选状态放在Vuex里统一管理这样跳转到订单确认页时可以直接拿到所有勾选的商品不需要再用各种事件传递。修改数量时前端做简单的数量范围校验比如不能小于1最大99然后调用后端接口更新数量和价格小计。订单确认页的重点是收货地址和金额核对。项目里做了简单的地址管理用户在下单时填写收货人姓名、手机号、详细地址确认订单后把这些信息随订单一起保存。金额部分由后端在创建订单时统一计算前端展示的金额只做展示以后端返回的接口数据为准。这一点要特别强调前端传金额给后端是不可信的价格应该全部以后端查到的商品价格为准来计算。4.3 axios封装、路由守卫与开发环境代理前端与后端的通信通过axios完成。我在项目里对axios做了二次封装封装一个request.js文件设置baseURL配置请求拦截器把token加到请求头里配置响应拦截器统一处理业务码。后端返回的code为401时说明登录过期前端自动清除本地用户信息和token跳转回登录页。开发环境跨域问题看似麻烦实际只需要配置Vue CLI的proxy代理。我在vue.config.js中设置了devServer.proxy把所有/api开头的请求代理到localhost:8080后端Controller的RequestMapping统一加上/api前缀。这个方案的好处是前端代码里不需要写完整的后端地址部署时可以灵活切换。module.exports { devServer: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } }生产环境下nginx反向代理同样把/api请求转发给后端服务前端打包后的dist目录直接由nginx托管。整个联调流程非常顺畅前后端各改各的只要维护好接口文档几乎不会出现互相阻塞的情况。5. 部署运行与踩坑排查实录这个项目拿到手之后从本地跑起来到部署上线大概分几步。每一步都有几个常见的坑我把它们记在这里你遇到问题的时候可以直接对照排查。5.1 本地启动环境准备与运行步骤本地运行需要环境JDK 1.8及以上、Maven 3.6、MySQL 5.7或8.x、Node.js 14及以上。第一步是导入数据库。直接用数据库客户端工具执行项目里的sql脚本脚本会自动建库建表并插入初始化数据。执行成功后检查一下user表里是否已经有了默认管理员账号。第二步是改后端配置。打开application.yml把数据源相关的url、username、password改成你的本地配置。配置里的serverTimezone要特别注意用Asia/Shanghai避免中国时区的时间偏移8小时的问题。然后启动SpringBoot的启动类看到控制台输出Tomcat started on port(s): 8080就说明后端起来了。第三步是跑前端。进入vue目录执行npm install安装依赖npm run serve启动开发服务器默认端口一般是8081。浏览器访问localhost:8081出现商城首页说明前后端已经打通。注意如果你本地的8080端口被占用改后端端口时记得同时改前端vue.config.js里的代理目标端口以及前端request.js的baseURL三处保持一致才不会出现连不上的问题。5.2 前后端部署Jar包加Nginx的经典组合部署阶段我选择的方式是后端打Jar包前端打静态文件用Nginx统一入口。这样做的好处是前后端可以独立部署、独立升级Nginx配置也很简单。后端在项目根目录执行mvn clean package -DskipTests生成target目录下的jar包。生产服务器上执行nohup java -jar xxx.jar logs/console.log 21 即可启动。前端在vue目录执行npm run build生成dist目录。把dist目录下的文件上传到服务器指定目录然后在nginx配置里设置server { listen 80; server_name yourDomain; location / { root /opt/shop/dist; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }注意try_files那行配置。Vue Router如果用的是history模式刷新页面时如果nginx没配置try_files就会出现404。很多人在部署Vue项目时遇到刷新就404的问题罪魁祸首就在这里。如果不想处理这个问题前端改用hash模式也能绕过去但URL里会有个#号不美观。5.3 实际开发中踩过的坑与排查方法这部分价值很高我挨个记录一下踩过的真实问题。第一个是MySQL驱动和时区问题。SpringBoot 2.x默认引入的是com.mysql.cj.jdbc.Driver如果用低版本习惯了com.mysql.jdbc.Driver启动时就会报ClassNotFound异常。解决方法是检查pom中的mysql-connector-java版本和驱动类名我统一用的SpringBoot父依赖管理的版本。时区问题则表现为JVM时间与数据库存储时间相差8小时原因就是连接URL没加serverTimezoneAsia/Shanghai。第二个是MyBatis的Mapper扫描问题。SpringBoot启动类或配置类上必须加MapperScan注解或者在每个Mapper接口上加Mapper注解二选一。漏了的话启动时就会报mapper bean找不到。我习惯用MapperScan(com.shop.mapper)把整个mapper包一次扫描省事。第三个是前端跨域问题。开发环境用proxy代理解决但有时候改了代理配置不生效需要重启npm run serveVue CLI对proxy配置的修改不会热更新。这个情况确实很容易困扰新手改了配置没重启来回折腾半天。第四个是文件上传大小限制。默认情况下SpringBoot的Tomcat对上传文件有1MB的限制商城商品图片动辄几百KB多张图片可能就超了。在application.yml里设置spring: servlet: multipart: max-file-size: 10MB max-request-size: 20MB第五个是事务不生效的问题。我见过不少同学把Transactional加在Controller层的方法上结果是表面运行正常但真正发生异常时数据没有回滚。原因是Spring的声明式事务是通过AOP代理实现的只有通过代理对象调用Service层的public方法时事务才会生效。Controller调用Service属于外部调用没问题同一个Service内部方法之间互相调用事务就不生效了。事务注解的正确位置是Service层的public方法上并且要保证方法被外部调用。还有一个MyBatis特有的坑查询结果映射到实体类时如果数据库字段是user_id这种下划线风格实体类是userId的驼峰风格需要在application.yml里开启下划线转驼峰mybatis: configuration: map-underscore-to-camel-case: true不开这个配置查询出来的userId会一直为null前端拿不到用户相关数据排查起来还挺隐蔽的。5.4 常见问题速查表整理一个表格把最典型的几个问题列出来方便你遇到时直接定位。现象原因解决办法后端启动报ClassNotFound: com.mysql.cj.jdbc.Driver数据库驱动版本过旧用SpringBoot版本管理的mysql-connector-java或显式升级驱动前端请求接口报404代理目标端口与后端不一致检查vue.config.js中target和application.yml端口刷新页面404Nginx未配置try_files增加try_files $uri $uri/ /index.html;图片上传后无法访问静态资源映射未配置添加ResourceHandler映射到upload目录查询返回的用户ID为空驼峰映射未开启设置mybatis.configuration.map-underscore-to-camel-case为true下单后库存没有更新事务未生效确认Transactional在Service层public方法上且被外部调用登录后一段时间自动失效token过期时间太短调整JWT过期时间但要注意安全性中文乱码字符集配置不一致数据库连接URL加characterEncodingutf8写在最后的经验这套系统前后端加起来大概二十多个接口、十多个页面看起来功能不少但真正考验人的地方全在细节里。我做完之后最大的体会是一个项目不是把功能跑通就算完事务控不控得住、库存扣得准不准、接口返回格式统不统一、部署配置规不规范这些才是拉开差距的地方。如果你正在拿这套源码做课设或者面试作品建议你从下单流程读起把事务和库存扣减的SQL理解透再去看前端购物车的状态管理。这两个地方读明白了面试官问你项目亮点时你就有的说了。最后提醒一句复制代码不是目的把每行SQL、每个注解背后的为什么搞清楚这个项目才算真正变成了你的东西。