☰
基于Spring Boot+Node.js+Vue的校园小卖部零食商城系统设计与实现
2026/10/14 6:05:32 网站建设 项目流程

做校园小卖部零食购物商城这类系统,最容易犯的错误是把普通电商平台原样搬过来。真正站在小卖部门口观察小半天就会发现,它的核心痛点不是商品展示不够精美,而是怎么用有限的人力消化掉高频小额订单:课间十分钟可能涌来几十个学生,晚自习结束又有一个小高峰,打烊之后还有人想买泡面和饮料。这套基于 springboot-nodejs 后端组合、vue 前端的校园在线零食商城,就是冲着这个场景去的——学生线上下单,小卖部按宿舍楼配送或者到店自提,店主在后台管商品、接单、核销。这篇文章我会把整个系统的需求拆解、技术选型、数据库设计、接口实现、辅助服务以及部署踩坑完整整理出来,适合正在做校园电商、商城类课程设计或个人项目的读者直接参考。

1. 立项前先算清账:小卖部商城和普通电商差在哪

1.1 从排队场景反推系统边界

不管页面做得多好看,如果订单进不来、处理不掉,一切都是白搭。我在梳理需求时先画了一张“校园时间线”:早晨 7:30 到 8:00 是早餐流,上午大课间是辣条、饮料流,午饭后是水果、酸奶流,晚自习后是泡面、薯片流。每个高峰持续十分钟左右,收银台前排一长串,上课铃一响人群就散掉,该买的没买上,第二天同一拨人又来了。

所以系统核心目标是“错峰”。把点单动作从线下移到线上,小卖部只需要在高峰前把预包装好的订单准备好,学生到店报订单号取货,或者由勤工俭学的同学按楼栋配送。第二个目标是“延长营业时间”。小卖部九点半关门,但宿舍楼十一点还灯火通明,在线商城完全可以挂单,第二天店主一开门就能看到一批待处理订单。第三个目标才是大家常说的“数字化管理”:哪些零食卖得快、哪些积压在货架最底层,不能只靠老板拍脑袋。

这三个目标直接决定了系统边界。与普通电商不同,这套系统不需要复杂的推荐引擎,不需要多级分销,不需要会员成长体系,但必须把“订单状态可感知”和“库存不超卖”这两件事做到极致。

1.2 角色与核心流程拆解

整个系统我拆成三个角色:

  • 学生:注册登录、浏览零食、加购、下单、支付、查看订单状态、确认收货。
  • 店主/管理员:商品上下架、改库存、处理订单、配送或核销、看销售统计。
  • 系统后台:用户、订单、库存在这套体系里紧密联动,所有状态变化都要有记录。

核心流程可以归纳成一条主线:选品 → 购物车 → 提交订单 → 支付 → 备货 → 配送/自提 → 完成。另外还有一条支线是“超时未支付自动取消”,这条支线在校园场景里非常重要,因为学生下单后可能被课拽走,一节课四十五分钟过去,订单还躺在待支付状态,库存却一直被占着。

和普通电商相比,校园场景有两个明显的差异化需求。第一,配送信息不再是详细门牌号,而是“宿舍楼 + 自提点”这种粗粒度地址,配送员不需要知道具体房间号,学生在楼下取就行。第二,订单状态里必须体现“备货中”这个线下动作,让学生能判断什么时候可以去拿,而不是干等一个“待发货”的抽象概念。

1.3 哪些功能我主动砍掉了

一开始店主拿着某大型电商App截图跟我说,就要这个效果。但实际评估后我砍掉了很多功能:不做复杂的会员等级体系,不做凑单推荐算法,不做多级分销,甚至连传统电商购物车的“过期自动清空”机制都没有完整保留。原因很简单——小卖部客单价低、复购高、走量,学生买一包辣条可能就三五块钱,任何增加下单步骤的设计都是在流失订单。

最后保留的核心功能只有五个:快速浏览、快速加购、极简下单、清晰状态、后台能处理异常订单。管理后台倒是多留了一些脑子,比如库存预警:某商品库存低于阈值时在后台列表里标红,这个功能虽然简单,但对小卖部老板来说比任何炫酷图表都实用。

