SpringBoot+Vue实战:免税优选商城系统设计与部署避坑指南
一直想找机会把基于 SpringBoot Vue 的免税商品优选购物商城管理系统完整整理一遍。这个项目是我近期完成的一个 Java 全栈实战项目后端采用 Java MySQL MyBatis前端使用 Vue核心目标很明确做一个免税商品场景下的优选购物商城。和普通商城系统不一样的地方在于免税场景对商品价格、税率、库存、订单状态都有更细的要求所以它既适合拿来学习全栈开发流程也适合作为课程设计或者简历项目去扩展。整个系统分两个角色普通用户和管理员。用户端提供商品列表、商品详情、购物车、收藏、下单结算、查看订单、个人中心这些操作管理端提供商品维护、分类维护、订单处理、用户管理、轮播图管理等功能。实际开发下来最大的感受是这类系统的难度不在单个功能而在各种状态之间的流转和边界情况的处理比如库存扣减、价格快照、订单超时未支付这些才是真正能体现项目质量的地方。如果你是刚开始接触 SpringBoot 项目或者想找一个前后端分离、能跑通完整业务的实战项目来参考这篇文章应该能帮你少走不少弯路。下面会完整包含数据库设计、后端接口实现、前端页面交互以及我在实际开发中踩过的坑。1. 项目缘起与整体设计思路1.1 做这个项目的目标是什么在做这个项目之前我其实先明确了一个问题普通的商城系统案例太多了如果只是把商品 CRUD、购物车加来加去做出来的东西很容易趋同对学习者来说价值不大。于是我把场景定到了“免税商品优选购物商城”。这个场景有几个特点商品本身有“免税价”和“原价”两个维度甚至还要区分不同的税率计算方式商品的分类要贴近实际比如化妆品、酒类、数码产品、食品等不同分类对应的展示规则和库存策略不完全一样用户对订单的“逻辑删除”与“物理删除”理解不能混这里更强调订单状态的变更管理端需要能对商品上下架、分类排序、订单发货进行管理而不是只做展示。基于这些特点系统的功能边界就清晰了前台面向购物用户后台面向运营人员中间用一套统一的接口去衔接。前台的侧重点是交互体验和购物流程顺畅后台的侧重点是数据准确性和操作效率。整套系统从零开始设计时我先把功能菜单画了一遍。用户端有首页、商品分类页、商品详情页、购物车、结算页、订单列表、订单详情、登录注册管理端有工作台概览、商品管理、分类管理、订单管理、用户管理、轮播图管理。功能清单确定之后前后端开发才能并行推进不然很容易做着做着发现某个关键页面缺失又回头补接口。1.2 技术栈选型时我为什么没有选老一套方案立项的时候身边很多朋友第一时间想到的是 SpringBoot MyBatis也有人问为什么不直接用 SSH 或者 JSP 那套老方案。这里我不想单纯评价老方案优劣只从实用角度说结论如果你要快速搭建、维护成本低、容易找到参考资料SpringBoot 是更合适的选择。具体几个原因SpringBoot 自动配置特性很强依赖管理比 SSH 时代清爽太多内嵌 Tomcat本地开发不用刻意打 war 包一个 main 方法就能启动项目配合 MyBatis 写 SQL 更直观尤其是涉及多表查询和统计报表的时候你能清楚知道 SQL 执行了什么排查问题也有据可查和 Vue 做前后端分离非常顺滑后端只提供 JSON 接口前端只负责渲染和交互。前端选择 Vue主要看重几个点组件化写法让商品卡片、分页、弹窗这些部分可以复用对状态的管理思路清晰适合购物车、用户登录状态这种全局数据另一方面会 Vue 的开发者数量也多无论后续自己扩展还是团队协作成本都低。数据库用 MySQL 比较稳妥成本低、生态成熟、云服务器部署也方便这套项目的数据量根本不会碰到瓶颈。如果希望把部分查询压力转移到缓存后续引入 Redis 也容易因为 Controller、Service、Mapper 是分层隔离的改造空间足够。2. 数据库设计免税商品场景下的表结构怎么搭2.1 核心表的拆分思路做商城系统很多人一开始容易把字段全堆在一张商品表里。一旦遇到分类调整、多图展示、订单记录就会非常难受。我这边把表拆分成了用户表、商品分类表、商品表、轮播图表、购物车表、订单表、订单明细表、用户地址表。关键关系商品分类和商品是一对多关系用户和购物车是一对多关系购物车表里用user_id product_id一起定位某条记录订单和订单明细是典型的一对多关系一张订单对多条明细用户和订单是一对多关系。这样拆完之后最直接的好处是商品详情页要展示分类名称、封面图、免税价的时候只需要根据商品 id 去关联查询即可下订单的时候把购买时的商品快照写入订单明细后续商品价格改了也不会影响历史订单。下面给出商品表的简化建表语句注意其中我把价格字段用 DECIMAL 来存而不是直接用 DOUBLECREATE TABLE product ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键ID, category_id BIGINT NOT NULL COMMENT 分类ID, title VARCHAR(200) NOT NULL COMMENT 商品名称, subtitle VARCHAR(500) DEFAULT NULL COMMENT 副标题, original_price DECIMAL(12,2) DEFAULT NULL COMMENT 原价, tax_free_price DECIMAL(12,2) DEFAULT NULL COMMENT 免税价, tax_rate DECIMAL(5,2) DEFAULT NULL COMMENT 税率/免税系数, stock INT NOT NULL DEFAULT 0 COMMENT 库存, cover VARCHAR(500) DEFAULT NULL COMMENT 封面图, images TEXT COMMENT 商品轮播图逗号分隔, status TINYINT NOT NULL DEFAULT 1 COMMENT 状态1上架0下架, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT商品表;你可能会问为什么不直接把价格算好存成一个字段。这是因为免税价可能会随着汇率或者税率规则变化存原始价格和税率字段方便后台调整商品时重新计算也方便追踪价格变化。实际项目中后台商品编辑页就是通过这两个字段联动计算前台展示价格的这个后面会讲到。2.2 价格精度、库存和状态字段的细节问题价格精度是个很容易踩坑的地方。Java 里面如果直接用 double 做金额计算优惠、税费、运费这些一叠加很容易出现 0.10.2 不等于 0.3 这类问题。所以在实体类和数据库层面都需要用 BigDecimal 对应 DECIMAL 类型这个约定从一开始就要定好中途再改会非常麻烦。订单表里要有一个订单编号字段一般用时间戳加随机数生成前端支付回调的时候也需要靠它来标识订单。订单状态我建议用 int 型用 0、1、2、3 这类值表示待付款、待发货、已完成、已取消等状态不要用中文因为后续扩展状态机、做定时关单处理都会更方便。订单明细表里不但要存商品标题和价格还要把当时下单的商品封面、规格、数量完整记录下来这样即使商品之后被后端删除用户仍然能在订单详情里看到当初买的什么。库存字段单独在商品表维护下单时扣减。要注意的是别在应用层里先查出库存再判断是否够用因为这种方式在高并发下容易出现超卖。应该用类似UPDATE product SET stock stock - #{num} WHERE id #{id} AND stock #{num}的方式去扣减受影响行数等于 0 说明库存不足再回滚事务。这一步在很多第一次写商城项目的人身上都会栽跟头后文故障部分我会专门展开。数据库索引这块也不要忽略。商品表的category_id、购物车表的user_id、订单表的user_id和order_no这些字段是高频查询条件建议都加上普通索引。否则数据量到了几万条之后分页查询和订单查询会出现明显的性能下降。3. 后端实现SpringBoot MyBatis 的落地细节3.1 包结构怎么组织才不显得乱后端工程我是按照常见的分层结构来组织的包名大致是这样com.example.dutyfree ├── config # 配置类拦截器、跨域、文件路径 ├── controller # 接口层 ├── service # 业务层 ├── mapper # MyBatis Mapper接口 ├── entity # 数据库实体 ├── dto # 请求和响应的数据传输对象 ├── common # 统一返回结构、异常处理、分页类 └── utils # 工具类Controller 层只负责接参、调用 service、返回结果Service 层处理业务逻辑Mapper 层只做数据访问。有人会嫌这种分层啰嗦但项目功能越多越能体会到分层的好处。比如改某个接口逻辑时只需要改 Service 层对应方法新增一个查询字段时Mapper 层 SQL 改动不影响 Controller。统一返回结构很重要。我用了类似Result(code, message, data)的结构前端通过 code 字段判断请求是否成功而不是直接依赖 HTTP 状态码。这能避免很多前后端联调时的不一致。例如登录接口返回 401 是 HTTP 层面的但业务上可能就是“账号密码错误”这时候 HTTP 状态码和业务码分开处理才清晰。同时我会写一个全局异常处理器把业务异常、参数校验异常、系统异常分别捕获转成统一的返回结构。这样用户看到的就不是一堆看不懂的异常堆栈而是一句明确的中文提示。3.2 商品列表、购物车、下单的核心实现商品列表接口是整个系统最常被调用的接口之一不要一上来就写死 SQL。考虑到前台要按分类筛选、按价格排序、按名称模糊搜索MyBatis 的 XML 里写一个动态 SQL 会方便得多大概思路是这样select idselectProductPage resultTypecom.example.dutyfree.entity.Product SELECT p.*, c.name AS categoryName FROM product p LEFT JOIN category c ON p.category_id c.id where if testkeyword ! null and keyword ! AND (p.title LIKE CONCAT(%, #{keyword}, %) OR p.subtitle LIKE CONCAT(%, #{keyword}, %)) /if if testcategoryId ! null AND p.category_id #{categoryId} /if AND p.status 1 /where ORDER BY choose when testsort price_ascp.tax_free_price ASC/when when testsort price_descp.tax_free_price DESC/when otherwisep.id DESC/otherwise /choose /select这种写法把筛选和排序控制在 SQL 层避免把全部商品拉到内存再排序。前端传递 keyword、categoryId、sort 参数即可分页可以使用 MyBatis 的分页插件不用手写 LIMIT 和 COUNT 两条 SQL省掉不少重复代码。购物车的核心逻辑其实不复杂但要考虑重复添加。用户把同一件商品再次放入购物车时正确的处理是数量累加而不是新增一条记录。我通常在购物车表上建一个user_id product_id的唯一索引插入时使用ON DUPLICATE KEY UPDATE quantity quantity 1这样既避免重复记录也减少了一次查询。下单是事务性最强的接口。创建订单时至少要做这几步检查商品是否存在且上架、检查库存、扣减库存、生成订单主表记录、生成订单明细、清空对应购物车记录。这里我强烈建议方法上加Transactional否则扣库存成功但订单生成失败时数据会不一致。如果一个接口同时涉及多个写操作先想清楚它们的依赖关系然后统一放在一个事务方法里。3.3 登录鉴权、角色控制是怎么做的商城系统至少有两类角色用户和管理员。学习阶段用 Session 也能实现但我更推荐用 JWT理由是和 Vue 前后端分离配合得好后端不用依赖 Cookie 和 Session前端保存 token 后每次请求带上即可。具体做法是用户登录成功后后端生成一个 token里面包含 userId 和角色信息并设置过期时间。后续请求通过一个拦截器校验 token解析出 userId 后放入请求上下文Controller 里就能直接拿到当前用户。管理端的接口需要额外校验角色比如只有 ADMIN 角色能调用商品添加、订单发货等接口。JWT 本身有它的特点不需要在服务器保存用户状态所以后台修改用户角色或者封禁用户时已发出的 token 短期内仍然有效。这个缺点在商城系统里通常可以接受如果追求更严谨可以把 token 信息存到 Redis 里做状态控制不过这个项目规模下我暂时没引入那么复杂的方案。前端这边还需要配合路由守卫。用户未登录时访问购物车、结算页、个人中心这些页面会被重定向到登录页。管理员访问管理后台之前也要检查当前用户是否有管理员身份否则拦截跳转。密码存储这里多说一句不要明文存数据库。程序里用 BCrypt 或者 MD5 加盐都行至少让密码不以明文形式出现在数据库里。默认密码也尽量每个人都不同初版可以统一但正式使用时要提醒用户修改。4. 前端实现Vue 商城页面的核心交互4.1 页面模块划分和组件复用前端部分我分为用户端和管理端两大块。用户端包含首页、商品列表、商品详情、购物车、结算、我的订单、登录注册等页面管理端包含商品管理、分类管理、订单管理、用户管理、轮播图管理等页面。写 Vue 最忌讳的是把所有逻辑堆在一个大组件里。首页的商品卡片、分页组件、数量选择器、图片懒加载这些我都会拆成独立组件。比如商品卡片只在首页和商品列表页复用了两三次就能节省不少重复代码。数量选择器在购物车页和结算预览页都用得上拆出来之后只需要传一个 quantity 值再加一个 change 事件使用方就能安心处理自己的业务逻辑。路由层面也是一样用户端和管理端可以通过路由守卫做权限分流。如果不想把所有东西混在同一个项目里也可以管理端单独拆成一个子应用只是会加大前期构建成本。对这个项目来说一个 Vue 工程内部分两个路由模块是最效率的做法。组件拆分时要注意命名和职责。抽屉、弹窗、空状态这类通用组件统一放在公共组件目录里样式也尽量统一。商品卡片这种业务组件放在对应页面目录下或单独的业务组件目录里通过 props 传入商品数据事件向外抛出点击行为保持组件自身的纯粹性。4.2 商品列表、购物车和结算的交互实现商品列表页一进入就请求分页接口筛选条件变化时重新请求。前端要处理好分页状态比如当前页码、每页条数、总条数确保切页之后还能保留筛选条件。我习惯把查询参数放在同一个响应式对象里切换分类时页码自动重置为 1这样“分类-排序-分页”之间的联动不会乱。购物车页面要实时计算总价。因为价格来自后端前端拿到购物车列表后会遍历所有商品把每一项的“数量×免税价”累加。计算过程可以放到计算属性里这样修改数量、删除商品时总价自动刷新不需要手动去更新视图。这里有一个细节单价要以后端返回的免税价为准不要在页面上自己拼价格防止和订单金额对不上。结算页面比购物车更复杂一点。这里需要展示默认地址、商品清单、金额明细还需要支持修改地址。前端在提交订单前可以先做一个本地校验比如购物车为空不能提交、没有地址不能提交。提交动作调后端的下单接口成功后跳转到订单详情页。后端只要保证订单生成成功前端再做一次刷新确认避免用户重复点击导致生成多个订单。商品详情页还有一个容易被忽略的点商品轮播图、产品参数、用户评价这些内容要按模块加载。如果所有数据都放在一个接口里返回接口会越来越重图片多的时候首屏会很慢。我这边把详情基础数据和图文详情拆开详情页先渲染基础信息图文详情通过滚动懒加载体验会好很多。4.3 接口联调时容易出问题的几个点前后端分离项目在联调阶段会有几个高频问题。第一个是跨域本地前端在 8080 端口后端在 8081 端口直接发起请求会被浏览器拦截。解决方式有几种后端配置 CORS、前端开发环境使用代理、上线后通过 Nginx 反向代理。我最后选择的是开发环境用代理生产环境用 Nginx 统一转发这样后端代码里不需要写死跨域配置更干净。第二个问题是接口返回结构不统一。如果有的接口直接返回数组有的接口返回对象前端每个请求都要单独处理很容易出错。我要求所有接口都走统一的 Result 结构即使没有数据也要返回空数组或者空对象这样前端的 axios 响应拦截器才能统一判断。第三个问题是图片路径。商城系统的图片如果直接用本地路径存储打包部署后经常因为路径问题显示不出来。建议图片统一存到服务器的固定目录然后通过一个映射路径对外访问。开发环境可以直接映射到本地区域生产环境交给 Nginx 处理。第四个问题是日期格式。Java 后端返回的时间字段默认格式可能带有时区或者毫秒前端直接显示会出现“2025-01-01T00:00:00.00008:00”这种样式。建议后端统一配置全局的 JSON 序列化规则把 LocalDateTime 格式化为yyyy-MM-dd HH:mm:ss前端拿到之后直接展示省得每个页面单独转换。5. 典型问题与排查实录5.1 列表查询时为什么会出现商品重复第一个项目测试阶段我发现在分类列表页面偶尔出现商品重复显示。排查下来问题出在 MyBatis 的一对多映射上我写查询时用了 JOIN 去同时查询分类名称结果分类表里出现多级子分类后一条商品记录因为关联条件写得太宽被查出来多条。后来处理方案是拆成两步第一步查询商品主数据第二步根据分类 id 集合一次性查出分类名称并装配到内存里。或者如果必须要用单条 SQL也要仔细写 resultMap 的 collection 映射不能偷懒用简单 resultType 去接收。排查这类问题有个小技巧先在数据库客户端里直接跑 SQL看返回的原始行数再用 MyBatis 的日志输出实际执行的 SQL比对两者结果。如果数据库查出来是正常的那问题基本就出在映射配置上。5.2 购物车并发提交导致订单和超卖刚才提到的库存扣减问题在我第一次测试并发下单时就暴露了。两个用户同时购买同一件商品商品库存只剩 1 件结果生成了 2 个成功订单。原因就是我先查询库存再扣减查询到的都是 1然后都去扣减库存变成 -1。修复方式就是前面说的在 SQL 层面使用条件更新UPDATE product SET stock stock - 1 WHERE id #{id} AND stock 1如果数据库受影响行数是 0说明库存不足订单创建失败。再加上事务控制订单生成和库存扣减要么都成功要么都失败。这个修复上线后我再跑并发脚本验证库存和订单数据就能对上了。订单重复提交的问题前端需要加防重复点击后端也需要根据 userId 和订单创建时间做一个幂等校验防止同一个购物车清单被提交两次。5.3 打包部署后接口 404 和前端资源白屏前后端分离部署时最常遇到的坑是后端接口能访问但前端页面刷不到或者前端页面能打开但接口全部 404。这里要理清两个服务的端口和转发关系。常见做法是把打包好的 Vue 静态资源交给 Nginx 托管Nginx 监听 80 端口再把 /api 路径转发到后端 SpringBoot 的 8081 端口。如果后端接口返回 404先看 Nginx 转发规则是否正确再看 SpringBoot 的上下文路径是否带前缀。如果因为前端使用了 history 模式路由刷新页面时 Nginx 还要配置 try_files否则会出现用户访问某个子路由时刷新即 404 的情况。后端打包时还要注意 MyBatis 的 XML 文件是否被打进 classes 目录。很多人第一次用 SpringBoot MyBatis 时习惯把 Mapper XML 放在 src/main/java 目录下结果打出来的 jar 包没有 xml运行时就报Invalid bound statement (not found)。解决方法是把 xml 放在 src/main/resources/mapper 目录下或者在 pom 文件里把 xml 文件也包含到构建资源中。5.4 图片上传后访问不了商城系统一定会涉及图片上传。我最初把图片上传后的路径直接拼接成D:/upload/xxx.jpg存到数据库结果前端访问的时候浏览器里输入这个绝对路径当然访问不了。后来我统一改成数据库只存相对路径/images/商品id/封面.jpg后端写一个静态资源映射配置把/images/**映射到服务器磁盘目录部署到生产环境后再由 Nginx 直接托管这个图片目录。这样开发环境和生产环境只需要改配置不用改代码图片路径问题就彻底解决了。6. 项目扩展方向与经验心得6.1 从这个项目走向生产级还需要补什么如果把这套系统拿到真实业务场景有几个地方我建议优先补。第一是缓存。商品列表和商品详情的高频查询可以先查 Redis缓存未命中再查数据库同时要处理商品修改和上下架时的缓存失效逻辑。第二是定时任务。比如订单超时未支付自动关闭、库存恢复、优惠活动到期下架这些用 SpringBoot 的 Scheduled 就能实现但需要把业务状态流转设计清楚尤其是订单关闭之后库存怎么回补一定要想明白。第三是支付对接。真正的商城系统肯定要接入第三方支付支付回调要保证幂等。虽然这个项目没有真实接入支付但我在下单接口上已经预留了支付回调的处理逻辑后续接起来可以直接复用订单号作为幂等键。第四是日志和监控。至少要记录请求日志、异常日志和用户关键操作日志否则线上出了问题很难回溯。管理端的操作日志很多人会忽略但商品删除、订单改价这类高风险操作一旦出问题有日志就能快速定位责任人。6.2 个人实际开发中的几个习惯这套系统开发下来我自己最大的体会是不要把精力全花在“写新功能”上要留时间做数据校验和边界测试。比如商品价格不能为负数、库存不能手动改成负数、分类删除前要检查是否还有商品关联这些校验看着不起眼但恰恰是项目质量的关键。另外我还养成了一个习惯每个接口都自己先用工具测一遍模拟前端可能传的参数异常。比如页码传 0、数量传负数、token 传空这些情况后端都要有合理的返回而不是直接抛 500。很多同学做项目时只验证正常流程交上去演示时一个异常参数就让页面崩溃这个印象会大打折扣。最后再补一句源码虽然是完整的但拿到手的项目不等于自己掌握的代码。建议在自己电脑上重新创建数据库、按照文档搭建环境然后把核心功能自己动手写一遍。我第一次看别人商城项目也是照着抄后来改成自己一步一步实现全栈开发里的很多细节才真正入脑。