Node.js+Vue搭建绿色商品商城:从数据库设计到部署全解析 去年帮一位做有机农场的开发者朋友搭建线上商城他提的需求很直接把店里的绿色蔬菜、环保日用品搬上网用户能看详情、能加购物车、能下单最好一个月内就能用起来。我盘了手头的服务器配置最终选了Node.js Vue这套组合。整个项目从零到可以演示前后不到三个星期后来陆续有同行问这个绿色商品网上商城的设计思路所以我把整个设计和制作过程整理出来从数据库、接口到前端页面和部署一次性讲清楚。这篇内容适合业务还在起步阶段、想用轻量技术栈快速落地一个商城的开发者参考也适合打算做“绿色商品”这类垂直电商方向的同学拿来当模板。1. 技术选型为什么是 Node.js Vue 而不是更“重”的方案1.1 先算一笔账这个商城到底需要什么做技术选型之前我喜欢先把需求拆成几个硬指标。这个绿色商品商城前期其实不复杂商品数量不多用户量不大但需要完整的浏览、搜索、购物车、下单流程还要给运营人员留一个简单的商品上架入口。关键是不能把成本拖得太高包括服务器成本、开发成本和后续维护成本。一开始也考虑过用更大型的后端框架但评估下来发现有点“杀鸡用牛刀”。这类电商业务的核心是 CRUD 加订单事务用 Node.js 的异步模型完全能扛住中小流量。Vue 在前端负责数据驱动的页面交互刚好匹配商城这种“列表、详情、购物车、结算”的典型结构。很多初学者容易陷入“框架越重越稳妥”的误区。实际上绿色商品商城的业务量级可能几千个 SKU、几百个日活这时候一台普通云服务器跑 Node.js MySQL 非常轻松。选轻量方案不是偷懒而是把有限的时间花在业务实现上而不是环境配置和框架约束上。1.2 Node.js 在后端到底承担什么角色这个项目后端选择 Express因为它生态成熟、中间件丰富遇到问题能找到大量可参考的写法。Node.js 负责提供 API:商品列表、商品详情、分类、登录注册、购物车、订单提交。这些接口都是典型的 I/O 密集型操作,读写数据库、返回 JSON,Node.js 的事件循环模型在这种场景下效率很高。后端架构我做了简单分层路由层负责收请求控制器层做参数校验和结果返回服务层写业务逻辑模型层只负责 SQL。不引入重型 ORM,直接用 mysql2 连接池写 SQL,这样团队里任何人接手都能快速看懂也不会出现“框架帮你做了太多事出了问题不好排查”的尴尬。Node.js 还有一个好处是前后端语言统一。前端写 Vue 是 JavaScript,后端写 Node.js 也是 JavaScript,数据结构的命名、类型转换、JSON 序列化都不用在两种语言之间来回切换。对独立开发者或者小团队来说这一点能省下不少沟通成本。1.3 Vue 解决的是页面和交互那一层前端选择 Vue 3 Vite Pinia。Vue 的核心价值是组件化和响应式商城这类页面很适合拆成组件商品卡片、价格标签、分类导航、购物车列表每个组件负责自己的局部状态页面之间用路由切换全局数据交给 Pinia。举个例子商品列表页和购物车页都需要展示价格和库存状态如果不用框架每个页面都要重新操作 DOM逻辑散落各处用 Vue 之后一个商品卡片组件可以在首页、分类页、搜索结果页复用数据通过 props 传入交互事件通过 emit 抛出代码逻辑收敛了很多。Vite 作为构建工具开发时热更新速度很快。商城里商品图片多改完样式立刻刷新调试体验比老一代打包工具好太多。这套组合在前端层面没有明显短板社区资料也足够多遇到样式、路由、状态管理等常见问题都能快速解决。2. 把“绿色商品”翻译成数据库里的字段2.1 商品表不只是 SKU还需要“绿色属性”商城最核心的表是商品表但绿色商品和普通电商商品不一样用户更关心产地、种植方式、环保认证、是否当季。所以商品表里除了常规的标题、价格、库存、图片我额外加了几个字段。CREATE TABLE goods ( id INT AUTO_INCREMENT PRIMARY KEY, category_id INT NOT NULL, name VARCHAR(120) NOT NULL, subtitle VARCHAR(255) DEFAULT , main_image VARCHAR(255) NOT NULL, gallery TEXT, price DECIMAL(10,2) NOT NULL, original_price DECIMAL(10,2) DEFAULT 0, stock INT NOT NULL DEFAULT 0, unit VARCHAR(20) DEFAULT 份, origin_place VARCHAR(100) DEFAULT , eco_info VARCHAR(255) DEFAULT , description TEXT, status TINYINT NOT NULL DEFAULT 0, sort_order INT NOT NULL DEFAULT 0, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里origin_place保存产地eco_info保存绿色认证要点比如“有机种植”“可降解包装”“无农药残留”之类的文字描述。不需要做成太复杂的认证体系真实业务里通常是运营在后台填写一段展示文案让用户在详情页看到这些绿色属性就够了。商品图片用main_image存主图gallery存详情图。为了简单我直接用 JSON 字符串存多图读取时JSON.parse处理。如果后续图片数量膨胀再拆成商品图片表也不迟。2.2 用户、购物车、订单、订单明细电商的最小闭环绿色商品商城的闭环最后还是“用户下单”所以用户表、购物车表、订单表、订单明细表缺一不可。用户表不用太复杂注册、登录、展示头像和昵称就行CREATE TABLE user ( id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE, password_hash VARCHAR(255) NOT NULL, nickname VARCHAR(50) DEFAULT , avatar VARCHAR(255) DEFAULT , phone VARCHAR(20) DEFAULT , points INT NOT NULL DEFAULT 0, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;购物车表的关键是checked字段表示用户是否勾选该商品参与结算。结算页只能针对勾选项生成订单这是很常见且容易忽略的细节:CREATE TABLE cart ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, goods_id INT NOT NULL, quantity INT NOT NULL DEFAULT 1, checked TINYINT NOT NULL DEFAULT 1, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_goods (user_id, goods_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;订单表和明细表分开建。订单表存一笔订单的整体信息比如总金额、收货人、支付状态明细表存订单里的每一件商品快照。为什么要快照因为商品价格和描述随时可能变下单后如果商品改价用户订单里仍然应该保留下单那一刻的信息。CREATE TABLE orders ( id INT AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(32) NOT NULL UNIQUE, user_id INT NOT NULL, total_amount DECIMAL(10,2) NOT NULL, receiver_name VARCHAR(50) NOT NULL, receiver_phone VARCHAR(20) NOT NULL, receiver_address VARCHAR(255) NOT NULL, remark VARCHAR(255) DEFAULT , status TINYINT NOT NULL DEFAULT 0, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, pay_time DATETIME DEFAULT NULL ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE order_item ( id INT AUTO_INCREMENT PRIMARY KEY, order_id INT NOT NULL, goods_id INT NOT NULL, goods_name VARCHAR(120) NOT NULL, goods_image VARCHAR(255) NOT NULL, price DECIMAL(10,2) NOT NULL, quantity INT NOT NULL, subtotal DECIMAL(10,2) NOT NULL ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;订单状态status我用了数字枚举0 待付款、1 已付款待发货、2 已发货、3 已完成、4 已取消。前后端约定好直接传数字前端再映射成文字。2.3 最容易漏掉的状态字段上下架和审核很多新手做商城商品表里恨不得只放价格和库存结果上线以后发现后台想下架一个卖完的商品还得改代码。我在商品表里专门放了status字段0 表示下架1 表示上架后端商品查询接口强制只返回status 1的数据。这个字段看起来简单实际很重要。绿色商品有时效性比如当季蔬菜过了季节就下架或者某批货源因为品质问题要临时停售。运营人员只要把商品状态改一下前端商品列表就看不到了但已下单用户的订单详情仍然能正常展示。如果把商品状态和库存混在一起判断比如用“库存为 0 就当商品下架”会出现很多边界问题明明暂时缺货用户还能在分类页看到商品但点进详情没货了体验很糟糕。所以商品可用性的判断必须分成两层状态开关决定要不要展示库存数量决定能不能购买。3. 后端接口分层从商品列表到下单的事务处理3.1 目录结构和中间件先把地基打好后端项目我按功能模块组织不搞花哨的微服务。目录结构大致如下server/ app.js routes/ goods.js user.js cart.js order.js controllers/ goodsController.js userController.js cartController.js orderController.js services/ goodsService.js cartService.js orderService.js db/ index.js utils/ jwt.js response.jsapp.js里加载中间件cors解决跨域express.json()解析 JSON 请求体express.static()托管上传图片再把路由挂载上去。数据库连接池单独放在db/index.js避免每个模块都新建连接。const express require(express); const cors require(cors); const goodsRouter require(./routes/goods); const app express(); app.use(cors()); app.use(express.json()); app.use(/uploads, express.static(uploads)); app.use(/api/goods, goodsRouter); app.listen(3000, () { console.log(server running at port 3000); });中间件的顺序有讲究express.json()必须在路由之前挂载静态资源托管也要尽早这样请求进来以后能按顺序处理不需要在路由里重复处理。3.2 商品列表和详情接口条件筛选、排序、分页一次做全商品列表接口是用户浏览商城的入口也是访问量最大的接口。支持分类筛选、关键词搜索、价格排序、分页返回这一套在绿色商品商城几乎是标配。router.get(/, async (req, res) { const { categoryId, keyword, sort, page 1, pageSize 20 } req.query; const offset (page - 1) * pageSize; let where status 1; const params []; if (categoryId) { where AND category_id ?; params.push(categoryId); } if (keyword) { where AND name LIKE ?; params.push(%${keyword}%); } const orderBy sort price_asc ? price ASC : sort price_desc ? price DESC : sort_order DESC, id DESC; const sql SELECT id, name, subtitle, main_image, price, unit, stock FROM goods WHERE ${where} ORDER BY ${orderBy} LIMIT ? OFFSET ?; params.push(pageSize, offset); const rows await db.query(sql, params); const countSql SELECT COUNT(*) AS total FROM goods WHERE ${where}; const [{ total }] await db.query(countSql, params.splice(0, params.length - 2)); res.send({ code: 0, data: { list: rows, total } }); });注意排序字段不能直接拼接用户输入否则会造成 SQL 注入。我的做法是前端传约定好的排序标识后端在白名单里映射成具体的 SQL 片段。条件查询里只用占位符传参哪怕是全文搜索也先做LIKE再配合分页避免一次查太多数据。商品详情接口更简单根据id查询单条并检查status 1。同时返回分类名称和绿色认证文案方便前端在详情页展示。3.3 登录、购物车接口先用 JWT 把用户态串起来商城需要注册登录我用 JWT 做登录凭证。用户注册时密码用 bcryptjs 加密登录成功后签发一个 token前端后续请求在Authorization请求头带上 token后端用中间件解析并往请求对象里挂上userId。const jwt require(jsonwebtoken); function auth(req, res, next) { const token req.headers.authorization?.split( )[1]; if (!token) return res.status(401).send({ code: 401, msg: 未登录 }); try { const payload jwt.verify(token, process.env.JWT_SECRET); req.userId payload.userId; next(); } catch (e) { res.status(401).send({ code: 401, msg: 登录已过期 }); } }购物车接口依赖req.userId所有操作都是“用户维度”的。添加购物车时先查询该用户是否已经加过同一商品如果存在就把数量累加否则插入新记录。更新数量和勾选状态也走同一个模块逻辑集中在 service 层controller 只做参数校验和返回。3.4 订单提交事务、库存校验、快照保存订单提交是整个后端最需要谨慎的部分因为涉及多张表写入和库存扣减必须用数据库事务保证一致性。大概流程是先从购物车读取用户勾选的商品校验每件商品是否上架、库存是否足够然后在事务里插入订单表、插入订单明细表、扣除库存、删除已结算的购物车记录最后提交事务。如果中间任何一步出错回滚事务。async function submitOrder(userId, receiverInfo) { const cartRows await cartService.getCheckedItems(userId); if (!cartRows.length) throw new Error(没有选中商品); const conn await db.getConnection(); await conn.beginTransaction(); try { let totalAmount 0; const items []; for (const row of cartRows) { const [goods] await conn.query( SELECT id, name, main_image, price, stock FROM goods WHERE id ? AND status 1 FOR UPDATE, [row.goods_id] ); if (!goods || goods.stock row.quantity) { throw new Error(商品库存不足${goods?.name || row.goods_id}); } const subtotal Number(goods.price) * row.quantity; totalAmount subtotal; items.push({ goods_id: goods.id, goods_name: goods.name, goods_image: goods.main_image, price: goods.price, quantity: row.quantity, subtotal }); } const orderNo generateOrderNo(); const orderId await insertOrder(conn, userId, orderNo, totalAmount, receiverInfo); for (const item of items) { await insertOrderItem(conn, orderId, item); await conn.query(UPDATE goods SET stock stock - ? WHERE id ?, [item.quantity, item.goods_id]); } await conn.query(DELETE FROM cart WHERE user_id ? AND checked 1, [userId]); await conn.commit(); return orderNo; } catch (e) { await conn.rollback(); throw e; } finally { conn.release(); } }库存查询用FOR UPDATE加行锁防止两个用户同时下单造成超卖。虽然绿色商品商城流量不大但这个习惯值得养成。事务逻辑复杂测试时我会故意让某件商品库存不足确认订单表和库存都不变再继续联调前端。4. Vue 前端从首页到结算页的落地过程4.1 用 Vite 搭项目路由和状态管理怎么排前端工程直接用 Vite 创建 Vue 3 项目然后装vue-router和pinia。路由结构按页面拆/首页、/goods商品列表、/goods/:id详情、/cart购物车、/checkout结算、/login登录、/order/:id订单详情。所有需要登录才能访问的页面在路由守卫里检查 token。状态管理用 Pinia主要拆成两个 store用户 store 存用户信息和登录状态购物车 store 存购物车商品数量和勾选状态。购物车数据虽然是后端接口返回的但前端也需要一份内存态因为没有全局状态一个页面改了数量另一个页面不会自动刷新。import { defineStore } from pinia; export const useCartStore defineStore(cart, { state: () ({ items: [], totalCount: 0 }), actions: { async fetchCart() { const res await cartApi.getCart(); this.items res.data.list; this.totalCount res.data.list.reduce((sum, i) sum i.quantity, 0); } } });4.2 商品列表、分类筛选和详情页的数据流商品列表页是用户最常访问的页面数据流比较标准页面加载时从路由读取categoryId和keyword调用商品列表接口拿到列表数据后交给商品卡片组件循环渲染。分类筛选我做成侧边栏点击分类后更新路由 query商品列表组件监听路由变化自动重新请求。这么做的好处是用户点击刷新页面还在同一个分类下浏览器前进后退也符合直觉。详情页除了展示基本信息还要把绿色商品的特色信息摆出来。我在页面顶部放主图下面放价格、库存、单位再往下是eco_info展示绿色属性最后是富文本描述。购买区域固定一个“加入购物车”按钮数量组件限制最大不超过库存。组件之间通过props传递商品数据事件通过emit向上通知。比如数量改变时详情页组件通知父组件更新本地购物车状态不需要每个组件都去请求接口前端交互会轻快很多。4.3 购物车和结算页的交互细节决定体验购物车页面要支持修改数量、删除商品、勾选结算。每件商品的数量变化我会先做前端校验比如不能超过库存、不能为 0校验通过后调用后端更新数量接口再刷新购物车 store。结算页从购物车取勾选商品展示订单金额、收货地址、备注信息。提交订单前前端再展示一次商品清单让用户确认。这个“确认”环节很关键尤其是绿色商品价格可能随季节波动用户在下单前看到最终金额能减少售后争议。提交订单成功后会跳转到一个提示页或者模拟支付页。真实业务里这里要接入第三方支付我目前是先走“模拟支付成功”的流程后端把订单状态改成已付款下单流程基本完整。支付这一块建议大家做成接口隔离后续对接具体支付机构时只需要替换一个方法不会把业务逻辑打乱。4.4 请求封装和联调细节拦截器是省事关键前端所有请求通过一个封装好的 request 模块发出。统一配置baseURL请求拦截器里自动加 token响应拦截器里统一处理业务码和错误提示。import axios from axios; const request axios.create({ baseURL: /api, timeout: 8000 }); request.interceptors.request.use(config { const token localStorage.getItem(token); if (token) { config.headers.Authorization Bearer ${token}; } return config; }); request.interceptors.response.use( res { if (res.data.code ! 0) { if (res.data.code 401) { localStorage.removeItem(token); window.location.href /login; } return Promise.reject(new Error(res.data.msg)); } return res.data; }, err { return Promise.reject(err); } );这样每个页面里调用接口时只需要关心业务数据不用反复处理 token 和统一错误提示。尤其是登录过期这一块在拦截器里统一跳转比每个接口单独判断省心得多。5. 上线前后踩过的坑和最后部署时的取舍5.1 本地联调跨域Vite 代理比 CORS 头更省心开发时前端跑在 5173 端口后端跑在 3000 端口跨域问题第一时间就会出现。最简单的办法是后端开cors中间件但我在实际开发中更推荐前端配置 Vite 代理把/api请求转发到后端这样前端代码里可以统一写相对路径/api/...上线后由 Nginx 代理开发和生产环境不需要改一套接口地址。// vite.config.js export default { server: { proxy: { /api: { target: http://localhost:3000, changeOrigin: true } } } };这个配置很基础但很多人会忽略一个问题代理只解决开发环境的请求转发后端图片上传返回的路径如果是http://localhost:3000/uploads/...前端还是访问不到。所以我从一开始就让后端返回相对路径/uploads/...由请求所在域名去拼接这个细节到部署阶段省了很多麻烦。5.2 商品图片路径绝对路径和相对路径必须统一商城必然涉及图片上传和管理。我用multer处理图片上传文件存在uploads/目录数据库存相对路径。后端返回给前端的数据里图片字段直接存/uploads/goods/xxx.jpg。上线部署时我把 Node.js 服务的静态目录指向uploads同时 Nginx 也配了/uploads/的映射。如果前后端不是同一域名需要前端在图片地址前拼接后端域名。因为我在 Vite 代理里统一把/api转发到 3000图片路径用相对路径时浏览器会请求当前域名下的/uploads所以 Nginx 必须把/uploads也代理到后端。这个坑我调了很久页面能请求接口但图片全是裂的。排查下来就是 Nginx 只代理了/api没代理/uploads。大家部署时一定要把接口和静态资源两个路径一起配好。5.3 并发下单和库存扣减先想清楚再写事务商城订单流程里最怕的是“超卖”。我没做秒杀只是一个普通商城但依然在订单提交时加了行锁和事务。绿色商品库存通常不大比如一份当季蔬菜就 30 份如果两个用户同时下单最后一单不加锁就可能都通过库存校验。写完订单事务之后我用并发脚本测了几次比如同时用两个账号买同一件库存只剩 1 的商品最终只有一个订单能创建成功另一个会收到库存不足的提示。这个结果是符合预期的。生产环境如果访问量变大可以把库存操作改成 Redis 原子扣减但对这个量级的项目数据库锁加事务已经足够稳定。5.4 单机部署Nginx 托管 Vue 静态文件反向代理 Node 接口项目上线时只有一台服务器所以用 Nginx 同时处理前端静态资源托管和后端接口反向代理。Vue 构建产物直接放到/var/www/goods-webNginx 将/指到该目录将/api和/uploads代理到 3000 端口。server { listen 80; server_name goods.example.com; root /var/www/goods-web; index index.html; location /api/ { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; } location /uploads/ { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; } location / { try_files $uri $uri/ /index.html; } }最后这个try_files配置是单页应用必须的否则用户刷新/goods/3这类页面时Nginx 找不到对应的物理文件会直接 404。把/index.html作为兜底Vue Router 才能接管并渲染正确的页面。整个项目跑通后我最深的体会是这类绿色商品商城并不需要堆多少高端技术把商品、购物车、订单这几条核心链路用 Node.js 和 Vue 扎实做透比追求新框架重要得多。后面如果给用户加优惠券、积分或者复杂的营销活动也只要在现有表结构和接口分层基础上继续扩展。做商城最怕一开始就把设计弄得太重先跑通闭环再慢慢迭代这才是更稳妥的做法。