2. 技术栈分工:Spring Boot 主后端、Node.js 辅助服务、Vue 前端的配合逻辑

2.1 标题里为什么有两个后端关键词

项目名写着 springboot-nodejs,初次接手的人多半会疑惑:到底用哪个做后端?实际项目里我采用的是“一主一辅”结构:Spring Boot 作为核心业务后端,负责用户、商品、购物车、订单、库存这些强事务、强一致性的模块;Node.js 作为辅助服务,负责订单超时扫描、WebSocket 实时消息推送这一类“轻量但频繁”的工作。这样设计的好处是各用所长,Java 处理事务和并发可靠,Node 处理长连接和定时任务方便快速。

如果你做课程设计或者个人项目,也可以按自己的情况调整:要么全部用 Spring Boot,把定时任务用 @Scheduled 实现、实时通知用 SSE 实现;要么干脆全部用 Node.js,用 Express 或 NestJS 写接口。我选择双后端混合不是刻意炫技,核心原因有两个:一是想验证“辅助服务独立部署”这个架构思路在中小型项目里的可行性,二是 Node 的 WebSocket 生态确实让店主端实时订单提醒省了很多事。

2.2 Spring Boot 承担的核心业务模块

这套系统里 Spring Boot 主要承担五块:

  • 用户模块:注册、登录、JWT 签发与校验、学生/管理员角色区分。
  • 商品模块:分类管理、商品 CRUD、上下架、库存字段维护。
  • 购物车模块:加购、改数量、勾选、批量删除。
  • 订单模块:提交订单、预扣库存、支付回调、取消订单、订单查询。
  • 统计模块:销量排行、营业额趋势、库存预警。

选择 Spring Boot 的原因很实际:下单流程涉及多张表写入,需要数据库事务;库存扣减要处理并发;管理端的一些统计查询 SQL 逻辑不短,Java 配合 MyBatis 比较好维护。我这次用的是 Spring Boot 3 + JDK 17,稳定性和社区资料都足够,遇到问题搜一圈基本都有答案。

2.3 Node.js 服务到底干了什么活

很多同学会误以为 Node.js 在这里是抢 Spring Boot 饭碗的,其实不是。下面是我分配给 Node 服务的三个具体任务:

  1. 订单超时扫描:每隔一段时间扫描状态为“待支付”且超过支付时限的订单,调用 Spring Boot 的取消接口,释放被锁定的库存。
  2. WebSocket 推送:维持一个和前端的长连接池。学生下单成功后,店主端网页立刻弹出新订单提示;订单状态变化时,学生端也立刻更新,不用一遍遍刷新页面。
  3. 通知消息组装:把订单变化信息拼成 JSON 推给前端,本质上是一个轻量消息通道。

这三点如果用 Spring Boot 做也能做,但定时任务和 WebSocket 的代码写起来比较繁琐,而且会和其他业务接口挤占线程资源。拆出去之后,主服务只管业务,辅助服务只管“跑腿”,日志和故障定位都更清晰。

2.4 Vue 前端的两端布局

前端我用 Vue 3 + Vite + Pinia + Vue Router,UI 组件库选的是 Element Plus。整个项目分成两个区域:面向学生的商城端和面向店主的后台管理端。同一个仓库里通过路由区分,公共部分比如 axios 封装、登录态管理、WebSocket 连接逻辑都抽成公共模块。商城端以移动端优先设计,因为学生大概率用手机访问;后台管理端用桌面布局,商品表格、订单筛选、统计图表都直接铺开。

Vue 选型的理由不复杂:组件化开发对商城页面这种“首页、列表、详情、购物车”结构很自然;生态完善,Element Plus 直接省了后台一大堆表单和表格组件;Pinia 管理购物车数量和登录状态比单纯 localStorage 更可控。项目里没有引入太重的 UI 框架,移动端页面基本是轻量自定义样式,减少了首屏加载压力。

3. 数据库与订单状态:商城核心表怎么设计才不返工

3.1 商品、分类、购物车三张基础表

