微信小程序商城全栈实战:Node.js+MySQL实现登录、库存与订单闭环

📅 发布时间:2026/9/13 1:39:27
微信小程序商城全栈实战:Node.js+MySQL实现登录、库存与订单闭环
简介这是一份基于 Node.js 与 MySQL 的 B2C 商城系统微信小程序端完整源码适合具有一定前端基础、希望快速搭建小程序商城或学习前后端分离架构的开发者。资源共 177 个文件压缩包仅 167KB其中 44 个 js 文件承载业务逻辑、36 个 wxss 负责页面样式、35 个 wxml 构建视图结构、35 个 json 提供页面配置另有 25 张 png 图片资源目录按功能模块划分方便定位与二次开发。项目覆盖商品展示、分类检索、购物车、地址管理和订单流程等典型 B2C 场景后端使用 Node.js 提供接口MySQL 存储业务数据能够清晰展示小程序端与服务端的数据交互方式。目前已有 1213 人学习下载源码可直接导入微信开发者工具运行也可在现有基础上扩展促销、会员等模块适合课程设计、毕业设计及个人商城项目起步参考。1. 一个 B2C 微信小程序商城包拆开 zip 后最该看什么这类“商城系统源码”的 zip 包装的一般是三种东西微信小程序端页面、Node.js 后端工程、MySQL 初始化脚本。页面做得再漂亮真正决定项目能不能跑通的核心也只有三条链路微信登录态怎么闭环、库存怎么在并发下正确扣减、订单状态在支付回调里怎么落库。这三条链路任何一环断掉项目就只能一直停在本地预览阶段。下面按一个完整 B2C 商城最常见的拆解方式来梳理先讲清表结构和登录态再实现下单事务最后排掉本地联调和上线前的坑。适合刚接触小程序电商项目、想把一个全栈商城从源码跑成真实交易流的读者。2. Node.js MySQL 商城里先定表结构再写登录态2.1 四层依赖小程序端、Node.js、MySQL、可选的中间件B2C 商城最常见的技术分层是微信小程序端负责商品展示和用户操作Node.js 侧提供接口MySQL 存用户、商品、订单这类强一致数据。早期不建议把 Redis 之类的中间件直接引进来登录 token 可以落库库存扣减可以用事务完成等到单表数据量和接口 QPS 真上来了再考虑缓存和消息队列也不迟。这样做的收益是排错链路短一个开发就能独立处理从页面到数据库的整个链路。Node.js 框架选 Express 或 Koa 都能胜任我习惯用 Express因为中间件生态成熟资料也好搜。MySQL 驱动选mysql2它支持PromiseAPI 和预处理语句比老旧的mysql包更适合新项目。注意mysql2和mysql的 API 很像但配置项上有细微差别项目里一旦混用容易出现连接池行为不一致。2.2 登录态闭环wx.login 换来 codeNode.js 再去找微信换 openid小程序端用户点“微信授权登录”实际做的是两件事。第一件调用wx.login()拿到临时 code第二件wx.request把 code 发给自己的后端。真正的 openid 只有后端能拿到因为换取 openid 需要 appid 和 secret这两个值不能随小程序包发布。换 openid 的接口是微信官方提供的Node.js 侧用 axios 请求即可// services/wx.js const axios require(axios); async function codeToOpenid(code) { const url https://api.weixin.qq.com/sns/jscode2session; const params { appid: process.env.WX_APPID, secret: process.env.WX_SECRET, js_code: code, grant_type: authorization_code }; const { data } await axios.get(url, { params }); if (data.errcode) { throw new Error(jscode2session 失败: ${data.errcode} ${data.errmsg}); } return data; // openid, session_key, unionid(可选) }参数说明appid 和 secret 从小程序后台的“开发管理 → 开发设置”里获取通过环境变量注入比硬编码在源码里安全js_code 是wx.login()回调里拿到的临时凭证有效期只有五分钟并且只能用一次grant_type 固定为authorization_code。返回结果里的 session_key 不需要写进数据库它只用于解密手机号这类敏感信息。返回的 openid 就是该小程序下的用户唯一标识用它来区分用户就够了。拿到 openid 后还要落库注意不能直接信任小程序端传过来的昵称头像CREATE TABLE user ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, openid VARCHAR(64) NOT NULL, unionid VARCHAR(64) DEFAULT NULL, nick_name VARCHAR(64) DEFAULT , avatar_url VARCHAR(512) DEFAULT , phone CHAR(11) DEFAULT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_openid (openid) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;openid 加唯一索引是为了兜底并发登录。两个请求同时查到用户不存在同时执行 INSERT唯一索引会让其中一条插入失败业务层捕获后重新查询即可。unionid 只有在小程序绑定开放平台后才会返回没有时不要阻塞业务流程。phone 字段允许为空新用户没授权手机号时不能卡住下单。2.3 自签 token 替代 session为什么不让 MySQL 存 session如果每个用户的登录态都往 MySQL 里写用户名下多端登录的记录会越来越多单表膨胀很快。常见做法是后端用 JWT 把用户身份签成一个 token返回给小程序端保存后续请求带上Authorization头由后端中间件验签。JWT 无状态后端水平扩容时不用同步 session 数据。const jwt require(jsonwebtoken); function issueToken(user) { const payload { uid: user.id, openid: user.openid }; return jwt.sign(payload, process.env.JWT_SECRET, { expiresIn: 7d }); }payload 里的 uid 用于关联订单、购物车等业务表openid 不要散落到各个业务表里做外键账号体系后续如果要接公众号统一登录只改 user 表映射关系即可。expiresIn 设 7d商城场景下用户七天内不活跃再重新登录比较合理。JWT_SECRET 必须够长够随机并且只能存在于服务端环境变量里。主数据表可以先规划成这样后面写接口不返工表名用途核心索引user用户基本信息uk_openidproduct商品基本信息idx_category, idx_statusproduct_sku规格与库存uk_product_skucart购物车uk_user_productp_order订单主表uk_order_no, idx_user_statusorder_item订单明细idx_order_idpayment_log支付回执uk_transaction_id注意p_order 不用order做表名因为 order 在 MySQL 8 里是保留字以后写 SQL 都要加反引号麻烦。2.4 小程序端 401 后的统一处理每个wx.request都去判断登录态会很啰嗦把请求封装成一个公共方法遇到 401 统一跳登录页function request(options) { return new Promise((resolve, reject) { wx.request({ ...options, header: { Authorization: Bearer ${wx.getStorageSync(token)}, ...options.header }, success(res) { if (res.statusCode 401) { wx.removeStorageSync(token); wx.navigateTo({ url: /pages/login/index }); reject(new Error(登录已过期)); } else { resolve(res.data); } }, fail: reject }); }); }说明wx.request的success回调里res.statusCode才是 HTTP 状态码业务码res.data.code是后端自定义的两者不要混用。token 失效后先清本地缓存再跳登录页避免跳转后旧 token 还在存储里导致死循环。3. B2C 下单链路Node.js 事务保证 MySQL 库存不超卖3.1 三条链路别混在一起加购、下单、支付很多新手把下单做成“插入订单 扣减库存”两条独立的 SQL不包事务这是最典型的错误。订单必须和库存同时成功或者同时失败否则会出现支付成功却没货发的状态。常见可靠做法是加购物车时不校验库存只校验商品是否上架提交订单时在事务里做“校验商品信息、扣减库存、生成订单、清空购物车”支付回调里做“改订单状态、写支付流水、触发发货通知”。三步分开写出问题时排查范围小日志也更容易对得上。下面重点讲提交订单这一步。3.2 订单主表与明细表结构订单表要把商品名称、单价、图片等信息冗余进来这就是下单时的“快照”。商品后续改价、改名历史订单不能跟着变。单商品商城可以简化多商品必须拆订单主表和明细表。以常规主表为例CREATE TABLE p_order ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, user_id INT UNSIGNED NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待支付, 1已支付, 2已发货, 3完成, 4已取消, 5已关闭, total_fee DECIMAL(10,2) NOT NULL, receiver_snapshot JSON NOT NULL COMMENT 收货人姓名/电话/地址快照, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no), KEY idx_user_status (user_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;order_no 不要直接用自增 id规则可以取yyyyMMddHHmmss 用户id 随机数对外暴露订单号时别人无法通过遍历猜出你的订单量。status 用 TINYINT 整数表示比字符串省空间也不容易出现“Paid”“PAID”“paid”这种拼写不一致。收货人信息在 user 表里会变订单里必须存快照否则发货时收货地址可能已经被用户改掉了。我一般会把订单状态机直接做成一张枚举表开发时对着看避免各写一套魔法数状态值含义触发动作0待支付下单完成等待回调1已支付支付回调验签通过通知发货2已发货商家填物流单号3已完成用户确认收货或超时自动确认4已取消用户支付前取消5已关闭超时未支付自动归还库存3.3 库存扣减的正确姿势条件 UPDATE 代替 SELECT 再 UPDATE超卖的发生路径很固定两个请求同时执行SELECT stock FROM product_sku WHERE id 1都读到 10然后分别执行UPDATE product_sku SET stock 9 WHERE id 1最终库存只剩 9但实际已经卖出两单。解决办法是让扣库存的 SQL 自带条件影响行数为 0 就说明库存不足直接回滚const mysql require(mysql2/promise); async function createOrder(conn, userId, skuId, count) { await conn.beginTransaction(); try { const [result] await conn.execute( UPDATE product_sku SET stock stock - ? WHERE id ? AND stock ?, [count, skuId, count] ); if (result.affectedRows 0) { throw new Error(库存不足); } const [orderResult] await conn.execute( INSERT INTO p_order (order_no, user_id, status, total_fee, receiver_snapshot) VALUES (?, ?, 0, ?, ?), [genOrderNo(), userId, totalFee, JSON.stringify(receiver)] ); await conn.commit(); return orderResult.insertId; } catch (e) { await conn.rollback(); throw e; } }逻辑说明conn是mysql2/promise连接池里getConnection()拿到的同一个连接事务必须在一个连接上完成否则 commit 不会生效。SQL 里stock ?是核心MySQL 行锁会保证同一时间只有一个事务能更新同一行后到的事务等锁释放后重新执行 UPDATE会发现库存不满足条件影响行数为 0从而触发回滚。这种条件更新在单库单表场景下足够应对绝大多数超卖问题不一定要上 Redis 分布式锁。注意事务里不要夹杂wx.request、发送短信、调用外部 API 这类操作。对外请求的耗时不可控行锁会一直持有拖垮连接池。下单成功通知这类动作放在事务提交之后再执行。3.4 超时关单与支付回调的幂等用户下单后不支付库存被占着不放。常见做法是定时任务扫描超过 30 分钟仍处于待支付的订单改成已关闭并恢复库存。恢复库存依然要走条件更新同时把订单状态从 0 改成 5防止同一个订单被关单任务重复扫描UPDATE p_order SET status 5 WHERE id ? AND status 0;这样即使关单任务被重复触发第二次执行因为 status 已经是 5 而影响行数为 0。再配合product_sku的库存恢复 UPDATE就能安全归还库存。支付回调的入口同样要幂等微信支付会多次推送同一个结果处理逻辑里必须先查payment_log是否已经存在该 transaction_id存在就直接返回成功不再重复改订单状态。payment_log.transaction_id要建唯一索引重复插入会直接抛异常这也是最后一道防线。4. 本地跑通Node.js、MySQL 初始化与微信小程序端联调4.1 先把 Node.js 和 MySQL 跑起来常见卡点本地开发 Node.js 建议直接装 LTS 版本npm 源换成国内公共镜像能明显减少依赖安装失败的概率。这和标题里 Node.js 安装教程、mysql 安装教程这些搜索意图直接相关操作上也就三条命令的事node -v npm -v mysql --version npm config get registrynode 和 npm 能打印版本号说明安装成功。npm config get registry如果返回的是官方源安装依赖时会比较慢可以执行npm config set registry https://registry.npmmirror.com这是公共镜像服务配置完重新npm install即可。MySQL 8 在部分系统上会出现认证插件不兼容但 mysql2 驱动默认能处理不需要额外改 MySQL 配置。数据库准备就绪后创建业务库并导入初始化脚本mysql -uroot -p -e CREATE DATABASE b2c_mall DEFAULT CHARSET utf8mb4; mysql -uroot -p b2c_mall init.sqlinit.sql 里只放建表语句和少量商品测试数据不要混入开发期的脏订单。utf8mb4 字符集能存下微信用户昵称里的 emoji 和特殊符号用 utf8 会直接插入报错。4.2 .env 配置与注意事项后端工程的配置集中放.env文件既方便本地切换也防止敏感信息被写进代码仓库PORT3000 MYSQL_HOST127.0.0.1 MYSQL_PORT3306 MYSQL_USERroot MYSQL_PASSWORD你的密码 MYSQL_DATABASEb2c_mall JWT_SECRET一串至少32位的随机字符串 WX_APPID你的小程序appid WX_SECRET你的小程序secretJWT_SECRET 不要写成123456这类短字符串JWT 一旦被爆破攻击者可以伪造任意用户身份。WX_SECRET 更不能泄露到前端或提交进 git 历史如果不小心提交过去小程序后台重置。MySQL 的 host 在本地用 127.0.0.1 而不是localhost因为 Node.js 在某些环境下解析localhost会优先走 IPv6连不上本机数据库。4.3 后端启动与 curl 验证依赖装好、配置写好启动命令npm install npm run dev curl -X POST http://127.0.0.1:3000/api/user/login \ -H Content-Type: application/json \ -d {code:临时code}这里临时code必须从微信开发者工具里实时获取随便填一个离线 code 会得到 40029 错误码这是登录接口联调最常见的失败原因。接口正常返回包含 token 的 JSON说明 Node.js 到 MySQL、Node.js 到微信服务器这条链路已经打通。联调过程中的关键地址整理如下端地址说明Node.jshttp://127.0.0.1:3000后端接口服务MySQL127.0.0.1:3306数据库连接微信开发者工具内置模拟器不需要外网域名即可请求局域网接口4.4 微信开发者工具里改 BASE_URL 并处理域名校验小程序端的接口地址一般集中在一个配置文件里改起来很快// config/base.js module.exports { BASE_URL: http://127.0.0.1:3000 };模拟器里可以用 127.0.0.1真机预览要换成电脑的局域网 IP比如http://192.168.1.5:3000并且手机和电脑必须在同一个 Wi-Fi 下。同时需要在开发者工具的“详情 → 本地设置”里勾选“不校验合法域名、web-view业务域名、TLS 版本以及 HTTPS 证书”否则wx.request会直接拦截 http 请求。这一步是本地联调最容易卡住的地方先确认后端 curl 通了再检查开发者工具这个开关。4.5 首页加载页怎么改入口页与导航栏高度适配“修改刚进入的加载页面”这类需求很常见实现上有两种思路在app.json里用entryPagePath指定一个启动页或者做一个 splash 页完成初始化后再跳首页。加载页里最容易出问题的是自定义导航栏高度尤其 iPhone 有刘海、安卓有状态栏。取胶囊按钮位置来计算高度是通用做法// utils/navbar.js const menu wx.getMenuButtonBoundingClientRect(); const system wx.getWindowInfo ? wx.getWindowInfo() : wx.getSystemInfoSync(); const navBarHeight (menu.top - system.statusBarHeight) * 2 menu.height;说明menu 是右上角胶囊按钮的位置和尺寸信息statusBarHeight 是状态栏高度两者相减得到胶囊到状态栏的距离这个距离乘 2 加上胶囊自身高度就是自定义导航栏的总高度。加载页在渲染前先读这个值用来设置占位视图的高度页面渲染时就不会跳一下。5. 上线前最后十五分钟鉴权、慢查询与响应头排查5.1 全局鉴权中间件挂到需要登录的路由前面商品列表、商品详情这类只读接口可以不鉴权但购物车、下单、售后接口必须校验用户身份。在 Express 里写一个中间件业务路由里就不需要重复做 token 校验const jwt require(jsonwebtoken); function auth(req, res, next) { const header req.headers[authorization] || ; const token header.replace(/^Bearer /, ); if (!token) { return res.status(401).json({ code: 401, msg: 未登录 }); } try { req.user jwt.verify(token, process.env.JWT_SECRET); next(); } catch (e) { res.status(401).json({ code: 401, msg: 登录已过期 }); } }挂载方式router.post(/order, auth, handler)这样即使某个接口忘了写校验逻辑只要路由没挂中间件就不会误伤其他接口。上线前可以扫一遍路由凡是带有user_id参数的写操作必须挂上这个中间件。5.2 慢 SQL 的排查动作商城流量一大最先告警的往往是订单列表接口。上线前先确认慢查询日志开着再对高频查询做一次 EXPLAINSET GLOBAL slow_query_log ON; SET GLOBAL long_query_time 1; EXPLAIN SELECT * FROM p_order WHERE user_id 1 ORDER BY id DESC LIMIT 20;EXPLAIN 结果里 type 如果出现 ALLrows 又特别大说明idx_user_status这个索引没建或者没被命中。注意user_id和status的联合索引覆盖了“列出某用户某状态订单”的查询场景比分开建两个单列索引更合适。慢查询日志在本地验证完就关掉减少不必要的 IO 开销。5.3 响应头与返回字段的坑微信小程序的wx.request对响应格式很敏感。后端接口如果用了res.send(JSON.stringify(data))Content-Type 可能变成text/html; charsetutf-8小程序端解析就会出问题。上线前用一条命令检查curl -I http://127.0.0.1:3000/api/products看到Content-Type: text/html就说明路由里没有正确设置 JSON 响应统一改成res.json(data)可以解决。另外检查接口返回字段password、openid、phone这些敏感字段不要出现在列表接口里用 SELECT 时只取需要的列避免整表字段全量返回。最后把这几个检查项写进发布脚本每次提审前自动跑一遍 Content-Type 和敏感字段校验能省掉不少审核驳回的等待时间。本文还有配套的精品资源点击获取