
毕业后第一年我在某电商团队做全栈开发天天跟商城系统打交道后来好几个朋友问我想自己从零写一套网上购物商城系统练手或者做毕业设计到底该用什么技术栈我每次都推荐同一套组合SpringBootVueMySQLMyBatis。不是因为它多新潮而是这套组合把后端业务落地、前端交互、关系型数据建模三件事覆盖得相当完整而且每个环节都有大量可查的成熟方案。这篇博文就是基于我实际搭建这套商城管理系统的完整过程整理出来的内容覆盖项目整体设计、数据库建模、后端接口落地方案、Vue前端双端搭建以及下单支付链路的难点拆解。代码思路和表结构设计我都会展开讲全程对标能跑通、能演示、能答辩的标准。适合准备做Java全栈项目的学生、想转行Web开发的初学者以及后端想补前端知识的朋友参考。1. 网上商城管理系统到底要做成什么样动手写代码之前必须先想清楚一个问题这个系统服务谁很多初学朋友一上来就建表、写接口结果做到一半发现商品、订单、用户三张表根本撑不起一个能看的系统。我建议第一件事就是画角色图和功能清单。1.1 两个核心角色决定功能边界网上购物商城一般拆成两个端前台用户端和后台管理端对应两类角色。用户端面向普通消费者核心路径是浏览商品 - 搜索筛选 - 加入购物车 - 提交订单 - 支付或模拟支付- 查看订单状态。围绕这条链路至少要提供用户注册与登录商品列表展示与按名称/分类筛选商品详情页购物车增删改查、选中结算提交订单、订单列表、订单详情收货地址维护管理端面向运营人员核心价值是管货、管单、管人商品管理新增、上下架、编辑库存和价格分类管理订单管理查看订单、修改发货状态用户管理查看用户列表、禁用账号统计概览比如商品总数、订单总数、销售金额不需要做太复杂有一个简单的Dashboard即可我见过不少项目在这部分踩坑把功能做得过大比如加了秒杀、优惠券、拼团结果每个模块都很粗糙演示时漏洞百出。初版项目功能宁少勿滥核心链路每一环必须闭环。1.2 为什么是SpringBootVue这套组合先说后端。SpringBoot是最主流的Java后端快速开发框架内嵌Tomcat不用单独部署容器一个Jar包就是完整的服务。配合MyBatis做数据访问层SQL是我自己写的复杂查询的可控性很强这一点在商品筛选、订单统计这种场景特别有用。相比JPA那种自动生成的查询MyBatis更直接出问题好排查。前端选Vue原因有两个一是组件化开发方式适合商城这种页面多但结构重复的场景商品卡片、订单项、分页组件抽出来到处复用二是Vue生态里的Vue Router和Vuex/Pinia对路由权限、全局状态管理支持很成熟配合Axios做接口请求非常顺手。数据库用MySQL理由就更直接了商城核心数据是商品、订单、用户都是强关系型数据事务特性ACID是硬要求——尤其是订单和库存绝不允许下单成功但扣款失败这种数据不一致。MySQL的InnoDB引擎支持行级锁和事务天然适合这套场景。一句话总结SpringBoot负责接需求、MyBatis负责查数据、Vue负责画界面、MySQL负责记账。这套组合的项目源码在各类社区里存量极大遇到问题很容易搜到解决方案对学习者来说是最稳妥的选型。2. 数据库建模先把订单、库存和商品的账算清数据库设计是商城系统的地基。我见过有人只用三张表做商城商品信息里直接塞字符串存图片路径订单表里用逗号分隔商品ID——这种设计一旦遇到一个订单包含多个商品的需求代码直接崩。正确的做法是先按业务实体拆分再处理它们之间的关系。2.1 六张核心表的结构设计我这里给出经过实际项目验证的六张核心表每一张都说明关键字段的设计意图。用户表user字段类型说明idbigint主键自增usernamevarchar(50)登录名加唯一索引passwordvarchar(255)建议BCrypt加密后的密文nicknamevarchar(50)显示昵称phonevarchar(20)手机号statustinyint0正常 1禁用created_timedatetime创建时间商品表product字段类型说明idbigint主键namevarchar(100)商品名称category_idbigint关联分类表pricedecimal(10,2)单价用decimal不用floatstockint库存量main_imagevarchar(255)主图URLdetailtext详情描述statustinyint0下架 1上架价格字段必须用decimal(10,2)这是很多初学者容易忽略的点。float和double存在二进制浮点误差算钱会出大事。购物车表cart字段类型说明idbigint主键user_idbigint关联用户product_idbigint关联商品quantityint数量checkedtinyint是否勾选结算0/1订单表orders和订单明细表order_item是商城建模的核心。订单主表记录一次购买行为的整体信息明细表记录这个订单里具体买了哪些商品、各买了多少、下单时单价是多少。订单主表 orders 的关键字段id、order_no订单编号唯一形如 202405211030001user_id、total_amount订单总金额冗余存储status订单状态0待支付 1已支付 2已发货 3已完成 4已取消receiver_name、receiver_phone、receiver_address收货信息快照created_time、pay_time、ship_time订单明细表 order_item 的关键字段id、order_id、product_id、product_name下单时名称快照、product_imageprice下单时单价快照quantity、subtotal订单明细为什么要冗余商品名称、图片、单价因为商品信息是会变的——商家改个价格、换个图片历史订单里的数据不能跟着变否则对账就乱了。这是典型的快照设计思路做商城一定要有这个意识。分类表category就比较简单id、name、sort_order。商品表和分类表通过category_id关联查询某个分类下的商品时用WHERE category_id ?即可。2.2 订单状态机用一列状态串起整个交易流程订单状态是整个系统里最容易写乱的地方。很多初学者用多个字段去表达状态比如一个is_paid、一个is_shipped、一个is_completed结果查询时各种组合条件代码越写越难维护。我的做法是只用一个status字段定义枚举值所有状态流转都通过明确的操作触发用户下单落库 - status 0待支付用户支付成功 - status 1已支付/待发货管理员发货 - status 2已发货用户确认收货或系统自动确认 - status 3已完成用户取消或超时未支付 - status 4已取消这个流转是单向的不允许随意跳转。后端在做订单状态更新时必须校验当前状态是否是目标状态的前置状态。比如发货操作只有status 1的订单才能更新为status 2。防止因为接口重复调用或恶意请求把订单状态改乱。这块的逻辑我会在第5章配合代码详细讲因为它是下单链路的核心。2.3 库存扣减方案的选择库存字段就一个stock但扣减方式有讲究。最原始的写法是先查库存 - 判断库存够不够 - 够则UPDATE stock stock - 1这在单用户场景没问题但并发环境下会出大问题。多个请求同时查到库存还有1件然后同时执行扣减库存会变成负数。这就是典型的超卖问题。安全做法是用原子更新语句UPDATE product SET stock stock - #{quantity} WHERE id #{productId} AND stock #{quantity}这条SQL利用stock quantity条件让数据库在行锁级别保证只有库存足够时才会更新成功影响行数为1表示扣减成功0表示库存不足。这比先查后更新可靠得多也是我在项目中实际采用的方式。更复杂的Redis预扣减方案适合高并发秒杀场景在初版商城系统里不必引入。3. SpringBoot后端落地接口分层与权限认证数据库设计完了接下来是SpringBoot后端工程。整个后端我习惯按这样的包结构组织com.example.mall ├── controller // 接口层 ├── service // 业务逻辑层 ├── mapper // MyBatis数据访问层 ├── entity // 数据库实体 ├── dto // 接口入参/出参对象 ├── common // 公共类统一返回结果、异常、常量 └── config // 配置类拦截器、跨域原则是controller只做参数接收和结果封装不写业务逻辑service专注业务规则mapper只做SQL和数据库交互。分层清晰了排查问题时能顺着接口 - 业务 - SQL快速定位。3.1 统一返回结构和全局异常处理前后端分离项目里接口返回值必须有一个约定俗成的格式。我用的结构是这样{ code: 200, message: success, data: { } }成功时code为200业务失败时code为400或自定义错误码后端抛异常时由全局异常处理器统一兜底返回。前端Axios只需要判断code是否等于200再决定走成功流程还是弹错误提示逻辑非常统一。后端用RestControllerAdvice做全局异常处理拦截两类异常业务异常比如库存不足、订单状态不允许操作和系统异常空指针、数据库连接失败等。业务异常通过自定义的BusinessException抛出message直接展示给用户系统异常则记录日志并返回系统繁忙这类通用提示避免把堆栈信息暴露给前端。这个设计很多初学者不重视但它是前后端联调效率的关键。没有统一返回结构时每个接口返回格式都不一样前端每个页面都要单独处理非常痛苦。3.2 JWT登录认证与拦截器登录认证我采用**JWTJSON Web Token**方案。用户登录成功后后端生成一个带过期时间的token返回给前端前端存到本地存储或Cookie里后续每次请求在Header中带上Authorization: Bearer token。后端处理分成三层登录接口验证用户名和密码密码用BCrypt加密存储和校验。注意不要自己写MD5加盐方案BCrypt是业界标准且SpringSecurity里直接能用。自定义一个拦截器AuthInterceptor在preHandle里从请求头解析token解析失败或过期直接返回401解析成功就把用户ID塞到请求上下文比如ThreadLocal后续业务代码随时可以拿到当前登录用户。注册拦截器时配置路径规则商品列表、详情、登录、注册这些接口放行购物车、订单、个人中心等需要登录的接口拦截管理端接口额外校验角色。管理端的权限判断可以在拦截器中只做登录校验等进入Controller时用注解或手动判断user.role字段。角色字段我习惯直接放在用户表里role取值为USER或ADMIN管理端接口在业务层校验一次防止普通用户直接调用管理员接口。3.3 MyBatis动态SQL处理商品筛选商品列表页的筛选条件是典型的动态查询用户可能按名称模糊搜索可能按分类过滤可能按价格区间也可能综合条件。用MyBatis的where和if标签可以优雅解决这个问题。select idsearchProducts resultTypecom.example.mall.entity.Product SELECT * FROM product where if testcategoryId ! null AND category_id #{categoryId} /if if testkeyword ! null and keyword ! AND name LIKE CONCAT(%, #{keyword}, %) /if if testminPrice ! null AND price gt; #{minPrice} /if if testmaxPrice ! null AND price lt; #{maxPrice} /if AND status 1 /where ORDER BY id DESC /selectwhere标签会自动处理掉第一个条件前面的AND关键字避免写出WHERE AND name LIKE ...这种错误SQL。分页我直接用LIMIT #{offset}, #{pageSize}配合前端传过来的页码计算offset虽然简单但足够用。如果需要更完整的分页方案引入PageHelper插件可以省去手动计算但多一个依赖初版项目不是必需。前端商品列表接口通常还需要返回总条数我是用一个查询返回结果列表、另一个查询返回COUNT(*)两个Mapper方法配合完成分页。后端收到请求后返回数据结构里包含list和total两个字段供前端渲染表格和分页组件使用。4. Vue前端搭建用户端与管理端的双面工程后端接口就位后前端是另一个大工程。Vue项目的目录结构我这样组织src ├── api // 接口请求模块按业务域划分 ├── assets // 静态资源 ├── components // 公共组件 ├── router // 路由配置 ├── store // 全局状态登录信息、购物车 ├── views │ ├── user // 用户端页面 │ └── admin // 管理端页面 └── utils // axios封装等工具前端最大的两个设计点一是路由权限控制二是Axios统一封装。4.1 前端工程结构与路由权限用户端路由/跳转首页商品列表/product/:id商品详情/cart购物车/orders订单列表/login登录注册。管理端路由/admin下挂商品管理、分类管理、订单管理、用户管理等页面。管理端和用户端可以做成一整个Vue工程通过路由区分而不是拆成两个独立前端项目。这样共用一套登录态、一套Axios封装维护成本低很多。路由权限我采用全局前置守卫方案在router.beforeEach里检查目标路由是否需要登录通过路由元信息meta.requiresAuth标记需要登录且本地没有token就跳转登录页访问管理端路由时额外检查用户角色是否为管理员不是的话直接跳回首页并弹出提示。这个逻辑写在main.js或独立的路由配置文件里几行代码就能实现router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.meta.requiresAuth !token) { next(/login) } else if (to.path.startsWith(/admin) localStorage.getItem(role) ! ADMIN) { next(/) } else { next() } })需要注意前端路由守卫只是交互层面的控制真正的安全防线在后端接口的权限校验前端隐藏按钮不能代替后端拦截器。4.2 Axios封装与请求拦截Axios统一封装是提高开发效率最直接的手段。我在utils/request.js中创建实例配置baseURL和超时时间然后加两个拦截器请求拦截器每次请求前从localStorage读取token有则加到请求头Authorization。service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers[Authorization] Bearer token } return config })响应拦截器统一解析后端返回的{code, message, data}结构。code为200时直接返回response.data.data给业务代码省去每处都写res.data.data的重复代码code不为200时弹出一个错误提示遇到HTTP状态码401时token过期清除本地登录信息并跳转登录页。service.interceptors.response.use( response { const res response.data if (res.code 200) return res.data alert(res.message) return Promise.reject(new Error(res.message)) }, error { if (error.response.status 401) { localStorage.removeItem(token) router.push(/login) } return Promise.reject(error) } )这套封装做完之后每个页面的接口调用代码会非常简洁。比如商品列表页获取数据只需要const list await getProductList(params)完全不用关心请求头、状态码、错误弹窗这些重复逻辑。4.3 购物车与结算页的组件设计购物车在商城前端里是一个典型的本地状态服务端持久化场景。我采用用户登录后购物车数据存后端数据库的方案前端Vuex/Pinia里存一份本地镜像用于即时响应每次增删改请求成功后重新拉取最新购物车数据。购物车页面需要考虑几个交互细节每条商品记录前有勾选框控制该商品是否参与结算全选逻辑所有勾选框都选中时表头全选框自动勾选数量加减数量加一或减一时直接调用后端接口更新并重新计算小计和总价已选商品的总件数和总金额实时显示总金额的计算我在前端做但最终金额以后端下单接口计算为准前端传过来的总价仅作展示。这是安全规范防止有人恶意篡改前端价格。商品卡片和订单项这类重复UI我用Vue组件抽出来ProductCard.vue接收一个product对象渲染图片、名称、价格CartItem.vue接收购物车项和勾选状态向外抛出修改数量、删除、勾选等事件。组件化的好处是首页、搜索页、商品推荐位都能复用同一套卡片样式。5. 下单、支付与库存的完整链路商城系统里最核心、最容易出错的就是下单支付链路。一条完整链路是用户在购物车点击结算 - 后端创建订单同时扣库存 - 前端跳转支付页 - 模拟支付成功回调 - 后端更新订单状态。5.1 下单接口的核心逻辑下单接口的入参很简单{ addressId, items: [{productId, quantity}] }items来自购物车中勾选的商品。后端Service层做这几件事遍历items查询每个商品的当前价格和库存校验库存是否充足不足则抛出业务异常商品XXX库存不足计算订单总额用原子更新语句扣减库存影响行数为0则说明库存被抢光抛出异常插入订单主表拿到生成的订单ID批量插入订单明细表删除购物车中已下单的商品这里必须强调创建订单和扣库存必须放在同一个数据库事务里任何一个步骤失败全部回滚。SpringBoot里只需在Service方法上加Transactional注解但要注意事务方法不能内部自调用否则注解失效——这是一个非常经典的踩坑点。我在初版项目里就遇到过自调用导致库存扣减了但订单没创建成功数据不一致排查了很久才发现是事务代理没生效。此外下单时接收的收货地址应该由前端传地址ID后端根据用户ID和地址ID查询出最新的收货人信息写入订单的receiver_*字段而不是直接信任前端传来的收货人姓名和电话字符串防止伪造。5.2 模拟支付回调真实项目里支付要对接微信/支付宝支付网关流程复杂需要商户资质。学习项目通常用模拟支付用户点击去支付前端把orderNo传给后端一个接口后端直接完成支付成功的状态更新。我设计两个接口POST /api/pay/simulate处理模拟支付入参orderNo校验订单存在且状态为待支付状态置为已支付记录支付时间。返回结果为模拟支付页面需要的参数。这里同时模拟了支付回调在实际对接中支付平台是异步通知后端接口的后端收到通知后更新订单状态。模拟场景省掉异步把支付动作做成同步接口前端在模拟页面点击确认支付后调用看到支付成功就跳转订单列表。逻辑清晰也方便演示。模拟支付页面我做成一个独立路由/pay/:orderNo页面里展示订单金额和确认按钮点确认后调接口成功后展示支付成功并自动跳转订单列表页。演示效果完整且不需要任何第三方依赖。5.3 并发扣库存的思考我在第2章提到过用原子SQL扣库存这一节补充下单并发场景的代码逻辑。假设两个用户同时抢最后一个库存后端用int rows productMapper.deductStock(productId, quantity); if (rows 0) { throw new BusinessException(库存不足); }这条SQL执行时MySQL会对匹配到的行加锁InnoDB行锁第二个事务会等待第一个事务提交或回滚后才执行。第一单扣减成功并完成下单第二单执行时发现stock quantity不满足影响行数为0直接给用户返回库存不足。这就是数据库行锁天然解决超卖问题的机制。需要注意的是扣完库存后如果后续步骤抛异常导致事务回滚扣减语句也会一并回滚库存会恢复原值。这也是为什么下单全流程要放在一个事务里的原因。事务的原子性在这里就是用来保证要么订单和扣库都成功要么都失败。6. 联调部署阶段的坑与经验功能代码全部写完距离系统跑起来还差最后一步联调和部署。这一步最容易消磨耐心很多朋友的项目死于前后端联调时代码一堆跨域报错、数据库连不上、打包后访问空白。我整理几个高频问题。6.1 跨域与代理配置开发环境下前端运行在8080端口后端运行在8081或8080后端、5173前端前端请求后端的接口必然触发跨域。解决方案有两种我在项目里采用Vue开发服务器代理方式在vue.config.js中配置module.exports { devServer: { proxy: { /api: { target: http://localhost:8081, changeOrigin: true } } } }前端所有请求都走/api前缀Vue开发服务器把这个前缀的请求转发到后端地址浏览器看到的是同源请求从根源上规避跨域。这个方案的好处是不需要后端额外配置CORS生产环境也更容易统一处理。如果后端必须开启CORS比如移动端应用或独立域名调用可以在SpringBoot里加一个WebMvcConfigurer配置设置allowedOrigins、allowedMethods、allowedHeaders并允许携带凭证allowCredentials(true)。两种方案选一个即可不要同时开启导致冲突混乱。6.2 数据库连接与时区SpringBoot连接MySQL时application.yml里的连接串是重灾区。我推荐这样配置spring: datasource: url: jdbc:mysql://localhost:3306/mall_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse username: root password: 123456几个参数的含义characterEncodingutf8防止中文乱码serverTimezoneAsia/Shanghai指定数据库时区不配置时可能报Server time zone异常useSSLfalse本地开发不需要SSL加密连接另外如果Java后端和MySQL装在同一台机器localhost没问题如果MySQL跑在Docker容器里或远程服务器必须把localhost换成对应的容器名或服务器IP。这个坑我帮别人排查过多次现象是数据库连接超时查了半天才发现是地址写错了。6.3 打包部署顺序项目完成后交付时通常要求一键运行。我的打包部署流程是后端Maven执行mvn clean package -DskipTests打成Jar包运行java -jar mall.jar启动。前端执行npm run build生成dist静态目录里面是打包好的HTML/CSS/JS文件。部署静态资源最简单的方案是把dist目录扔进Nginxnginx配置中设置root指向dist目录接口请求通过Nginx反向代理到后端端口。Nginx配置片段server { listen 80; root /usr/share/nginx/html/mall; index index.html; location /api/ { proxy_pass http://127.0.0.1:8081; } location / { try_files $uri $uri/ /index.html; } }最后一行try_files很关键它解决了Vue Router的history模式刷新页面404问题。不写这一行用户访问/product/1时刷新就直接404因为Nginx找不到对应的物理文件try_files把这些前端路由统一回退到index.html由前端路由接管。如果只是本地演示不整Nginx也行前端打包后直接用npx serve -s dist起一个静态服务接口请求依然走代理或后端CORS。但正式交付的部署方案我个人还是建议用Nginx因为它同时处理了静态资源、反向代理和路由回退一套配置彻底解决生产环境问题。我的习惯是把后端接口统一加一个/api前缀这样Nginx里反代配置只需一行前端Axios的baseURL也写成/api联调和部署完全一致不会有本地能跑、部署后接口全挂的割裂感。最后再分享一条经验项目演示前一定要把清空数据库重新初始化的流程走一遍。商城管理系统通常要提交SQL初始化脚本我每次都是跑一遍完整流程执行建库建表SQL、启动后端、启动前端、注册一个用户、注册一个管理员、添加分类和商品、走一遍下单支付、再演示管理端发货。这条链路约10分钟但它能一次性暴露数据库脚本遗漏、初始数据缺失、接口顺序依赖等问题。这套系统从数据库设计到前后端联调基本把Java全栈开发的常见知识点都覆盖了。你照着这个思路做出来的项目无论是拿去面试还是做毕业展示都能讲清楚每一步设计的理由——而这恰恰是能跑通之外最加分的地方。