先从一个非常常见的失误说起:很多初学者把商品表设计成“所有字段堆一起”,价格用 DOUBLE,库存用 INT 且不做并发控制,最后统计价格出现 15.999999 这种精度问题,库存也经常扣成负数。价格字段必须用 DECIMAL(10,2),库存扣减要在 SQL 层做条件更新,这个放到后面接口章节细说。

分类表结构非常简单,只负责分组展示:

CREATE TABLE `category` ( `id` bigint NOT NULL AUTO_INCREMENT, `name` varchar(50) NOT NULL, `sort` int DEFAULT 0, `status` tinyint DEFAULT 1, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

商品表是节点最多的表,字段设计直接决定后续接口好不好写:

CREATE TABLE `product` ( `id` bigint NOT NULL AUTO_INCREMENT, `category_id` bigint NOT NULL, `name` varchar(100) NOT NULL, `image` varchar(255) DEFAULT '', `price` decimal(10,2) NOT NULL, `stock` int NOT NULL DEFAULT 0, `sales` int NOT NULL DEFAULT 0, `status` tinyint DEFAULT 1, `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_category` (`category_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

购物车表的字段不多,但有一个容易纠结的点:要不要冗余商品名称和价格。我在本项目里购物车只存 product_id 和 quantity,展示时实时联表查商品信息。原因很简单——小卖部商品调价频繁,购物车里的旧价格没有意义,学生最终以订单快照价格为准,购物车只是一个待结算的暂存区。

CREATE TABLE `cart` ( `id` bigint NOT NULL AUTO_INCREMENT, `user_id` bigint NOT NULL, `product_id` bigint NOT NULL, `quantity` int NOT NULL DEFAULT 1, `checked` tinyint DEFAULT 1, `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_user` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

3.2 订单主表与订单明细:事务一致性的根基

订单体系是这套系统的命脉。主表保存一次下单的整体信息,明细表保存这次订单里每一件商品的下单快照。为什么明细要存快照?因为商品表的价格和名称随时可能被店主修改,订单生成后不能跟着变,否则对账就乱套。

订单主表:

CREATE TABLE `orders` ( `id` bigint NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL, `user_id` bigint NOT NULL, `status` tinyint NOT NULL DEFAULT 10, `total_amount` decimal(10,2) NOT NULL, `discount_amount` decimal(10,2) DEFAULT 0.00, `pay_amount` decimal(10,2) NOT NULL, `delivery_type` tinyint DEFAULT 1, `dorm_building` varchar(20) DEFAULT '', `pickup_point` varchar(50) DEFAULT '', `remark` varchar(255) DEFAULT '', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, `pay_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

订单明细表:

CREATE TABLE `order_item` ( `id` bigint NOT NULL AUTO_INCREMENT, `order_id` bigint NOT NULL, `product_id` bigint NOT NULL, `product_name` varchar(100) NOT NULL, `product_price` decimal(10,2) NOT NULL, `quantity` int NOT NULL, `total_price` decimal(10,2) NOT NULL, PRIMARY KEY (`id`), KEY `idx_order` (`order_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

两张表通过 order_id 关联,生成订单时在一个事务里同时写入。这里有一个所有电商项目都逃不开的问题:订单号怎么生成?我用的是“日期时间 + 用户ID后四位 + 随机数”拼出的 32 位以内字符串,并发量不高时完全够用,不需要引入雪花算法这种重型方案。订单号加唯一索引,还给后面的幂等处理留了后路,同一笔订单不会因为网络重试被创建两次。

3.3 订单状态机与超时回收

订单状态我设计成六种,用 tinyint 存储,逻辑清晰且方便扩展:

状态代码含义下一步动作
待支付10已下单未付款支付或超时取消
已支付20付款成功店主开始备货
备货中30店主正在打包打包完成转配送
配送中40已送达或自提点待取学生确认收货
已完成50交易完成无
已取消90超时或主动取消释放库存

这里有一个新手项目里最容易漏掉的逻辑:库存扣减到底发生在哪一步。我的做法是“下单即预扣库存”,也就是创建订单时就把商品库存减掉,支付超时再释放;而不是等支付成功才扣。因为校园高峰时段同样一包薯片可能被多个人同时加购,如果支付成功才扣库存,最后一定能超卖。预扣之后库存字段会短暂减少,未支付订单占着库存,在小卖部低并发场景完全合理,等超时任务把它们释放掉就行。

状态流转的规则不复杂,但必须每次操作都校验前置状态。比如取消接口只允许从“待支付”流转到“已取消”,如果订单已经是“配送中”,取消请求就必须被拒绝。我在代码里用一个简单的状态机校验方法统一处理,避免出现“已完成的订单还能被取消”这种低级事故。

4. Spring Boot 接口实现:登录鉴权、商品分页、下单事务的代码细节

4.1 统一返回体、全局异常与 JWT 鉴权

前后端分离项目最怕接口各写各的返回结构,前端接一个崩一个。我先定义统一返回体 Result ,里面固定放 code、message、data 三个字段,code 为 0 表示成功,非 0 表示业务错误码。所有 Controller 方法都返回 Result,全局异常处理器把 Service 层抛出的业务异常统一拦截并转换成 Result 返回,前端拿到非 0 code 就直接弹提示,不用针对每个接口单独写错误处理。

一个简化版的统一返回体写法:

public class Result<T> { private int code; private String message; private T data; public static <T> Result<T> ok(T data) { Result<T> r = new Result<>(); r.setCode(0); r.setMessage("success"); r.setData(data); return r; } public static <T> Result<T> fail(int code, String message) { Result<T> r = new Result<>(); r.setCode(code); r.setMessage(message); return r; } }

登录鉴权用 JWT:用户登录成功后,服务端签发 token,里面放 userId 和 role,前端每次请求在 Header 里带 Authorization。拦截器放行登录接口,其余接口校验 token 有效性和角色权限。后台管理路径 /admin/** 只允许 admin 角色访问,学生接口只允许登录用户访问。这里有个值得注意的细节:JWT 是无状态的,一旦签发没法立即吊销,所以后台管理端我加了一个简单的 token 版本号机制,店主修改密码后版本号变化,旧 token 全部失效。

4.2 下单接口的事务处理与防超卖 SQL

下单接口是整个后端编码量最大的一块。我按下面这个顺序来写,顺序不能乱:先校验参数,再校验商品可售状态和库存,然后计算金额,生成订单主表,生成明细列表,最后统一提交事务。金额计算必须放在后端,前端传过来的总价只能当参考值,否则改请求参数就能“一块钱买一箱薯片”。

用代码表达核心逻辑:

@Transactional public OrderVO createOrder(Long userId, List<CartItem> items) { List<OrderItem> detailList = new ArrayList<>(); BigDecimal totalAmount = BigDecimal.ZERO; for (CartItem item : items) { Product product = productMapper.selectById(item.getProductId()); if (product == null || product.getStatus() != 1) { throw new BizException("商品不存在或已下架"); } // 条件更新:库存充足才扣减,防止超卖 int rows = productMapper.deductStock(product.getId(), item.getQuantity()); if (rows == 0) { throw new BizException("商品库存不足:" + product.getName()); } OrderItem detail = new OrderItem(); detail.setProductId(product.getId()); detail.setProductName(product.getName()); detail.setProductPrice(product.getPrice()); detail.setQuantity(item.getQuantity()); detail.setTotalPrice(product.getPrice().multiply(BigDecimal.valueOf(item.getQuantity()))); totalAmount = totalAmount.add(detail.getTotalPrice()); detailList.add(detail); } // 满减优惠:后端统一计算,前端不参与决策 BigDecimal discount = calcDiscount(totalAmount); BigDecimal payAmount = totalAmount.subtract(discount); Orders order = new Orders(); order.setOrderNo(generateOrderNo()); order.setUserId(userId); order.setTotalAmount(totalAmount); order.setDiscountAmount(discount); order.setPayAmount(payAmount); order.setStatus(10); orderMapper.insert(order); for (OrderItem detail : detailList) { detail.setOrderId(order.getId()); orderItemMapper.insert(detail); } return buildVO(order); }

deductStock 的 SQL 是防超卖的灵魂:

UPDATE product SET stock = stock - #{quantity}, sales = sales + #{quantity} WHERE id = #{productId} AND stock >= #{quantity}

受影响行数为 0 就说明库存不足,直接抛异常让整个事务回滚。这个方法在低并发场景下足够可靠,不用引入分布式锁。如果订单量级非常大,再考虑把库存预扣改为 Redis 原子扣减,但校园小卖部的单量显然还没到那个程度。我用事务包住整个循环,意味着任何一个商品库存不足,前面已经扣掉的其他商品库存都会一并回滚,订单不会出现“只扣了部分库存”的尴尬局面。

4.3 商品分页与条件查询的实践做法

商城首页、分类页、搜索页都需要分页拉商品列表。我用 MyBatis-Plus 的分页插件,配合一个查询参数对象:

public PageResult<ProductVO> pageProduct(ProductQuery query) { LambdaQueryWrapper<Product> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(query.getCategoryId() != null, Product::getCategoryId, query.getCategoryId()); wrapper.eq(query.getStatus() != null, Product::getStatus, query.getStatus()); wrapper.like(StringUtils.hasText(query.getKeyword()), Product::getName, query.getKeyword()); wrapper.orderByDesc(Product::getSales); Page<Product> page = new Page<>(query.getPageNum(), query.getPageSize()); productMapper.selectPage(page, wrapper); // 转换为VO,填充分类名称、格式化时间等前端需要的字段 return convertToPageResult(page); }

这里要强调一个实践原则:不要把数据库实体直接返回给前端。实体字段可能包含不该暴露的内部字段,而且对字段改动毫无抵抗能力。我在项目里单独维护 ProductVO,将创建时间格式化好再返回,前端拿到的就是可以直接渲染的结构。商品列表排序我用销量倒序为主,新品上架时间倒序为辅,这个排序能保证爆款商品永远排在前面,符合小卖部的实际经营逻辑。

5. Node.js 辅助服务落地:定时取消订单和实时推送到底怎么写

5.1 订单超时自动取消:轮询方案与幂等处理

订单超时处理是保证库存准确的必要环节。方案有两种:一种是在 Spring Boot 里用 @Scheduled 每 30 秒扫一次待支付订单,另一种是 Node 服务端做同样的事。我把这件事放到 Node 服务里做,逻辑核心就是“扫描超时单 → 调用取消接口 → 释放库存”。

我最终采用了轮询方案:Node 服务每 30 秒查一次数据库里的订单表,找出 status=10 且 create_time 小于当前时间减去支付时限的订单,逐单调用 Spring Boot 的 cancelOrder 接口。接口内部判断订单是否还是待支付状态,是则改为已取消并回补库存,不是则忽略。

这里必须强调一个幂等思想:Node 扫描到一批单,如果第一次调用取消接口超时了,第二轮又扫到同一单,cancelOrder 接口必须能“重复调用也无害”,否则就会重复释放库存。回补库存的 SQL 同样要走条件更新,并且要以订单明细里的数量为准,不能拿前端传参当依据:

@Transactional public void cancelOrder(String orderNo) { Orders order = orderMapper.selectByOrderNo(orderNo); if (order == null || order.getStatus() != 10) { return; // 不满足取消条件,静默返回 } orderMapper.updateStatus(order.getId(), 90); List<OrderItem> items = orderItemMapper.selectByOrderId(order.getId()); for (OrderItem item : items) { productMapper.incrStock(item.getProductId(), item.getQuantity()); } }

5.2 WebSocket 实时消息通道的搭法与解耦

店主端最想要的效果就是“新订单来了不要我再刷新页面”。我在 Node 服务上用 ws 模块搭了一个轻量 WebSocket 服务,前端连接后把 userId 作为身份标识。学生下单成功时,Spring Boot 在订单创建事务成功后,向 Redis 的频道发布一条消息,Node 订阅这个频道,拿到消息后从连接池里找到对应用户的连接并推送过去。

这样做的好处是解耦:Spring Boot 只负责“发完消息就走”,不需要知道哪个前端在线、用什么协议推送;Node 端则完全不参与业务数据计算,只负责把上游发来的消息交给对应连接。如果某些业务不需要实时推送,Spring Boot 完全可以不感知 Node 的存在。后续换推送通道,比如接入微信公众号通知,只要在 Node 端加一个适配器就行,核心业务代码一行都不用改。

前端这一侧,我在 Vue 项目里封装了一个简单的 WebSocket 模块,并在连接异常关闭后每 5 秒重连一次,避免学生端突然收不到订单状态通知。收到消息时根据 message.type 做分发:新订单提醒弹提示音,订单状态变化则刷新对应订单列表,非常直接。

5.3 Node 服务与主后端的边界控制

这里必须给一句忠告:Node 辅助服务再顺手,也不要去碰核心业务逻辑。我在开发中途曾想把订单取消直接写在 Node 里,省得调接口,后来发现很难处理事务——取消订单既要改订单状态又要回补库存,拆到 Node 里就得自己写两段 SQL,一致性完全没有保障。最后还是老老实实让 Node 只做“发现超时订单”和“推送消息”,真正改状态的操作永远在 Spring Boot 事务里完成。

一旦守住这个边界,系统出问题时排查范围就非常清晰:订单状态不对先查 Spring Boot 日志,消息没推出去再查 Node 日志,而不是两边都是一团浆糊。你可以把一个服务拆开,但你得让每个部分只负责一个明确的事情,这是小型项目里最容易忽略的架构意识。

6. Vue 前端实现:学生购物体验和后台管理的关键交互

6.1 商城端页面结构与公共状态管理

商城端页面我按照学生使用动线来定:首页是分类导航加爆款商品、商品列表页支持搜索和筛选、商品详情页提供加购按钮和库存提示、购物车页支持勾选批量结算、订单列表页展示各个状态。移动端优先,底部固定一个购物车入口浮标,方便随时进入。

Vue 项目里我用 Pinia 管理三个核心状态:登录态、购物车数量、当前订单的支付状态。登录态存 token 和用户基础信息,购物车数量在每次加购成功后用接口返回的最新数量刷新。这里有一个容易踩的坑:不要只把购物车数据存本地 localStorage,否则用户换设备购物车就丢了。我的方案是购物车始终存后端,本地只缓存数量统计值用于角标展示,这样数据在任何设备上都保持一致。

6.2 加购与下单的接口串联细节

前端加购逻辑不复杂,但必须处理库存边界。用户在详情页输入数量时,先做本地校验,不能超过商品展示库存;提交加购时后端仍会做一次扣减校验,如果返回库存不足错误,前端要弹出明确提示,并在购物车里把失效商品标红,而不是静默失败。

下单流程是勾选购物车条目,把 productId 和 quantity 列表传给 createOrder 接口,返回订单号后跳转到支付确认页。支付方式我做了简化:接入一个模拟支付通道,本质上就是“点击确认支付 → 后端标记已支付”,真实扫码支付需要商户资质,个人项目通常接不了,用一个测试支付接口即可流程闭环。支付结果用轮询配合 WebSocket 通知来确定,如果 WebSocket 断了,前端每 3 秒轮询一次订单状态兜底,保证学生不会卡在支付页干等。

6.3 后台管理端的四个核心页面

后台管理留给店主使用的页面主要有四个:

  • 商品管理:表格展示商品,新增和编辑用弹窗表单,库存数量可以直接改,列表里加库存预警标红。
  • 分类管理:新增、改名、排序、停用,用于前台分类 tab 的动态展示。
  • 订单处理:按状态筛选,待支付订单显示支付时限倒计时,已支付订单可以点“开始备货”和“确认配送”。
  • 数据统计:近 7 天销售额折线图、商品销量排行、待处理订单数。

订单处理是所有页面里最需要细致的。店主点“确认配送”之后,前端立刻通过 WebSocket 通知学生“你的零食正在路上”,这个即时反馈是提升体验感的关键细节。订单列表我加了操作日志列,每次状态变更都记录时间和操作人,后面学生对订单状态有疑问时,店主可以直接截图对账,省掉很多扯皮。

7. 部署联调与踩坑记录:跨域、图片上传、库存并发、优惠叠加

7.1 本地启动步骤与端口规划

开发环境下我的端口规划是:MySQL 3306、Redis 6379、Spring Boot 后端 8080、Node 辅助服务 3001、Vue 前端 5173。前端通过 Vite 代理把 /api 转发到 8080,把 /ws 转发到 3001,这样可以避开大部分跨域问题。启动命令很简单:

# 启动 Vue 前端 npm install npm run dev # 启动 Node 辅助服务 cd server-node npm install node app.js # 启动 Spring Boot 后端 cd server-java mvn spring-boot:run

顺序建议是先启动后端和 Node,再启动前端,否则前端页面能打开但接口全是 502。数据库初始化脚本要在启动前执行一次,包含建库、建表和基础分类数据;Redis 不需要预置数据,但服务必须提前启动。项目根目录我放了一份 README 写明启动顺序,因为隔一周再看自己都容易忘。

7.2 我最想复盘的几个坑

第一个坑是跨域配置。最初想省事,让前端直接请求 http://localhost:8080/api,结果浏览器报跨域。我在 Spring Boot 里配置了 CorsFilter 放行所有来源,等后端配置好之后又发现一种情况:如果前端把 token 放在 Authorization header,跨域预检请求会自动多一次 OPTIONS 请求,需要让后端对 OPTIONS 请求也直接返回 200。加了这个处理之后就很稳定了。

第二个坑是图片上传。小卖部商品图开始时我存在后端磁盘上一个临时目录,后端重启图片就丢了,商品列表一片空白。后来改成上传到 nginx 静态目录,数据库里只存相对路径,访问时由 nginx 直接 serve 图片文件,后端重启完全不影响。开发阶段如果懒得配 nginx,至少也要把上传路径配置成绝对路径,千万别放在 target/classes 这种构建目录底下,否则执行 mvn clean 之后所有图都消失。

第三个坑是优惠活动叠加。有段时间前端直接把满减后的金额传给后端,结果学生叠加会员折扣之后出现总价和明细对不上。后来我把优惠计算全部收回后端:先算商品原价总和,再按规则算满减,再算会员折扣,全部在 Service 层完成,前端只能展示,不能参与计算。这个原则必须守住,后端永远要重新计算一遍金额,前端传的总价在 Service 里根本没有被采用。

第四个坑是库存并发。前期测试大家都在同一时间点抢购同一个商品,出现库存扣成负数的情况。后来把 update 语句改成 stock >= #{quantity} 的条件更新才解决。事务嵌套也要注意,取消订单回补库存时如果不在事务里,可能会出现订单取消成功但库存没加回来的情况,这种数据不一致是最难排查的。

7.3 上线前要过一遍的检查清单

上线前我在 Linux 服务器上做了最后一轮确认:

  • Java 服务用 systemd 托管,Node 服务用 pm2 托管,nginx 同时承担静态资源配置和反向代理。
  • 检查 MySQL 的时区设置,避免订单超时判断在时间问题上出偏差。
  • 给 WebSocket 的消息推送加鉴权,防止陌生人伪造订单状态通知。
  • 用测试账号跑一遍“下单-支付-备货-配送-确认收货”全流程。
  • 再跑一遍“下单后不支付等超时取消”的回收流程。
  • 确认商品图片路径在搬迁到服务器后没有写死 localhost。
  • 给订单表、商品表加上主要查询字段的索引,避免数据量上来之后慢查询拖垮接口。

这两套流程都通过,才算是可以交付的状态。

结尾

最后分享一点个人体会:校园小卖部零食商城这类项目,表面上拼的是页面和接口,实际拼的是订单状态机和库存一致性。我前两版翻车基本都翻在“只在支付成功才扣库存”和“超时取消没有做幂等”这两件事上,后来把状态机画在纸上再写代码,问题少了大半。Spring Boot 负责核心业务、Node 负责辅助服务、Vue 负责页面呈现,这套组合对于一个从零到一的校园电商系统来说恰到好处:该重的地方足够重,该轻的地方足够轻。如果你也在做类似的商城项目,建议先别急着写代码,把商品的购买流程和异常分支完整走一遍,你会发现一半以上的设计问题在流程图阶段就能暴露出来。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询