资讯动态

SpringBoot+Vue开发茶叶商城:商品建模、订单状态机与前后端联调全攻略

发布时间:2026/10/20 19:10:34 来源:尧图企业网站定制
做电商类管理系统的人应该都清楚这类题目在课程设计和练手项目里属于“熟面孔”。但茶叶商城这个选题和普通的标准商品进销存、图书管理这类系统不一样它光是商品模型就比常规电商复杂一块同一款茶叶可以按礼盒装、散称装、品鉴装分开定价茶叶本身还有年份、产地、工艺等级这些维度。如果再叠加购物车、订单流转、后台统计这些模块要处理的状态和边界条件会明显多一截。这篇文章我打算不按“学生作业汇报”那种方式写而是用实际开发中更容易上手的路径来拆解——从数据建模到后端接口设计再到前端页面和前后端联调把每一步的关键决策讲清楚顺便把容易踩的坑指出来。适合准备做毕业设计、想练手全栈项目的开发者以及工作一段时间但对电商类业务不太熟悉、想系统过一遍的朋友。1. 茶叶商品的特殊性为什么不能照搬通用电商模型很多人一上来就按通用电商那套建表商品表、分类表、SKU表、订单表完事。等真开始填茶叶数据马上会发现不对劲。茶叶商城的数据模型有几个绕不开的差异点。首先是茶叶的规格参数不是一个固定字段集合比如绿茶要记录采摘时间、发酵程度、产地区域普洱要额外处理山头、年份、生熟分类岩茶要看焙火程度。如果只在商品表里预留两三个字段后面扩展属性必然靠拼接字符串查询筛选和列表展示都会很别扭。其次是销售单元的问题。同一款正山小种可以按一盒 250 克卖也能按半斤散装卖还能出 8 克装的品鉴装。每种的库存、价格、拿货成本都不一样。这类多销售单元的建模如果继续用一张商品表存多个价格字段代码写起来会极度痛苦。然后是茶叶的“一茶多图”。商品主图、详情图、不同规格对应的场景图这是茶叶商城的刚需。商品和图片是一对多关系不能把图片地址逗号拼接存进商品表里后面轮播图、列表缩略图都会用到。我在实际建模时参考了电商行业比较通用的方案但做了针对茶叶场景的调整核心是四张主表goods存商品的公共信息——标题、简介、所属分类、品牌、产地、上架状态、月销量goods_sku对应具体的销售单元每个 SKU 有独立的规格名、价格、划线价、库存和条形码并用外键关联回商品表goods_spec存规格参数键值对比如采摘季节、海拔、外形再单独一张goods_gallery存图片地址和排序字段。CREATE TABLE goods ( id BIGINT PRIMARY KEY AUTO_INCREMENT, cate_id BIGINT NOT NULL COMMENT 所属分类 关联category表, title VARCHAR(200) NOT NULL, subtitle VARCHAR(255) DEFAULT , brand VARCHAR(50) DEFAULT , origin_place VARCHAR(100) DEFAULT COMMENT 产地, status TINYINT DEFAULT 1 COMMENT 1上架 0下架, month_sales INT DEFAULT 0, detail_html LONGTEXT COMMENT 富文本详情, create_time DATETIME, update_time DATETIME ); CREATE TABLE goods_sku ( id BIGINT PRIMARY KEY AUTO_INCREMENT, goods_id BIGINT NOT NULL, sku_name VARCHAR(100) NOT NULL COMMENT 如250g礼盒装, price DECIMAL(10, 2) NOT NULL, original_price DECIMAL(10, 2) DEFAULT 0, stock INT DEFAULT 0, sku_code VARCHAR(50) UNIQUE COMMENT SKU编码, image_url VARCHAR(255) DEFAULT );分类表也很关键。茶叶分类既要有大类红茶、绿茶、白茶、黑茶、乌龙茶又会往下分产地或工艺。我在category表里用parent_id做了二级分类查询时用递归方式把两级层级组装成树形结构。前端一级菜单展示大类二级菜单展示具体细分后台管理时通过前端组件级联选择实现成本低且灵活。这种表结构的好处是以后想加“普洱茶年份筛选”只需要在goods_spec里给普洱类商品补一个year字段记录不需要改动表结构查询时用 JSON 属性或者独立子表配合能轻松处理这类扩展需求。2. SpringBoot 后端的模块划分与核心接口设计后端我用的是 SpringBoot 2.7 搭配 MyBatis-Plus。选 MyBatis-Plus 而不是 JPA原因是这类带查询条件的列表接口比如茶叶名模糊搜索、价格区间、分类筛选以及分页统计需求太频繁了MyBatis-Plus 的LambdaQueryWrapper能少写大量 XML。项目按功能拆包不贪多但边界要清晰com.teashop ├── common // 统一返回结果、异常处理、工具类 ├── config // 跨域、拦截器、静态资源映射 ├── controller // 接口层 ├── service // 业务层 实现类 ├── mapper // 数据访问层 ├── entity // 数据库实体 ├── dto // 请求参数封装 └── vo // 响应视图对象接口上我说几个茶叶商城比较关键的也是面试或答辩时容易被追问的。2.1 商品列表接口的筛选逻辑商品列表是商城访问量最大的接口要支持关键词搜名称、分类 ID、价格区间、排序销量、价格、上架时间。如果用 QueryWrapper 硬拼条件一多明显别扭而且存在 SQL 注入风险。我的做法是定义一个GoodsQueryDTO在 Service 里按条件拼接。public PageResultGoodsVO pageGoods(GoodsQueryDTO dto) { LambdaQueryWrapperGoods wrapper new LambdaQueryWrapper(); // 关键词模糊匹配标题和副标题 if (StringUtils.hasText(dto.getKeyword())) { wrapper.and(w - w .like(Goods::getTitle, dto.getKeyword()) .or() .like(Goods::getSubtitle, dto.getKeyword()) ); } if (dto.getCateId() ! null) { wrapper.eq(Goods::getCateId, dto.getCateId()); } if (dto.getMinPrice() ! null) { wrapper.ge(Goods::getMinPrice, dto.getMinPrice()); } if (dto.getMaxPrice() ! null) { wrapper.le(Goods::getMaxPrice, dto.getMaxPrice()); } // 排序策略 if (sales.equals(dto.getSort())) { wrapper.orderByDesc(Goods::getMonthSales); } else if (price_asc.equals(dto.getSort())) { wrapper.orderByAsc(Goods::getMinPrice); } else { wrapper.orderByDesc(Goods::getCreateTime); } // 分页 PageGoods page goodsMapper.selectPage( new Page(dto.getPage(), dto.getSize()), wrapper ); // 转VO填充SKU列表和图片 }需要特别注意的是筛选价格不能用商品表的价格字段而应该用最小 SKU 价格来比较否则会出现商品价格 500 元500g 装但实际有 80 元 50g 品鉴装用户筛选 0-100 元时搜不到的情况。我是在goods_sku表聚合出min_price冗余到goods表查询性能比实时子查询好得多。2.2 购物车的合并处理购物车接口容易忽略“重复加购”的场景。用户已经把某个 SKU 加到购物车又点了一次这时候正确的行为是把数量加一而不是再插一条新记录。另外用户登录前后购物车如何合并也是移动端商城的标配需求。public void addCart(Long userId, Long skuId, Integer num) { CartItem exist cartMapper.selectOne(new LambdaQueryWrapperCartItem() .eq(CartItem::getUserId, userId) .eq(CartItem::getSkuId, skuId)); if (exist ! null) { exist.setNum(exist.getNum() num); cartMapper.updateById(exist); return; } CartItem item new CartItem(); item.setUserId(userId); item.setSkuId(skuId); item.setNum(num); item.setChecked(true); // 默认勾选 cartMapper.insert(item); }这里要顺带提一个我在项目里遇到的坑购物车数量修改接口一开始我没有做 SKU 库存上限校验用户可以把购物车数量改成 999等到提交订单才发现库存不足。正确做法是前端在加号点击时读取 SKU 当前库存做乐观校验后端在下单前再次严格校验双层防线缺一不可。2.3 订单状态机的设计订单状态如果用字符串随便存业务判断会写得很乱。茶叶商城的订单状态要比虚拟商品复杂一点因为涉及实物物流。我定义了以下状态枚举待付款用户创建订单后未支付待发货支付完成等待商家发货待收货商家已发货用户未确认已完成用户确认收货已取消用户支付前主动取消或超时未支付退款中 / 已退款售后链路状态扭转的合法性校验放在 Service 层我在OrderService里专门写了一个方法校验状态迁移private static final MapString, ListString ALLOWED_TRANSITIONS Map.ofOrderedEntries( Map.entry(PENDING_PAYMENT, List.of(PAID, CANCELLED)), Map.entry(PAID, List.of(SHIPPED, REFUNDING)), Map.entry(SHIPPED, List.of(COMPLETED, REFUNDING)), Map.entry(COMPLETED, List.of()), Map.entry(CANCELLED, List.of()), Map.entry(REFUNDING, List.of(REFUNDED)) );这个设计的价值在于所有银行回调、定时任务取消订单、用户主动操作最后都走同一个状态扭转方法非法扭转直接抛异常。前阵子有人问我为什么要在这种练手项目里做状态机我的回答是——订单模块是电商系统的资金出入口状态一旦错乱改数据的成本和责任都很大程序里把规则写死比靠人脑记靠谱得多。2.4 JWT 登录与拦截器用户身份用 JWT 方案登录成功签发一个有效期 7 天的 Token。因为商城需要判断当前用户是否登录购物车、订单但又不能写出“每个接口都先查一遍 Session”这种低效代码我用拦截器做统一处理。public class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(Authorization); if (!StringUtils.hasText(token)) { throw new BusinessException(401, 未登录); } UserInfo user jwtUtils.parseToken(token.replace(Bearer , )); if (user null) { throw new BusinessException(401, 登录已过期); } UserContext.set(user); // ThreadLocal 保存当前用户 return true; } }这里有两个经验点一个是白名单配置登录注册、商品列表、商品详情、分类列表这些公开接口不能让拦截器卡住另一个是通过ThreadLocal的UserContext传递当前用户比每次都从请求头解析 Token 再查数据库要干净很多也方便 Service 层拿到 “谁在操作” 这个上下文。3. Vue 前端页面结构与核心模块实现前端用的是 Vue 2 Element UI 的组合这个组合虽然新项目不太推荐了但做毕设或课程设计的同学如果熟悉npm run build那套构建流程风险最低。Vue 3 的 setup 语法虽好但对刚上手的人来说Vue 2 选项式 API 的思维模型更容易理解且 Element UI 组件齐全减少自己造轮子的成本。3.1 页面路由两级设计商城前台的路由我分成“商城端”和“管理端”两块。商城端面向 C 端顾客首页用/home茶叶列表页/goods商品详情页/goods/:id购物车/cart订单确认/checkout个人中心/user。管理端面向商家运营独立放在/admin下面包含商品管理、订单管理、分类管理、会员管理、数据统计。两级路由要分开是因为拦截判断不同。前台只有部分页面需要登录后台则所有页面都必须有管理员身份否则跳回首页。我用 Vue Router 的beforeEach导航守卫统一控制。router.beforeEach((to, from, next) { const auth localStorage.getItem(auth) if (to.path.startsWith(/admin)) { // 未登录或超管身份不符合直接回登录页 if (!auth || JSON.parse(auth).role ! ADMIN) { return next(/login) } } // 需要登录才能访问的页面 if (to.meta.requiresAuth !auth) { return next(/login?redirect to.fullPath) } next() })写这段的时候有个细节不要用path.startsWith(/admin)之外再做大量规则判断导航守卫本身就希望保持简单。如果你把云里雾里的权限判断全部堆进去后续加一个页面就要回来改守卫维护成本很高。3.2 商品列表页的下拉刷新与筛选交互列表页的筛选分类、价格排序通常伴随着“重置”和“搜索”两个操作。很多人直接在按钮里做条件清空然后重新请求接口但商品列表页有一个比筛选本身更关键的体验问题分页状态回传。用户翻到第 3 页点进详情返回后第 1 页还是第 3 页商品数据是否刷新我通过activated钩子加keep-alive解决了这个缓存问题。keep-alive router-view v-if$route.meta.keepAlive / /keep-alive商品列表组件内部查询参数对象我统一记在queryParams常量里切换分类或触底加载下一页时组件不销毁重建通过监听路由变化更新数据。3.3 购物车逻辑与结算页联动购物车前端最容易出 Bug 的是复选框的全选逻辑与选中金额汇总。全选框的选中状态不是简单checkedAll一个布尔值要靠所有 item 的 checked 状态动态计算金额汇总也要实时绑定到“被勾选项的 SKU 价格 × 数量”总和上而不是直接读取购物车总价字段。我习惯用 Vuex 管理购物车状态因为结算页和导航栏购物车角标都需要跨组件读取购物车数据。Action 里写fetchCart、addCart、updateCart、removeCart四个方法Mutations 只做状态同步服务端返回数据后更新本地仓库。computed: { selectedAmount() { return this.cartItems .filter(item item.checked) .reduce((total, item) { return total item.price * item.num }, 0) } }注意这里如果后端返回的商品价格是字符串类型前端计算时要用Number(item.price)做一次转换否则容易得到128.00 1这类字符串拼接的错误结果这个坑我实测踩到过。3.4 订单提交流程的幂等保护结算页点击“提交订单”最怕用户手抖连点了两次。两次提交请求如果都成功就会生成两笔相同订单。解决思路一般是后端生成一个下单请求号前端在按钮点击后立即禁用按钮并且携带这个唯一请求号后端用请求号做去重。// 下单请求号去重 public Long createOrder(Long userId, String requestNo, ListOrderItemDTO items) { if (orderNoCache.get(requestNo) ! null) { throw new BusinessException(500, 请勿重复提交订单); } orderNoCache.put(requestNo, userId); // 校验库存、计算金额、生成订单 }这个方案还能防“绕过前端校验、用脚本高强度刷下单请求”的恶意行为对毕设项目来说是一个很加分的细节。4. 前后端对接中的关键配置与典型问题排查代码写完只是第一步前后端能不能顺畅跑起来很多时候比写业务代码更磨人。我把联调阶段出现频率最高的问题集中说一遍。4.1 跨域问题的处理前端跑在http://localhost:8081后端跑在http://localhost:8080浏览器默认会拦截跨域请求。我用的是 SpringBoot 配置类方式统一处理而不是在 Nginx 里解决——因为开发环境下前后端分离联调时跨域必须在后端代码里先打通。Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }这里有一个细节很容易被忽略allowCredentials(true)和allowedOrigins(*)同时使用时会报错必须用allowedOriginPatterns(*)配合。很多新手卡在 “明明配了跨域但浏览器还是拦截” 的问题上原理就在这里。4.2 拦截后的 OPTIONS 预检请求如果你的后端加了 JWT 拦截器那么前端发起非简单请求比如带Content-Type: application/json的 POST时浏览器会先发一个OPTIONS预检请求。这个请求没有Authorization头如果拦截器直接放行判断容易返回 401 或抛异常前端表现为“请求被跨域拦截”。拦截器里要专门对OPTIONS方法放行if (OPTIONS.equalsIgnoreCase(request.getMethod())) { return true; }这个点虽然小但几乎每个做前后端分离、并且加了登录鉴权的项目都会撞上提前在拦截器里处理掉省得联调阶段反复怀疑人生。4.3 接口返回格式的统一设计前后端联调过程中另一个最常见的混乱来源是接口返回格式不统一。有的接口返回{code: 0, data: ...}有的接口直接返回data有的异常返回变成了一段 HTML 错误页导致前端拦截器里写了一大堆分支判断维护起来很痛苦。我建议项目一开始就统一封装一个ResultT响应体成功是{ code: 200, message: success, data: T }业务失败是{ code: 自定义编码, message: 具体原因, data: null }。异常统一由RestControllerAdvice捕获保证任何错误返回的结构一致。这样前端axios的response拦截器只需要处理code ! 200的情况统一弹出错误消息逻辑非常干净。4.4 图片上传与静态资源访问茶叶商品图片多我一开始把图片通过 MultipartFile 接口保存到本地磁盘但加载详情页时图片经常 404。排查后发现问题很初级SpringBoot 默认的静态资源路径并没有包含我保存图片的那个目录。需要配置虚拟路径映射Configuration public class WebConfig implements WebMvcConfigurer { Override public void addResourceHandlers(ResourceHandlerRegistry registry) { // 本地文件存储路径实际部署时可按需要调整 String uploadPath System.getProperty(user.dir) /upload/; registry.addResourceHandler(/upload/**) .addResourceLocations(file: uploadPath); } }前端组装图片地址时如果用相对路径/upload/xxx.jpg在当前域名下没问题。但如果图片 URL 需要给到别的服务建议存相对路径而非绝对地址前端根据当前域名拼接这样在本地跑和服务器部署时都能正确加载不会因为 IP 或端口变了出现图片全部挂掉的尴尬。5. 后台管理的几个关键实现商品上下架、订单处理与图表统计商城后台是给管理员或运营用的交互上没有商城端那么花哨但逻辑的严谨性要求更高。5.1 商品上下架与库存联动商品上下架不是一个简单的状态字段修改。下架操作要考虑商品是否已有待发货的订单如果有不允许直接下架防止履约矛盾。下架后购物车里该商品还有效吗有效但前端要展示“已下架”标记且结算时由后端校验拦截。某个 SKU 库存归零是下架整个商品还是只隐藏该 SKU我采取后者单独控制 SKU 的上下架状态。代码实现上我把“下架”作为一个带前置校验的事务操作而不是一个update语句完成。这个思路在答辩时说清楚会显得对业务理解有大局观。5.2 订单搜索与发货操作订单管理列表要支持按订单号、用户手机号、订单状态、时间范围筛选。这里我特意用手机号而不是用户 ID 来过滤是因为运营人员日常处理售后时用户通常只记得自己的手机号。这个细节看似无关紧要实际上反映了是不是做过真实业务——单靠订单号搜索在运营场景里非常难用。发货操作需要记录物流公司和物流单号同时把订单状态从 “已付款” 翻转为 “已发货”。用户确认收货后如果超过 7 天未主动确认系统应自动模拟确认我通过定时任务处理每天跑一次把超时未确认的订单批量更新为已完成。5.3 简单的数据看板茶叶商城的后台统计我实现了三个核心指标近 30 天销售额折线图、商品分类销售占比饼图、热门商品 Top10 排行榜。技术上用 ECharts 渲染后端提供聚合查询接口。SQL 需要注意的点是统计销售金额要排除已取消和退款订单否则数据明显虚高。SELECT DATE_FORMAT(create_time, %Y-%m-%d) AS day, SUM(pay_amount) AS total_amount FROM orders WHERE status IN (PAID, SHIPPED, COMPLETED) AND create_time DATE_SUB(CURDATE(), INTERVAL 30 DAY) GROUP BY day ORDER BY day;有一个真实遇到过的问题订单里同时存了商品总金额、运费、优惠金额、实付金额统计时如果只SUM(总金额)而没扣掉退款单看板数据会差很多。好的做法是先明确一个语义——“销售额 实付金额且订单处于非取消、非退款状态”所有统计都基于这个口径不混用字段。6. 安全与性能优化不该忽略的小细节很多练手项目能跑通就完了但我个人观点是哪怕只是一个课程设计安全刻度和性能意识也应该早一点养成。以下几条成本不高、收益明显。6.1 密码加密与登录频率限制绝对不要明文存密码最次也要用 BCrypt 加密后存储。Spring Security 的BCryptPasswordEncoder或者常见的加密工具包都可以。每次登录失败记录次数连续失败 5 次后锁定账号 15 分钟防止恶意撞库。6.2 后端参数校验所有前端传入的数据在后端 Service 入口必须校验。我统一使用Valid注解 NotNull、Min这类约束注解处理 DTO。反正记住一条原则前端校验是为了体验后端校验才是安全底线。库存数量、下单件数这些关键字段不能只靠前端组件配置一个max属性就放心了。6.3 Redis 缓存与热点数据商品详情页的访问量通常远大于后台接口而商品数据日内变动不大。我在项目里用 Redis 缓存商品详情数据缓存的 Key 是goods:detail:{id}查询接口先查缓存缓存没有则查库并回填设置 10 分钟过期。这种“缓存击穿”防御虽说不算特别深入但是一种很好的性能习惯。商城首页的分类列表、热门搜索词也可以走同样的缓存策略。6.4 防 XSS 与富文本过滤茶叶详情页允许商家输入富文本。如果直接把 HTML 内容原样渲染存在被插入恶意脚本的风险。前台展示时我做了 HTML 标签白名单过滤只保留p、img、h1~h4、strong、em等常用标签其余标签一律剥除。这个处理在管理端提交时进行比前端展示时再过滤要可靠得多。7. 从“能跑”到“能答辩能展示”我的一点建议第一个版本做出来后我强烈建议不要急着加功能先做一轮完整的质量审查。主要看三件事一是流程闭环。用户从注册登录→浏览商品→加入购物车→提交订单→后台发货→用户确认收货这一整条链路中间不能有任何断点。很多时候模块单独测都正常但一跑完整链路就会出现订单状态更新不到下一步、库存扣减和订单生成不在同一事务里这类问题。二是边界容错。商品刚好只剩 1 件两个用户同时下单怎么办用户把购买数量填成负数呢支付成功后订单状态还停留在待付款呢这些问题看起来极端但实际联调中非常容易出现。三是演示数据的质量。我实际做过一场演示发现商品图太糊、标题太随意演示效果大打折扣。建议商品数据的文案认真写图片提前压缩尽量让页面给人“这是一个正经商城”的感觉。答辩评委和面试官第一眼看的大多不是技术架构而是整体呈现。我在实际开发中还有一个小技巧多项目适用准备一份常见的问答清单把你做过的每一个模块比如“为什么要用 Redis”“购物车存 Redis 还是 MySQL”“订单状态为什么用枚举而不是字符串”都提前准备好回答的思路。看起来像是在为了答辩做准备实际上这些也是面试时最容易问到的关键点一举两得。总的来说这个茶叶商城系统的开发完成度能做到什么级别取决于你在商品建模、订单状态机、前后端交互细节这几个点上挖得多深。表面看它只是一个普通的 CRUD 项目但把这些细节处理好它就能变成一份既有业务思考、又有落地能力的作品。希望这篇文章能给正在做类似系统的人一些思路上的帮助少走几段弯路。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