Node.js+Vue实战:超市商城系统与积分兑换全栈开发记录
上个月接了一个超市的线上商城需求项目代号就叫dh925。需求不复杂但细节不少在线浏览商品、加入购物车、下单支付另外还要把线下会员积分系统打通顾客可以用积分兑换指定商品。技术栈最后定了Node.js Vue前后端完全分离前后端加起来开发了一个半月目前已经稳定运行。这篇分享不想贴完整教学文档我只想以一个落地项目者的视角把从选型、搭建、到积分兑换业务实现的完整链路拆一遍尤其是那些文档里不会告诉你的坑。如果你正准备用Node.js Vue做商城类系统或者老板让你在现有商城里加积分功能这篇分享应该能帮你少走不少弯路。1. 这套商城系统到底要解决超市的什么问题有人觉得超市做线上商城就是把商品放到网页上卖其实没那么简单。超市的商品有几个特点品类多、单价低、复购频率高而且生鲜类还要求及时送达。如果只是把商品列表、详情、购物车、下单这些基础功能做出来那和普通电商没区别超市业态真正要解决的是一个是前面提的会员积分。线下顾客在收银台办会员卡积分靠消费累计以前只能在年底换点洗衣液。搬到线上后积分变成了用户留在系统里的数字资产它直接影响复购。如果用户攒了500积分能看到积分商城里有想要的商品那这个用户就不会轻易流失。所以积分兑换不是简单的折扣而是会员运营的一环。第二个是商品信息的准确性。超市SKU多价格变动频繁促销也经常调整。后台如果没法快速上下架、改库存、改价格运营会很痛苦。所以这个系统必须有一个对运营友好的商品管理后台而不是让运维直接改数据库。第三个是订单履约。超市订单的履约包含自提和配送两种。自提要生成提货码配送要对接第三方物流或者自建配送。我们dh925项目第一期先做自提配送接口预留。1.1 需求范围划分开发前我列了一张功能清单按优先级排用户端注册登录、商品浏览、商品搜索、购物车、订单提交、积分查看、积分兑换、自提码展示。管理端商品管理上下架/库存/价格、分类管理、订单管理、积分商品管理、用户管理、积分兑换记录。公共能力统一的登录认证、操作日志、接口访问控制。第一期不做支付对接因为超市走的是线上下单门店提货付款模式所以订单状态没有支付回调只有待提货、已完成、已取消。这个决策后来被证明很明智因为支付对接会拖慢整个项目节奏。1.2 为什么把积分兑换单独摘出来在一个商城里加积分功能最容易犯的错误是把积分当成订单的附属品只在下单逻辑里加一个字段。实际用下来积分兑换需要独立管理积分商品可能和普通商品同款但兑换渠道不同需要单独的库存池。兑换成功后积分要冻结/扣除订单取消要回滚这个状态流转需要记录。管理员要能查看谁在什么时候兑换了什么否则对账会乱套。所以在设计后端时我把points相关逻辑做成了一个独立模块而不是散落在订单服务里。后面上线之后发现独立模块对排查问题、对账、发优惠券都特别方便。2. 技术选型为什么是Node.js Vue而不是其他组合这是接手项目时团队讨论最多的问题。我之前做过几年Spring Boot也做过一些Node.js项目老实说超市系统用Spring Boot完全没问题但这次最终选Node.js Vue主要是考虑三点团队构成本身就是前端为主的团队前后端都用JavaScript/TypeScript减少语言切换成本。商城的业务逻辑以IO密集型为主读商品、加购物车、下订单Node.js的异步非阻塞模型在处理大量并发请求时并不吃亏。前后端分离是硬性要求Vue在这类中后台场景下组件生态足够成熟开发效率很高。当然Node.js也有短板CPU密集计算、强事务场景不如Java舒服。所以我从一开始就把数据库事务尽量控制在小的业务方法内复杂的跨服务事务用状态机补偿去处理而不是完全依赖数据库。2.1 前端框架选择Vue 2还是Vue 3项目启动时Vue 3已经是主流我直接选了Vue 3 Vite Pinia。Vue 2虽然还有存量系统在用但新项目没必要再踩旧生态的坑。Vue 3的组合式APIComposition API在管理购物车、积分兑换这类追求响应式状态的逻辑时代码复用和可维护性比选项式API好很多。后面写下来也确实顺。很多教程还在用Vue CLI但Vite在开发热更新上的体验真的不是一个量级。项目大了之后Vite几乎秒开而Vue CLI冷启动可能要等好几秒。2.2 后端框架选择Express还是Koa/NestJSNode.js后端三件套Express、Koa、NestJS。我做这个项目时选的是Express理由很实际文档多、中间件生态最全、团队小伙伴都熟悉。NestJS适合大型项目但它带来的依赖注入、模块化、装饰器体系学起来需要成本对dh925这个体量的项目来说有点重。Koa虽然更轻更现代但生态和文档相对少一些排查问题时能找到的参考资料不多。2.3 数据库方案数据库选了MySQL 8.0。原因很简单商城核心是商品、订单、积分这些强一致性的结构化数据MySQL的事务能力比MongoDB更适合。Redis用来做三件事首页热门商品的缓存减少数据库压力。库存的预扣减解决超卖问题后面单独说。用户登录态Token的黑名单实现退出登录立即失效。如果用纯Node.js操作MySQL我推荐用sequelize或者Prisma。dh925用的是Sequelize虽然它有时候生成SQL比较啰嗦但迁移、模型校验都能省不少事。2.4 项目目录结构我用的是monorepo结构一个仓库放前后端backend/ ├─ config/ ├─ models/ ├─ controllers/ ├─ routes/ ├─ middlewares/ ├─ services/ └─ utils/ frontend/ ├─ src/ │ ├─ api/ │ ├─ assets/ │ ├─ components/ │ ├─ router/ │ ├─ stores/ │ ├─ views/ │ └─ utils/ └─ vite.config.js前后端独立部署前端通过Nginx把/api反向代理到后端服务开发环境用Vite的proxy联调起来很舒服。3. 环境搭建中的高频坑Node.js安装、npm脚本权限与Vue项目初始化这一部分很多人会跳过觉得太基础。但我接手过不少项目很多初级程序员卡在环境半天所以还是把关键点列出来。3.1 Node.js版本怎么选Node.js版本分Current和LTS。生产环境一定选LTS版本我用的Node.js 18 LTS直到项目上线都没碰到什么兼容性问题。不要用最新的Current版本某些依赖可能还没跟上遇到bug你查半天可能发现就是版本坑。下载可以直接去官网下载安装包也可以装一个nvm-windows随时切换版本。我个人强烈推荐用nvm因为不同项目可能要用Node 16或者20切换版本是家常便饭。nvm install 18.17.0 nvm use 18.17.0 node -v npm -v3.2 Windows下npm脚本被禁用的坑热搜上经常看到npm : 无法加载文件 D:\Program Files\nodejs\npm.ps1因为在此系统上禁止运行脚本我在新同事的电脑上碰到过不下五次。原因很简单Windows PowerShell的默认执行策略是Restricted不允许运行.ps1脚本而npm的全局命令实际上是通过npm.ps1这个脚本去触发的。解决办法有两种第一种以管理员身份打开PowerShell执行Set-ExecutionPolicy RemoteSigned然后选Y。RemoteSigned表示本地脚本可以运行从网上下载的脚本需要签名对开发机来说足够安全。第二种如果不想改策略在cmd或者Git Bash里跑npm因为cmd和Git Bash不依赖PowerShell执行策略。不过我建议还是直接改掉策略毕竟很多工具链都用PowerShell脚本比如pnpm、yarn。改完之后执行npm -v看看能不能正常输出版本号不能的话检查一下系统环境变量PATH里是否包含Node.js的安装目录。3.3 用Vite初始化Vue项目Vue 3 Vite的项目初始化命令很简单npm create vitelatest frontend -- --template vue cd frontend npm install npm run dev这里有个小坑如果你本机的npm默认registry是官方源在国内环境装依赖可能很慢甚至失败。我一般直接配淘宝镜像npm config set registry https://registry.npmmirror.com配完之后安装依赖就是几十秒的事。不过要注意npm镜像只影响下载速度不影响代码逻辑。3.4 环境变量与代理配置前后端分离开发时前端需要知道后端接口地址。我在项目根目录创建了.env.development和.env.production内容大概是VITE_API_BASE_URL/api开发环境由Vite的proxy把/api转发到http://localhost:3000生产环境由Nginx做一样的事这样前端代码里不写死任何域名后面换域名也方便。// vite.config.js server: { proxy: { /api: { target: http://localhost:3000, changeOrigin: true } } }4. 后端设计Express MySQL的API与积分兑换业务后端是整个系统的核心尤其是积分兑换这块业务逻辑如果理不清后面改起来会非常痛苦。我按照资源维度拆分路由每个模块尽量只干一件事。4.1 API整体设计先看一眼主要的接口清单模块接口方法说明用户/api/user/registerPOST用户注册用户/api/user/loginPOST登录并返回JWT商品/api/productsGET商品列表支持分页/分类/搜索商品/api/products/:idGET商品详情购物车/api/cartGET/POST/PUT/DELETE购物车管理订单/api/ordersPOST提交订单含积分抵现积分/api/pointsGET积分余额与明细积分/api/points/redeemPOST积分兑换商品后台/api/admin/productsPOST/PUT/DELETE商品管理用JWT做认证登录成功后返回一个access_token前端放在请求头里。中间件里统一校验所有/api/admin开头的接口还需要校验管理员角色。4.2 用户认证与密码安全密码绝对不能用明文。我用bcryptjs做哈希成本因子设置12const bcrypt require(bcryptjs); const hash await bcrypt.hash(password, 12); const isMatch await bcrypt.compare(password, hash);JWT签发用jsonwebtoken有效期设置成24小时最好把过期时间设短一点配合refresh_token。但第一期为了省事只做了access_token。上线后发现用户在高峰期token过期会被强制重新登录体验不好第二版加了refresh_token接口。4.3 商品与购物车模块商品表字段大概有id、名称、分类id、价格、原价、库存、销量、图片、上下架状态、创建时间。价格用DECIMAL(10,2)库存用整数字段。购物车模块很多人存数据库也有人用LocalStorage。dh925选择把购物车数据存后端这样用户在电脑上加入购物车手机上也能看到。表结构要有一个唯一索引user_id product_id防止同用户重复加入同一商品。CREATE TABLE cart ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, product_id INT NOT NULL, quantity INT NOT NULL DEFAULT 1, checked TINYINT(1) DEFAULT 1, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_product (user_id, product_id) );4.4 订单模块与积分抵扣下单是整个系统里事务最重的地方。正常订单接口要做的事从请求里带出购物车选中的商品ID列表。查询商品最新价格和库存校验是否足够。生成订单号计算总价。如果用户选择积分抵现要校验可用积分并计算抵扣金额。扣减库存生成订单明细清空购物车对应项。扣减用户积分并生成积分流水。我在services/orderService.js里用Sequelize的transaction包住这些操作const transaction await sequelize.transaction(); try { const products await Product.findAll({ where: { id: cartItems.map(i i.product_id) }, lock: transaction.LOCK.UPDATE }); // 校验价格和库存 for (const item of cartItems) { const product products.find(p p.id item.product_id); if (!product || product.stock item.quantity) { throw new Error(商品${product.name}库存不足); } } // 计算总价、积分抵扣 // 扣库存、生成订单、扣积分 await transaction.commit(); } catch (e) { await transaction.rollback(); throw e; }这里特别注意查询商品时用了lock: transaction.LOCK.UPDATE这是悲观锁防止两个请求同时读到相同库存导致超卖。后面有一章专门讲并发。4.5 积分兑换独立模块积分兑换和正常下单有点像但又不一样。积分商品列表单独有一张redeem_products表兑换的时候不是直接扣商品库存而是扣积分扣独立库存。兑换流程如下请求/api/points/redeem带product_id校验当前用户积分是否足够查询兑换商品的可兑换数量是否大于0用Redis锁把用户维度锁住防止重复点击导致重复兑换扣减兑换商品库存生成积分兑换订单状态为待领取扣减用户积分生成积分流水类型为兑换消耗如果第5步成功而第6步失败人工补偿重点在于第4步。积分兑换的并发高发场景是整点秒杀式活动比如10点发放50件商品用户同时点击。我在redeem接口里加了Redis分布式锁用SETNX配合过期时间避免同一个用户短时间内重复提交const lockKey point_redeem:${userId}:${productId}; const locked await redisClient.set(lockKey, 1, { NX: true, EX: 10 }); if (!locked) { throw new Error(您兑换太频繁了请稍后再试); } try { // 执行兑换逻辑 } finally { await redisClient.del(lockKey); }上线后这个锁很关键没有它用户连续点击两次兑换按钮就会产生两笔订单和两次扣分。防重复在前端做了按钮loading后端用锁再做一层保险。5. 前端实现Vue Router组织页面购物车与积分交互前端用Vue 3 Vite Pinia Element Plus。页面没有多少但要保证操作流程顺畅特别是购物车和积分兑换的交互体验。5.1 页面规划与路由设计用户端页面首页商品瀑布流、分类入口、搜索框。商品详情页商品图片、价格、库存、加入购物车按钮。购物车页勾选、改数量、合计金额。确认订单页地址信息第一期为自提门店、积分抵现开关、提交订单。我的页个人信息、积分余额、兑换记录、订单列表。积分商城页积分兑换商品列表、兑换按钮。路由用createRouter创建按需懒加载const routes [ { path: /, name: Home, component: () import(/views/Home.vue), meta: { requiresAuth: false } }, { path: /cart, name: Cart, component: () import(/views/Cart.vue), meta: { requiresAuth: true } }, { path: /points-redeem, name: PointsRedeem, component: () import(/views/PointsRedeem.vue), meta: { requiresAuth: true } } ];5.2 路由守卫与登录状态检查访客加购物车、进购物车、下单都需要登录。我在router.beforeEach里统一检查如果页面要求登录而本地没有token就跳转到登录页并带上redirect参数登录后回跳router.beforeEach((to, from, next) { const token localStorage.getItem(token); if (to.meta.requiresAuth !token) { next({ path: /login, query: { redirect: to.fullPath } }); } else { next(); } });这里要注意热搜词里常见的坑动态路由、权限控制。如果后续要区分普通会员和商品管理员建议把菜单和路由权限都做成动态加载而不是一次性注册所有路由。dh925第一版只在后端校验管理员接口前端隐藏管理入口等二期再上动态路由。5.3 状态管理购物车用Pinia购物车数据虽然存后端但用户加购、改数量时界面要即时响应。我用Pinia维护一份cart状态首次登录后拉取购物车数据后续操作先更新本地状态再调用接口接口失败再回滚// stores/cart.js export const useCartStore defineStore(cart, { state: () ({ items: [] }), actions: { async addItem(product) { const previous [...this.items]; this.items.push({ ...product, quantity: 1 }); try { await api.addToCart(product.id, 1); } catch (e) { this.items previous; throw e; } } } });这套乐观更新失败回滚在交互上体验非常好用户几乎感觉不到网络延迟。如果连loading都不加看起来会比秒登还快。5.4 积分兑换的前端交互积分兑换在列表页每个商品卡片上放一个立即兑换按钮。点击后弹出确认框显示所需积分和库存余量。确认后调用兑换接口成功以后前端要立即更新积分余额和商品剩余数量避免用户以为没兑换成功又点一次。这里有一个细节积分兑换商品不一定要走购物车直接下单。所以前端要区分普通商品和积分商品积分商品的按钮跳转到兑换确认弹窗普通商品才走加入购物车。积分商品的展示数据来自/api/points/redeem-products和后端积分模块是配套的。前端渲染时把原价划掉积分用一个明显的数字展示用户对积分价值才有感知。6. 订单与库存扣减的并发处理不能只在代码里写if这是整个项目我花时间最多的地方。很多人做商城demo库存判断就一个if (product.stock quantity)单用户测没问题一上线就超卖。我们来看一下为什么。6.1 什么是超卖假设一件商品库存只有1件。用户A和用户B同时下单两个请求同时读到库存是1都判断stock 1通过然后都执行stock - 1最终库存变成-1两个用户都下单成功——这就是超卖。用SQL描述错误做法是UPDATE products SET stock stock - 1 WHERE id 100;这个SQL本身带上stock stock - 1是原子操作如果只执行这一句是安全的但危险的在于判断扣减不是原子操作中间有网络延迟和其他逻辑。6.2 第一种方案乐观锁乐观锁的思路是更新库存时带上版本号或条件如果更新的行数为0说明有人抢先改过这次操作失败重试。UPDATE products SET stock stock - ? WHERE id ? AND stock ?;这个SQL用一个WHERE条件stock ?affected rows等于0就说明库存不足或已被扣减事务失败。这种方式适合库存变化不频繁的场景在高并发下会有一些请求需要重试但逻辑简单可靠。6.3 第二种方案Redis预扣库存dh925的商城会有促销活动比如积分兑换的限量商品在整点放出来瞬间QPS可能上千。纯数据库扣减会拖垮数据库所以我做了Redis库存预扣商品上架时把库存总量同步到Rediskey比如stock:product:100。用户下单时先用DECR命令扣减Redis库存返回负数就说明卖超了回滚。真正生成订单后再异步扣减MySQL库存确保两边最终一致。这里需要注意Redis扣减成功但MySQL扣减失败的情况。我的处理是MySQL扣减失败时回补Redis库存然后记录错误日志并告警。因为有对账库存很少出现长期不一致。const newStock await redisClient.decr(stock:product:${productId}); if (newStock 0) { await redisClient.incr(stock:product:${productId}); throw new Error(库存不足); }6.4 事务边界与幂等性订单创建、库存扣减、积分扣减这三件事必须在一个数据库事务里任何一个失败都要回滚。我前面给的sequelize.transaction就是干这个的。但积分兑换场景还有另一个问题重复提交。前端按钮防抖、后端Redis锁、再加上数据库唯一索引三重保险。我在积分兑换订单表上建了user_id redeem_product_id created_date的唯一索引这样同一用户同一天对同一积分商品只能兑换一次活动也可以修改限定次数从数据库层面兜底。6.5 对账工具系统上线后我写了一个对账脚本每天凌晨跑一遍把Redis库存、MySQL商品库存、积分订单汇总、用户积分余额做一致性校验。对账结果是零才知道系统没漏对出问题就发钉钉告警。这种兜底手段一开始就要规划等项目跑起来再补就难了。7. 联调部署跨域、接口联调、打包发布开发玩得再溜部署这块出了问题同样头疼。下面列几个我实际碰到的问题。7.1 开发环境的跨域前端跑在5173端口后端跑在3000端口直接请求http://localhost:3000/api会跨域。处理方式在3.4说了用Vite的proxy。这里要注意changeOrigin: true必须设置否则后端收到请求头里的Host还是前端地址某些中间件会拿Host做校验导致奇怪的bug。7.2 前端打包与Nginx部署前端执行npm run build生成dist目录Nginx里做静态文件服务和反向代理server { listen 80; server_name shop.example.com; location / { root /var/www/frontend; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }try_files $uri $uri/ /index.html;这句很重要否则用Vue Router的history模式刷新非首页会404。如果路由用的是hash模式可以不用。7.3 Node.js服务的进程管理Node.js进程要守护起来不能直接在终端node app.js跑不然窗口一关服务就没了。我用的PM2npm install -g pm2 pm2 start backend/index.js --name dh925-server pm2 save pm2 startupPM2会自动日志切分和进程重启。线上跑了一个多月没有出现半夜挂掉的情况。7.4 线上问题排查最常遇到的三个线上问题数据库连接数被打满MySQL默认连接数只有151高并发时报Too many connections。解决方法先用max_connections调大同时检查代码有没有释放连接Sequelize默认会有连接池调好pool参数比直接调大数据库配置更优雅。接口响应慢常见原因是慢SQL。打开MySQL慢查询日志看哪些SQL没有走索引商品表、订单表这些高频查询表在开发阶段就要把索引建好。内存泄漏。排查Node.js内存泄漏要用--inspect和heap snapshot但更常见的是Redis连接没关、路由监听重复注册。启动时用pm2 reload而不是restart也能避免一些奇怪问题。8. 上线后的复盘哪些设计让我省心哪些还要继续改项目交付不等于结束真正的问题往往是上线后才暴露的。dh925跑了一个多月我每周都会看一眼日志、订单、积分流水这里说说复盘下来的感受。8.1 让我省心的三个决定第一积分兑换独立模块化。无论是给用户补积分还是排查兑换记录查不到我都只需要看一张表和一套接口不需要去翻订单代码。第二并发控制提前做了Redis锁事务唯一索引三层后面做活动没有出现超卖和超兑。第三前端乐观更新购物车和积分余额用户反馈点了没反应的问题几乎没有。8.2 上线后发现的三个问题第一JWT过期时间太短导致高峰期用户被迫重新登录这个前面提过二期要加refresh_token。第二库存对账脚本一开始是手动跑的后来改成定时任务才省了人工。第三自提码没有做短信提醒用户经常忘记来看后面打算接入消息推送。8.3 后续还可以怎么扩展如果这个项目继续做下去我比较看好的方向小程序端复用现有接口Vue可以直接通过uni-app或Taro包装成本低于重新开发。优惠券系统和积分体系类似也要独立的发放、核销、过期回调模块。库存预警商品库存低于阈值时自动通知采购这个可以用定时任务加Webhook搞定。支付集成增加订单支付状态机把待支付、已支付、已退款串起来这样就能真正线上闭环。最后说一句个人体会做系统最怕的不是技术不会而是业务边界不清。dh925这种超市商城光把CRUD写完很简单但把积分兑换、库存并发、对账这些细节做扎实才算是真正交付了一个可运营的系统。我从这个项目里最深的收获是设计阶段多花一天梳理状态流转后面能省出五天的补坑时间。这篇分享就到这里如果你也在做类似的商城积分项目欢迎对照检查一遍自己的方案。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →