从接手某高校课程设计那段时间开始我就在找一套结构完整、能跑通电商闭环、又能直接拿来改的SpringBootVue项目。网上超市这个方向正好合适功能覆盖注册登录、商品浏览、购物车、订单、后台管理既有典型CRUD又有库存、状态流转这种值得深挖的业务点。我基于SpringBootVueMySQLMyBatis从建表到联调完整做了一遍这里把设计和实现在内的全过程整理出来重点说清楚每一个关键决策是怎么来的以及真正踩过的坑。1. 项目整体设计与思路拆解1.1 技术选型背后的考量为什么是SpringBootVue后端选SpringBoot几乎是Java技术栈的默认答案。它内嵌Tomcat免掉了一堆XML配置starter机制把依赖管理简化到极致适合管理系统这种CRUD密集场景。配合MyBatis是因为SQL可控性强网上超市涉及多表联查、动态条件筛选、订单统计这类场景手写SQL比自动生成直观得多排查问题也更精准。JPA确实写简单查询方便但遇到复杂分页和动态条件时手工拼接SQL反而不如MyBatis顺滑。前端选Vue第一是上手成本低模板语法和组件化设计让页面拆得非常自然第二是Vue配合Element UI这类组件库做后台管理界面的效率极高表格、表单、弹窗、分页这些高频交互组件都可以直接复用不花时间在重复造轮子上。项目整体采用前后端分离架构后端只输出JSON接口前端负责页面渲染和数据交互。好处是部署解耦后端打jar包前端打静态资源包各自扩展互不干扰团队协作时可以并行开发前端用Mock数据先跑页面后端用Postman测接口不需要互相等。这套组合在中小型管理系统里有多主流不用多说招聘市场的Java全栈岗位一半以上写的就是这套技能树。做完这个项目简历上写“熟悉SpringBootMyBatisVue全栈开发”就有了实际项目支撑面试官追问技术细节时也有真实经验可讲。1.2 系统模块规划从需求到功能拆分网上超市系统里的核心角色有两类普通用户和后台管理员。先梳理用户端的需求注册登录、按分类浏览商品、关键词搜索、商品加入购物车、调整购物车数量、提交订单、查看订单状态。后台管理端的需求差异明显维护商品的增删改、上下架、调整价格库存、处理订单发货与取消、查看用户列表。两类角色对应两套界面但共用同一份后端服务通过接口权限做区分不用单独建两套后端。模块划分上我拆成六大块用户模块、商品模块、购物车模块、订单模块、分类模块、后台管理模块。每个模块的职责边界非常清楚——购物车只管临时的加购和数量调整订单是核心交易链路商品和分类是基础数据。这样在写Controller和Service时方法归属清晰后期加功能、改bug都不会出现“这个逻辑放哪都行”的纠结。比如购物车里的“清空购物车”方法只属于购物车Service订单创建成功后调用它而不是把清空逻辑写在订单Service里。保持这种单向依赖关系重构时动一个模块不会牵连其他模块。前端也按同样思路拆成用户端组件和后台管理端组件。用户端有首页商品广场、购物车页面、订单列表、个人中心后台管理端有商品管理表格、订单管理表格、分类管理。两边很多布局组件可以复用同一个组件库但业务组件分开维护避免一个文件里塞了多套角色的逻辑导致代码臃肿。1.3 数据库表设计与几个关键约定数据库是这套系统的地基。核心涉及6张表用户表、商品表、分类表、购物车表、订单表、订单明细表。我先说几个容易踩坑的关键约定再给建表思路。第一个约定是金额字段一律用decimal而不是float/double。MySQL里float和double存在精度丢失问题订单金额涉及对账哪怕差一分钱都是事故。decimal(10,2)足够覆盖单笔金额Java实体类对应用BigDecimal接收算总金额也统一用BigDecimal运算拒绝double。第二个约定是库存扣减用乐观锁思路商品表加stock字段更新时带WHERE stock #{num}条件防止并发下单超卖这一点在订单提交环节详细展开。第三个约定是订单主表和明细表必须拆开。一个订单包含多个商品如果合并成一张表每一行都要重复存收货地址、订单状态、下单时间等主表字段冗余量大且后期维护混乱。拆开后订单主表存订单维度信息明细表一行一个商品通过订单id关联查询订单详情时两表join即可。表设计还要注意索引。商品表的name字段、订单表的user_id字段和order_no字段都要加索引因为搜索商品和查用户订单是最高频的操作。我实际吃过亏一开始没给user_id加索引数据量到几千条之后查订单列表明显变慢加上索引后速度立刻起来。索引不是越多越好但高频查询字段该加一定要加尤其是外键关联字段不加索引会导致联查变成全表扫描。2. 核心细节解析与实操要点2.1 后端分层结构与MyBatis接口设计要点后端代码按经典四层结构组织Controller控制层、Service业务层、Mapper数据访问层、Entity实体类外加Config配置类和Common通用返回类。一个核心原则是Controller保持轻薄只负责接收参数、调用Service、返回Result结果不写业务判断。比如“加入购物车”这个操作Controller只接收productId和quantity两个参数真正判断商品是否存在、库存是否充足、购物车里是否已有该商品等逻辑全部放在Service层。这样Controller层代码非常干净而且业务逻辑可以被多个Controller复用比如库存扣减在提交订单和购物车校验时都要用放Service层就能避免重复代码。MyBatis这块我采用Mapper接口和XML映射文件结合的方式。简单单表查询用注解Select直接写SQL没毛病但涉及动态条件的场景比如商品列表要支持按名称模糊搜索、按价格区间筛选、按分类筛选XML里的where和if标签灵活得多维护也更清晰。下面是我实际使用的商品分页查询SQL片段select idfindProductPage resultTypecom.store.entity.Product SELECT * FROM t_product where if testname ! null and name ! AND name LIKE CONCAT(%, #{name}, %) /if if testcategoryId ! null AND category_id #{categoryId} /if if testminPrice ! null AND price ![CDATA[ ]] #{minPrice} /if if testmaxPrice ! null AND price ![CDATA[ ]] #{maxPrice} /if /where ORDER BY id DESC LIMIT #{offset}, #{pageSize} /select这里有三个细节必须注意。第一LIKE模糊查询用CONCAT拼接参数而不是在Java侧拼好再传避免SQL注入风险第二和在XML里直接用会解析报错要么用lt;和gt;要么像我这样用CDATA包裹二选一否则启动阶段XML解析直接失败第三LIMIT的offset和pageSize用#{}传参MyBatis会安全地预编译不要用${}字符串拼接。2.2 购物车与订单状态机的设计购物车这块很多新手容易设计复杂。其实购物车只是用户的临时选择集合核心维度就三个哪个用户、哪个商品、数量多少。不需要做额度限制、不需要提前锁定库存、不需要复杂的优惠计算。我用的是简单表结构id、user_id、product_id、quantity、create_time、update_time。用户每次点击“加入购物车”时先查这个用户是否已经加过这个商品存在就直接把quantity加一不存在就插入新记录。这样表不会因为反复加购产生大量重复数据而且输入框改数量时也只需要update一次quantity字段。订单状态是整个系统里最容易出bug的环节。我把它定义为明确的状态码0待付款、1待发货已付款、2已发货、3已完成、4已取消。前端根据状态码渲染不同按钮后端在Service层做流转校验。比如只有状态为0的订单能取消状态为1的订单管理员才能发货不允许用户对已发货订单发起取消。状态流转逻辑全部放Service层用一个独立的StateMachine方法接收当前状态和目标状态合法则返回新状态非法直接抛业务异常。实际代码大致长这样public void changeOrderStatus(Order order, int targetStatus) { int current order.getStatus(); switch (current) { case 0: // 待付款 if (targetStatus ! 1 targetStatus ! 4) { throw new BusinessException(待付款订单只能支付或取消); } break; case 1: // 待发货 if (targetStatus ! 2 targetStatus ! 4) { throw new BusinessException(待发货订单只能发货或取消); } break; case 2: // 已发货 if (targetStatus ! 3) { throw new BusinessException(已发货订单只能确认收货); } break; default: throw new BusinessException(当前状态不允许变更); } order.setStatus(targetStatus); }有人可能觉得这写法啰嗦但实际测试中它能拦截大量非法操作尤其前端按钮写漏了或接口被人直接调用时后端状态机是最可靠的安全网。2.3 前端Vue组件化与API请求封装前端项目按“基础组件页 功能模块”组织。商品列表、购物车、订单管理、后台商品维护每个模块一个独立Vue组件组件之间用Vue Router控制跳转用Vuex管理全局的用户信息和购物车数量标记。Element UI提供表格、表单、对话框等现成组件省掉大量重复样式代码。以商品管理页为例数据表格用el-table新增和编辑用el-dialog套el-form提交成功后再刷新列表。这套做法在后台管理类页面里可以无限复制开发效率非常高。接口请求这块我封装了一个统一Axios实例把公共逻辑收敛到一处。baseURL设成/api请求超时设为10秒请求拦截器里自动从localStorage取token并放进Authorization请求头响应拦截器统一判断业务状态码401就跳转登录页。这样业务代码里不用每次写重复逻辑出问题也只改一个文件。核心代码const service axios.create({ baseURL: /api, timeout: 10000 }); service.interceptors.request.use(config { const token localStorage.getItem(token); if (token) { config.headers.Authorization token; } return config; }); service.interceptors.response.use( res { const data res.data; if (data.code 401) { localStorage.removeItem(token); router.push(/login); return Promise.reject(new Error(未登录)); } return data; }, err { if (err.response err.response.status 401) { localStorage.removeItem(token); router.push(/login); } return Promise.reject(err); } );跨域是前后端联调绕不开的问题。开发环境下前端跑8080端口后端跑8088端口浏览器拦截跨域请求。我在后端加了全局CORS配置允许来自本地开发服务器地址的跨域请求。生产环境把前端dist目录部署到Nginx后通过Nginx反向代理把/api路径转发给后端Java服务跨域就被Nginx天然规避了后端也不用暴露接口地址这个部署思路在后面有详细配置示例。3. 实操过程与核心环节实现3.1 环境准备与项目初始化步骤这是我实际跑通的版本组合JDK 1.8、Maven 3.6.3、Node.js 14.x、MySQL 5.7、Vue CLI 4.x。JDK 8在运维环境里依然最主流选它兼容性最好MySQL选5.7或8.0均可但驱动坐标要对应版本MySQL是8.0的话驱动用mysql-connector-java 8.x同时JDBC URL要加时区参数serverTimezoneAsia/Shanghai否则连接直接报错。后端项目用Spring Initializr生成后添加的依赖集中在这几个spring-boot-starter-web、mybatis-spring-boot-starter、mysql-connector-java、lombok。然后改两处配置application.yml配置数据源、端口、MyBatis的mapper-locations和驼峰映射启动类加MapperScan扫描Mapper接口包。前端Vue项目用Vue CLI创建随后安装依赖vue create web-store-web npm install axios element-ui vue-router vuex npm run serve配置好基础环境后我习惯先把数据库脚本跑一遍按外键依赖顺序建表先分类表、商品表再购物车表、订单表、订单明细表最后用户表。每张表都加create_time和update_time字段这个习惯后期排查数据问题会非常有用。建表时用统一的命名规范表名t_前缀字段名全部下划线风格避免实体类和数据库字段映射时出现不明不白的错误。3.2 用户注册登录与JWT鉴权的实现用户模块是购物车和订单的前提所以最先实现。注册接口逻辑很直接接收用户名和密码检查用户名是否已存在密码用BCrypt加密后存入数据库。为什么选BCrypt而不是MD5MD5加盐也得自己实现而且算力提升后碰撞风险增加。BCrypt是为密码存储设计的算法自带随机盐同一密码每次加密结果都不同即使数据库泄露也很难逆向出明文。成本因子我设默认10校验时用PasswordEncoder.matches()不需要手动提取盐。登录成功后的核心是生成Token。我用JWT做无状态鉴权Token里只放userId和username设置8小时过期。为什么不依赖Session因为前后端分离后后端可能水平扩展成多个实例Session在各实例间不共享要共享就得引入Redis做集中化管理。JWT自包含、服务端无状态每次请求带Token后端拦截器解析校验签名即可。登录Controller层代码PostMapping(/login) public Result login(RequestBody LoginRequest req) { User user userService.login(req.getUsername(), req.getPassword()); if (user null) { return Result.error(用户名或密码错误); } String token JwtUtil.generateToken(user.getId(), user.getUsername()); return Result.success(token); }登录校验放在Service层做三件事按用户名查用户、用BCrypt校验密码、查不到或校验失败统一返回“用户名或密码错误”。不区分提示是为了避免暴露“用户名存在”这种信息给攻击者。Token生成后前端存localStorage后续请求依靠拦截器放行的JWT识别用户身份。拦截器在preHandle方法里从请求头取Authorization字段解析不出JWT直接返回401解析成功就把userId塞进request属性Controller里再取出来用。注册拦截器时注意放行登录、注册、商品列表这些不需要鉴权的接口否则前端页面第一次打开就直接报错。3.3 商品管理模块的增删改查实现商品管理是后台管理员的核心操作区。管理员后台页面提供新增、编辑、上架、下架、删除操作用户端通过status字段只展示已上架商品。删除我用了软删除策略给商品表加is_deleted字段删除时置为1而不是物理DELETE。为什么不物理删除因为商品被历史订单引用物理删除会导致订单详情关联不到商品名称历史数据直接残缺。软删除既隐藏无效商品又保留数据追溯能力管理员误删后还能恢复是很实用的设计。后端商品Service里关键方法是分页查询和状态更新。分页用PageHelper插件使用非常简单查询前调用PageHelper.startPage(pageNum, pageSize)紧接着执行Mapper查询插件自动拼接LIMIT。这里有个铁律startPage必须紧接着Mapper查询中间不能插任何其他业务逻辑更不能做别的SQL操作否则分页会作用到最近的SQL上数据条数和页码完全错乱。我一开始不熟悉这个机制在startPage后先查了个字典表结果翻页怎么都不对后来翻源码才明白PageHelper底层的ThreadLocal机制。现在习惯把分页参数封装成PageRequest对象Service层第一行调用不走弯路。商品上下架的更新逻辑本质是一次UPDATE status字段但要注意前端提示刷新。编辑保存后如果页面还用旧数据渲染用户看到的就不是最新状态。我在新增和编辑成功后重新拉取商品列表而不是只改本地数组里一个元素这样前端展示永远和数据库同步不会出现“我改了怎么没反应”的困惑。图片上传这块没上云存储项目本地跑不需要也减少部署依赖。处理方案是把图片转Base64存到商品表的TEXT字段前端直接用img :srcproduct.imageUrl展示。这种方式合适小图和演示项目生产系统建议用文件上传存本地磁盘或对象存储因为Base64会让记录多出几十KB数据库变大后查询性能受影响。这个取舍在答辩或面试时值得解释清楚能体现你有生产环境意识。3.4 订单提交流程与库存扣减的实现订单提交是整个系统业务逻辑最密集的地方。前端用户点结算时把购物车选中的商品ID列表传给后端后端依次处理查购物车明细、计算总金额、校验商品库存、生成订单主表、批量生成订单明细、扣减库存、清空购物车。多个写操作必须要么全成功要么全回滚方法上加Transactional事务注解。这个流程的顺序是有讲究的不能乱。库存扣减是并发控制的重头戏。如果直接写UPDATE t_product SET stock stock - #{num} WHERE id #{productId}并发场景下两个请求同时读到stock5都扣3件最终库存可能变成2但实际已卖出6件严重超卖。我加了安全条件UPDATE t_product SET stock stock - #{num} WHERE id #{productId} AND stock #{num}。MySQL在行锁机制下这条语句执行时锁住商品行后进入的请求因为条件不满足更新0行Service通过返回值判断并抛出“库存不足”。这种写法比悲观锁SELECT ... FOR UPDATE轻量又比纯应用层判断可靠是电商库存扣减的经典方案。订单号我用时间戳用户ID随机数拼成字符串保证唯一性。如果后续接支付系统建议单独设计订单号规则比如带日期格式的19位纯数字方便对账和按日分表。当前版本没有支付通道订单状态停留在待付款时用户和管理员在列表里能看到状态码课程设计或演示场景完全够用。订单创建完成后返回订单详情页。前端要注意避免用户刷新后重复下单提交成功跳转到订单详情时组件内部做好状态控制不要再用购物车数据重新触发下单接口。我的做法是提交接口成功返回订单ID前端根据订单ID查询订单详情而不是重新走购物车结算接口。3.5 前端购物车与订单交互的实现细节购物车页面看起来简单实际交互细节不少。数量调整器我用了一个计数器组件用户点加减号时先阻止默认的失焦事件等计数器稳定后再调接口同步后端避免输入过程中疯狂发请求。同步数量接口是PUT /api/cart/{id}/quantity参数只有数量后端校验最大不超过商品库存上限。删购物车项是单条删除关键是要在Vuex里同步更新购物车总件数标记否则顶栏的徽标数字不对用户会觉得系统出bug了。结算按钮触发前前端先做一次选中状态检查至少选择一个商品才允许点击。提交时把选中项的ID数组传给后端保证后端只处理用户勾选的商品而不是全量购物车。这个细节许多教程代码里没体现实际体验中非常重要——用户购物车里留了几件不想要的东西直接全量结算会让人很崩溃。订单列表页的状态展示我用状态码映射组件0待付款显示橙色1待发货显示蓝色2已发货显示青色3已完成显示绿色4已取消显示灰色。按钮区根据状态码渲染待付款显示“取消订单”待发货显示“确认发货”仅管理员已发货显示“确认收货”。前端只管状态码文案和操作入口都由后端在接口返回的字段控制保持单一数据源避免前端写死文案后和后端逻辑不一致。4. 常见问题与排查技巧实录4.1 跨域请求被拦截的排查与配置开发环境最常见的第一坑就是跨域。前端npm run serve默认跑在localhost:8080后端SpringBoot跑在8088端口页面直接发Ajax请求浏览器控制台报错里带CORS字样。解决方式是在后端写全局CORS配置类允许前端来源的跨域请求Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOrigins(http://localhost:8080) .allowedMethods(*) .allowedHeaders(*) .allowCredentials(true); } }这里有个容易踩的细节allowCredentials(true)和allowedOrigins(*)不能同时用浏览器会拒绝响应。要允许携带凭证并且指定来源必须把allowedOrigins写成具体地址不能用星号。配置完重启后端才生效CORS配置只在启动时加载。排查跨域问题时先在浏览器F12的Network里看请求是否发出再看响应头的Access-Control-Allow-Origin有没有正确返回。请求根本没发出去多半是前端拦截器或网络问题发出去但被CORS拦截才是后端配置问题。这条排查思路能帮你快速定位问题在哪一侧不用瞎猜。4.2 分页查询结果错乱与PageHelper使用禁忌前面提过PageHelper对使用方式非常挑剔。一个典型错误是startPage之后、查询之前做了其他SQL操作比如查了字典表或统计数量PageHelper的ThreadLocal分页参数会拦截到最近那条SQL上结果就是数据条数和页码对不上翻页也错乱。解决办法是分页查询第一行就是Mapper查询中间不插任何数据库操作。还有一种情况是查询结果正常但总条数显示为0。这个多半是业务SQL里已经手动写了LIMITPageHelper会在LIMIT后再套COUNT查询导致COUNT结果异常。用了PageHelper业务SQL就不要自己写LIMIT分页完全交给插件管理。检查方法也简单把MyBatis日志级别调成DEBUG看控制台打印的COUNT SQL和分页SQL就能发现问题。实际开发里这一条能省下很多排查时间。4.3 LocalDateTime格式化和金额计算的两个小坑后端实体类用LocalDateTime接收数据库的datetime字段返回前端时如果不做处理Jackson序列化出来是一段数组格式前端拿到的根本不是时间字符串。解决办法是配置文件里统一设置Jackson日期格式spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai不要用JsonFormat一个个字段去加注解效率低还容易漏。配置文件一处搞定所有LocalDateTime字段统一格式。金额计算必须用BigDecimal。Java里用double做0.10.2运算得到0.30000000000000004订单金额差一分钱对账就是个事故。项目里凡是涉及金额的地方统一用BigDecimal接收和计算数据表用decimal(10,2)实体类用BigDecimal前端展示用toString()取字符串。这个坑在代码评审时最好指出一份代码里出现double接金额基本就埋了隐患。4.4 前端路由刷新404与生产部署方案前端用Vue Router的history模式时直接刷新某个子路由比如/store/cartNginx返回404。原因是history模式基于HTML5的History API管理路由但Nginx静态站点里并不存在/store/cart这个物理文件。解法是Nginx配置里加try_files把所有请求指向index.html交给前端路由接管location / { root /var/www/web-store; index index.html; try_files $uri $uri/ /index.html; }后端接口的转发配置也放同一份Nginx里把/api路径反代到SpringBoot服务端口location /api/ { proxy_pass http://127.0.0.1:8088/; }这样前端请求走Nginx转发后端接口地址不暴露外网部署也省心。如果不想折腾Nginx也可以把前端dist目录放后端static文件夹一并输出但那样前后端耦合进同一个包更新前端资源必须重新打包后端不推荐长期用。生产环境里前后端分离部署再通过反向代理统一入口才是主流做法。4.5 其他高频问题的速查表现象可能原因解决办法启动时报数据库连接失败JDBC URL未加时区参数URL末尾加?serverTimezoneAsia/Shanghai返回JSON里中文变问号数据库连接字符集未指定URL加characterEncodingutf8修改商品后前端列表不更新前端状态未重新拉取数据保存成功后重新调用列表接口登录后刷新就掉线前端token未持久化登录成功存localStorage刷新时恢复接口偶尔返回500表字段与实体类属性映射不一致开启MyBatis驼峰映射确认map-underscore-to-camel-case删除商品提示外键引用订单明细表引用了商品ID改用软删除或先检查关联订单最后分享几点实际操作的体会这个项目从建表到联调我用了整整一周多时间。最大的体会是网上超市这种CRUD型管理系统技术上没有特别高深的内容难的反而是业务边界的梳理和并发场景的设计——临时购物车要不要持久化、订单状态允许哪些流转、库存怎么扣才不超卖这些想清楚了写代码很快想不清楚后面全在打补丁。如果你也在做类似的系统先别急着写代码把数据表关系和状态流转在纸上画清楚再动手。项目后续如果上简历或毕设答辩“如何保证库存不超卖”“为什么选JWT而不是Session”这两个问题一定要能讲明白这是面试官和答辩老师关注度最高的点。最后分享一个小技巧开发阶段把MyBatis日志级别设成DEBUG控制台直接看每一条SQL语句排查问题效率翻倍。这个项目后续还可以往订单统计报表、Excel导出、接口权限细分方向扩展改动量可控适合想继续深入做全栈的同学接着玩。
阅读完成 · 觉得有帮助?