小程序商城源码实战:跑通SKU、购物车与订单事务链路

发布时间:2026/10/29 4:02:23
小程序商城源码实战:跑通SKU、购物车与订单事务链路

简介:面向微信小程序商城开发学习、课程设计与毕业设计场景的完整源码包,适合具备基础前端知识、希望理解商城类小程序整体实现思路的学生与开发者参考。压缩包共68个文件,约3.32MB,以30张png界面与素材图、9个js逻辑文件、7个wxss样式文件、7个wxml页面结构、7个json配置及若干jpg/jpeg图片为主,基本覆盖页面展示、交互样式与业务逻辑等层面。已有651人学习,具备一定参考热度。源码围绕商品展示、购物车、结算、订单处理、支付对接、用户管理与营销活动等常见商城模块组织,可帮助读者梳理微信小程序前端页面搭建、组件使用、WXML/WXSS布局及业务数据流转的对应关系。对于准备课程设计、毕业设计或想快速了解商城小程序目录结构与功能拆分的学习者,可作为对照分析与二次开发的基础样例。

1. 一份小程序商城源码解压之后:能跑通的下单链路比什么都值钱

课程设计或者毕业设计拿到的包,最怕的不是没代码,而是代码跑不起来、还看不出卡在哪一步。这份小程序商城源码解压之后是一套完整的前后端:小程序端负责首页、分类、商品详情、购物车、订单,服务端负责商品、订单、用户三类接口,用开发者工具导入前端、本地起一个 Node 服务,就能把「浏览商品—选规格—加购物车—提交订单—扣库存」这条链路完整走一遍。它的价值不在于页面多漂亮,而在于业务流程是闭环的、数据结构是能看懂的,适合两类人:一类是需要一个能演示完整交易流程的底座、把课程设计或毕业设计交出去的人;另一类是接私活想省掉脚手架、直接在商品和订单模块上改的前端。后面几章就按「先看清结构、再跑通链路、最后动手改」的顺序往下拆。

2. 技术栈与目录结构:先分清哪些是业务代码,哪些是脚手架

拿到包的第一件事不是急着点运行,而是把目录摊开看一遍,判断这套代码是「原生小程序 + 自建服务端」还是「小程序 + 云开发」。两者的改法完全不同:前者你需要在本地起服务、配数据库、管域名;后者改的是云函数和云数据库,联调门槛低但对平台依赖重。这份包属于前者,小程序端是原生 WXML/WXSS/JS 三件套,没有引入第三方框架,服务端是一层很薄的接口服务,数据落库。

2.1 三条主线怎么分:小程序端、服务端、数据库

原生小程序端的好处是学习成本几乎为零,页面逻辑都写在Page({})里,setData驱动视图,任何熟悉 JS 的人打开就能读。代价是缺少状态管理,跨页面的数据(比如购物车、登录态)需要靠本地存储或者全局变量手动同步,这也是后面容易踩坑的地方。如果换成 Taro 或 uni-app 这类跨端框架,编译产物会多一层,调试时看栈信息会绕一些,课程设计场景下反而增加解释成本。

服务端我一般建议用 Node.js + Express,理由是接口层足够薄,一套路由对应一组 REST 接口,几十行就能把「查商品列表」「查详情」「创建订单」写完,答辩时也容易讲清楚一次请求经过了哪些文件。数据库用 MySQL,商品、SKU、订单、订单明细、用户五张核心表,关系清晰,写论文时的 E-R 图能直接画出来。整套东西的选型对照如下:

层次选型替代方案取舍说明
小程序端原生 WXML/WXSS/JSTaro、uni-app原生无编译层,调试链路最短
服务端Node.js + ExpressKoa、Spring Boot路由直白,接口层代码量小
数据库MySQL + mysql2 连接池MongoDB订单有强事务需求,关系型更稳
鉴权openid + 简易 tokenJWT、Session小程序场景无账号密码,openid 够用
图片对象存储或 CDN包内本地图主包体积卡在 2M,图片必须外置

表里最后一行是很多模板包的默认做法,也是上传代码时第一个撞上的墙,第 5 章会细说。选型上没有绝对的对错,关键是想清楚你这套东西要交付给谁看:如果是给评审看流程,越朴素越好;如果是给自己当项目起点,接口分层和错误码规范比框架更值得花时间。

2.2 目录逐层拆解:入口文件、页面、工具库、服务端

解压后的目录大致是这个结构,不同包命名会有出入,但分层逻辑基本一致:

mall-wx/ ├── miniprogram/ # 小程序端 │ ├── app.js # 全局逻辑:登录、全局数据 │ ├── app.json # 页面路由、tabBar、窗口配置 │ ├── app.wxss # 全局样式变量 │ ├── pages/ │ │ ├── index/ # 首页:轮播 + 推荐位 │ │ ├── category/ # 分类页:左侧类目 + 右侧商品 │ │ ├── goods/ # 商品详情:图集 + 规格弹窗 │ │ ├── cart/ # 购物车 │ │ ├── order/ # 确认订单、订单列表 │ │ └── mine/ # 个人中心 │ ├── components/ │ │ ├── goods-card/ # 商品卡片,列表复用 │ │ └── sku-popup/ # 规格选择弹窗 │ ├── utils/ │ │ ├── request.js # wx.request 封装 │ │ ├── cart.js # 购物车本地存储 │ │ └── sku.js # 规格组合计算 │ └── project.config.json # 开发者工具项目配置 ├── server/ # 服务端 │ ├── app.js # 服务入口,挂中间件和路由 │ ├── routes/ # 路由声明 │ ├── controllers/ # 业务逻辑 │ ├── db/pool.js # 连接池 │ └── sql/init.sql # 建表 + 初始数据 └── README.md

app.json是整份前端代码的地图,页面路径、tabBar、导航栏样式都在这里,先读它比逐个翻页面快得多:

{ "pages": [ "pages/index/index", "pages/category/category", "pages/goods/goods", "pages/cart/cart", "pages/order/confirm", "pages/order/list", "pages/mine/mine" ], "window": { "navigationBarTitleText": "商城", "navigationBarBackgroundColor": "#ffffff", "navigationBarTextStyle": "black", "backgroundTextStyle": "dark" }, "tabBar": { "list": [ { "pagePath": "pages/index/index", "text": "首页" }, { "pagePath": "pages/category/category", "text": "分类" }, { "pagePath": "pages/cart/cart", "text": "购物车" }, { "pagePath": "pages/mine/mine", "text": "我的" } ] }, "style": "v2" }

pages数组的第一项就是启动页,顺序不能乱改,否则冷启动会落到别的页面。tabBar.list的下标很关键,购物车是第 3 项(从 0 开始数),后面在utils/cart.js里设置角标时就依赖这个下标,改动 tabBar 顺序一定要同步改角标代码,不然角标会跑到「我的」上面去。style: "v2"表示启用新版组件样式,如果你的包是从老模板改的,去掉这一行按钮圆角和间距会变回旧样式,视觉上会有出入。

server/app.js则是服务端的入口,中间件顺序决定了请求能不能被正确解析:

// server/app.js const express = require('express'); const app = express(); // 解析 JSON 请求体,缺这一行 req.body 永远是 undefined app.use(express.json({ limit: '2mb' })); // 允许跨域,方便用浏览器或 Postman 单独调接口 app.use((req, res, next) => { res.header('Access-Control-Allow-Origin', '*'); res.header('Access-Control-Allow-Headers', 'Content-Type'); next(); }); app.use('/api/goods', require('./routes/goods')); app.use('/api/cart', require('./routes/cart')); app.use('/api/order', require('./routes/order')); app.use('/api/auth', require('./routes/auth')); // 统一兜底错误处理,避免异常直接把进程打挂 app.use((err, req, res, next) => { console.error('[unhandled]', err); res.status(500).json({ code: 5000, msg: '服务异常' }); }); app.listen(3000, () => console.log('server on 3000'));

中间件是「从上往下」执行的,express.json()必须放在路由挂载之前,否则req.body拿不到东西,这个顺序错一次能查半天。兜底错误处理要写在所有路由之后,Express 靠参数个数四个来识别错误中间件,写成三个参数就变成了普通中间件,不会生效。

2.3 配置项清单:跑之前必须先改的三处

不管模板写得多完整,一定有三处是跟具体环境绑死的,不改就跑不起来。第一处是小程序端的接口地址,通常在utils/request.js顶部;第二处是project.config.json里的appid,用测试号也能跑,但真机预览和云能力会受限;第三处是服务端的数据库连接,账号密码必须换成你本机 MySQL 的。

// utils/request.js // 开发阶段:本机 IP 或 127.0.0.1,开发者工具里可以直接用 // 真机预览:必须换成电脑的局域网 IP,例如 http://192.168.1.8:3000 const BASE_URL = 'http://127.0.0.1:3000'; // server/db/pool.js const mysql = require('mysql2/promise'); // 连接池不要每次请求都新建,进程启动时创建一次即可 const pool = mysql.createPool({ host: '127.0.0.1', port: 3306, user: 'root', // 换成你本机 MySQL 账号 password: '123456', // 换成你本机 MySQL 密码 database: 'mall', waitForConnections: true, connectionLimit: 10, // 本地开发 10 个够用,别开太大 queueLimit: 0 }); module.exports = pool;

connectionLimit是个容易被忽略的参数,本地开发设成 10 就够,设太大会把 MySQL 的最大连接数占满,尤其是在你频繁热重启服务的时候。BASE_URL写成127.0.0.1只在开发者工具里有效,因为工具跑在电脑上,而真机跑在手机上,手机访问不到电脑的 localhost,这一条几乎每个人都栽过,第 5 章会放进踩坑清单里细说。

3. 前端核心链路:从商品列表到订单提交的四段代码

前端这条链路是整个项目里业务密度最高的部分,也是二次开发时改动最频繁的地方。我把request封装、列表分页、SKU 规格选择、购物车同步这四个点拎出来逐段拆,它们正好对应「拿数据—展示数据—做选择—存状态」四个动作,任何一个没写对,后面的下单都会出问题。

3.1 请求封装与统一返回体:先把code判断收口

模板项目最容易出现的问题是每个页面都写一遍wx.request,回调里再判断一遍成功失败,改一次接口地址要改十几处。正确做法是抽一个request出来,把 baseURL、超时、错误提示、返回体解析全收口在里面,页面只关心业务数据。

// utils/request.js const BASE_URL = 'http://127.0.0.1:3000'; // 统一返回体约定:{ code: 0, msg: 'ok', data: {...} } // code === 0 表示成功,其余为业务错误 function request(path, method = 'GET', data = {}) { return new Promise((resolve, reject) => { wx.request({ url: BASE_URL + path, method, data, timeout: 10000, header: { 'content-type': 'application/json' }, success(res) { // HTTP 层成功不代表业务成功,两层都要判断 if (res.statusCode !== 200) { wx.showToast({ title: '服务异常', icon: 'none' }); return reject(res); } const body = res.data || {}; if (body.code === 0) { resolve(body.data); } else { // 业务错误统一交给后端给文案,前端不硬编码 wx.showToast({ title: body.msg || '请求失败', icon: 'none' }); reject(body); } }, fail(err) { wx.showToast({ title: '网络异常', icon: 'none' }); reject(err); } }); }); } module.exports = { request };

这段代码的关键在两层判断:statusCode是 HTTP 协议层的状态,code是业务层的状态。很多新手只判断前者,结果后端返回200 + {code: 4001, msg: '库存不足'},页面照样把空数据渲染出来,用户看到的是白屏而不是提示。timeout建议显式设置,小程序默认超时比较长,接口挂掉时页面会一直转圈,体验很差;10000 毫秒是个折中值,弱网环境下可以放宽到 15000。reject(body)把业务错误对象往外抛,页面里的catch可以拿到msg做进一步处理,比如库存不足时引导用户返回详情页重新选规格。

3.2 商品列表上拉加载:分页参数和hasMore的判断

列表页最容易写错的是分页边界。常见写法是page从 1 开始,请求成功后page + 1,然后用「本次返回条数是否小于 pageSize」判断有没有下一页。这个判断在最后一页刚好满页时会出错,会多发一次空请求。稳妥的做法是让后端同时返回total,前端用page * size < total来算。

// pages/goods/list.js const { request } = require('../../utils/request'); Page({ data: { list: [], page: 1, // 下一次要请求的页码 size: 10, hasMore: true, loading: false }, onLoad() { this.fetchList(true); }, // 触底加载下一页 onReachBottom() { if (this.data.hasMore && !this.data.loading) { this.fetchList(false); } }, // 下拉刷新,重置分页 onPullDownRefresh() { this.fetchList(true).then(() => wx.stopPullDownRefresh()); }, async fetchList(reset) { if (this.data.loading) return; this.setData({ loading: true }); const page = reset ? 1 : this.data.page; try { const res = await request(`/api/goods?page=${page}&size=${this.data.size}`); // res 结构:{ list: [], total: 0 } this.setData({ list: reset ? res.list : this.data.list.concat(res.list), page: page + 1, hasMore: page * this.data.size < res.total, loading: false }); } catch (e) { // 失败时回退 loading,否则上拉再也不触发 this.setData({ loading: false }); } } });

loading这个标志位不只是给骨架屏用的,它同时承担了「请求去重」的职责。小程序的上拉事件触发很密集,滚动一下可能连发三四次,没有这个锁就会重复请求同一页,列表里出现重复商品。reset参数把刷新和加载合并成一个函数,好处是首次进入和下拉刷新走同一条路径,不容易出现「刷新后页码没重置」的问题。catch里必须把loading置回 false,否则一次网络抖动之后这个页面就再也加载不出东西了,这种问题在测试阶段很难复现,上线后用户一反馈就是「加载不动」。

后端对应的查询也很简单,LIMIT offset, size加上一次COUNT(*),两个查询并发发出即可:

// server/controllers/goods.js async function listGoods(req, res) { const page = Math.max(parseInt(req.query.page) || 1, 1); const size = Math.min(parseInt(req.query.size) || 10, 50); // 上限 50,防止被刷 const offset = (page - 1) * size; // 两个查询互不依赖,用 Promise.all 并发,省一半响应时间 const [[rows], [[{ total }]]] = await Promise.all([ pool.query( 'SELECT id, title, cover, price, origin_price FROM goods WHERE status = 1 ORDER BY id DESC LIMIT ?, ?', [offset, size] ), pool.query('SELECT COUNT(*) AS total FROM goods WHERE status = 1') ]); res.json({ code: 0, data: { list: rows, total } }); }

size做了上限截断,这一点在真实项目里很重要:如果不限制,有人传size=100000就能把整张表拉走,接口直接被打挂。WHERE status = 1是软删除标记,商品下架不物理删除,订单里的历史记录才不会断掉。

3.3 SKU 规格组合:把二维规格摊平成一维数组

商品详情页最绕的是规格选择。数据库里通常存两张表:goods_spec存规格名和可选值(颜色:黑/白;尺码:S/M),sku存每一种组合对应的价格和库存。前端要做的两件事:一是把规格渲染成可点的按钮,二是用户点完之后拼出当前组合,去sku列表里匹配出对应的skuId。

// utils/sku.js // 规格结构:[{ name: '颜色', values: ['黑','白'] }, { name: '尺码', values: ['S','M'] }] // 目标:生成所有组合 [['黑','S'], ['黑','M'], ['白','S'], ['白','M']] function buildCombination(specs) { return specs.reduce((acc, spec) => { const next = []; acc.forEach(combo => { spec.values.forEach(v => next.push(combo.concat(v))); }); return next; }, [[]]); } // 根据用户已选的每个规格值,反查 skuId // selected: ['黑', 'M'],skuList: [{ id: 12, specs: ['黑','M'], price, stock }] function matchSku(skuList, selected) { return skuList.find(sku => sku.specs.length === selected.length && sku.specs.every((v, i) => v === selected[i]) ); } module.exports = { buildCombination, matchSku };

reduce的初始值是[[]],一个包含空数组的数组,每遍历一层规格就把已有组合和新值做笛卡尔积,两层规格得到 4 个组合,三层就是 8 个,以此类推。这个写法的好处是不用嵌套循环,规格数量变化时代码不用改。matchSku里必须先比长度再逐项比,只比every的话,['黑']会错误匹配到['黑','S'],因为空位没参与比较。

实际交互里还有一个细节:当某个组合库存为 0 时,对应的规格按钮要置灰。做法是每选一个值就重新算一遍「当前已选集合下,还有哪些值是有货的」,而不是简单地按stock > 0判断。否则会出现用户选了「黑 + S」发现没货,返回去换颜色时发现所有颜色都灰了,体验直接翻车。

3.4 购物车本地存储与 tabBar 角标同步

购物车有两种实现:纯服务端、纯本地、本地 + 登录后合并。模板项目多数用本地存储,好处是未登录也能加购,缺点是换设备就丢了。本地版的代码不复杂,但角标同步这一块很容易漏。

// utils/cart.js const KEY = 'CART_V1'; const CART_TAB_INDEX = 3; // 对应 app.json 里 tabBar 的第 4 项 function getCart() { return wx.getStorageSync(KEY) || []; } function saveCart(list) { wx.setStorageSync(KEY, list); syncBadge(list); } // 角标不是装饰,用户靠它判断有没有加成功 function syncBadge(list) { const count = list.reduce((sum, it) => sum + it.count, 0); if (count > 0) { wx.setTabBarBadge({ index: CART_TAB_INDEX, text: count > 99 ? '99+' : String(count) }); } else { wx.removeTabBarBadge({ index: CART_TAB_INDEX }); } } // 同规格累加,不同规格新增;extra 里存标题、图片、价格用于列表展示 function addToCart(skuId, count = 1, extra = {}) { const list = getCart(); const hit = list.find(it => it.skuId === skuId); if (hit) { hit.count += count; } else { list.push(Object.assign({ skuId, count, checked: true }, extra)); } saveCart(list); return list; } module.exports = { getCart, saveCart, addToCart };

CART_V1里的版本号是给自己留的后路:以后购物车结构变了(比如要加优惠券字段),直接把 key 改成CART_V2,老数据自然失效,不用写迁移逻辑,也不用担心用户本地存着旧结构导致渲染报错。addToCart里把title、cover、price一起存下来,是为了购物车页面不用再发一次请求查商品信息,代价是商品改价后购物车显示的是旧价格。这个取舍要在提交订单时补回来:下单接口必须用数据库里的实时价格重新计算总价,绝不能信前端传过来的金额,否则改个本地存储就能一块钱买手机。

4. 服务端接口与事务:订单落库这一段最容易翻车

前端做得再顺,订单没落对也是白搭。订单模块是整个项目里唯一必须用事务的地方,因为它同时动三张表:扣库存、写订单、写明细,中间任何一步失败都必须整体回滚,否则就会出现「库存扣了但订单没生成」这种对不上账的情况。

4.1 路由分层与错误码约定

接口层保持三层:routes只声明路径和绑定控制器,controllers写业务,db/pool.js提供连接。这样做的好处是加接口时不用动业务代码,改业务时不用碰路由表。

// server/routes/order.js const express = require('express'); const router = express.Router(); const { createOrder, listOrders, closeOrder } = require('../controllers/order'); router.post('/create', createOrder); // 创建订单 router.get('/list', listOrders); // 我的订单列表 router.post('/close', closeOrder); // 手动取消 module.exports = router;

错误码建议按模块分段,不要全用 500。4xxx表示客户端问题(参数错、库存不足),5xxx表示服务端问题(数据库异常、超时)。前端拿到code就能决定是弹提示还是重试,比解析文案可靠得多。我一般会在server/constants/error.js里集中定义:

// server/constants/error.js module.exports = { OK: 0, PARAM_INVALID: 4001, // 参数缺失 STOCK_NOT_ENOUGH: 4002, // 库存不足 ORDER_NOT_FOUND: 4004, // 订单不存在 SERVER_ERROR: 5000 // 服务端异常 };

4.2 下单接口:事务加行锁,别让库存扣成负数

这是整份代码里最值得逐行看的一段。核心有三点:整个流程包在事务里、查库存时加FOR UPDATE、异常时回滚且释放连接。

// server/controllers/order.js const pool = require('../db/pool'); const E = require('../constants/error'); async function createOrder(req, res) { const { openid, items, addressId } = req.body; // items 结构:[{ skuId: 12, count: 2 }, ...] if (!openid || !Array.isArray(items) || items.length === 0 || !addressId) { return res.json({ code: E.PARAM_INVALID, msg: '参数不完整' }); } const conn = await pool.getConnection(); try { await conn.beginTransaction(); let totalFee = 0; for (const it of items) { // FOR UPDATE 锁住这一行,别的事务在这一步会等待 const [rows] = await conn.query( 'SELECT price, stock FROM sku WHERE id = ? FOR UPDATE', [it.skuId] ); if (rows.length === 0) throw new Error('SKU_NOT_FOUND'); if (rows[0].stock < it.count) throw new Error('STOCK_NOT_ENOUGH'); // 金额用分做整数运算,避免浮点误差 totalFee += rows[0].price * it.count; await conn.query( 'UPDATE sku SET stock = stock - ? WHERE id = ?', [it.count, it.skuId] ); } // 订单号:时间戳 + 随机数,生产环境建议用雪花算法 const orderNo = 'SO' + Date.now() + Math.floor(Math.random() * 900 + 100); const [result] = await conn.query( 'INSERT INTO orders (order_no, openid, address_id, total_fee, status) VALUES (?, ?, ?, ?, ?)', [orderNo, openid, addressId, totalFee, 'WAIT_PAY'] ); // 明细从 sku 表里取价格,不信任前端传的金额 for (const it of items) { await conn.query( `INSERT INTO order_item (order_id, sku_id, count, price, title) SELECT ?, id, ?, price, title FROM sku WHERE id = ?`, [result.insertId, it.count, it.skuId] ); } await conn.commit(); res.json({ code: E.OK, data: { orderNo, totalFee } }); } catch (err) { await conn.rollback(); const msg = err.message === 'STOCK_NOT_ENOUGH' ? '库存不足' : err.message === 'SKU_NOT_FOUND' ? '商品已下架' : '下单失败'; res.json({ code: E.SERVER_ERROR, msg }); } finally { conn.release(); // 无论成败都要还回连接池 } }

参数说明:openid是用户标识,由登录接口下发;items是购物车里勾选的商品数组,每项只需要skuId和count;addressId是收货地址主键。totalFee由服务端根据sku.price累加得出,单位是分,前端展示时除以 100。

FOR UPDATE是整个接口的关键。没有它,两个用户同时下单时,双方都读到stock = 1,都判断「库存够」,都执行扣减,最后库存变成 -1。加了行锁之后,第二个事务会在SELECT这一步阻塞,等第一个事务提交后才继续,此时读到的已经是扣减后的值,判断就会失败。代价是并发性能下降,同一个 SKU 的下单会串行化,但商城场景下单量远没到需要担心的量级,正确性优先。

finally里的conn.release()也不能省。getConnection是从池子里借的,用完不还,池子会被借空,表现是服务跑一段时间后所有接口都超时,日志里报连接数耗尽。

4.3 登录态:code 换 openid 与会话保持

小程序的登录不需要账号密码,流程是前端调wx.login拿一个临时的code,传给服务端,服务端拿code去开放平台换openid,然后把openid作为用户标识返回。这里有三个约束:code五分钟过期、只能用一次、不能在前端直接换。

// server/controllers/auth.js const axios = require('axios'); const pool = require('../db/pool'); // 从环境变量读,别把 appid/secret 写死在代码里提交到仓库 const { APPID, SECRET, SESSION_URL } = process.env; async function login(req, res) { const { code } = req.body; if (!code) return res.json({ code: 4001, msg: '缺少 code' }); try { const { data } = await axios.get(SESSION_URL, { params: { appid: APPID, secret: SECRET, js_code: code, grant_type: 'authorization_code' }, timeout: 5000 }); if (!data.openid) { return res.json({ code: 4002, msg: '登录失败' }); } // 首次登录落库,已存在则忽略 await pool.query('INSERT IGNORE INTO users (openid) VALUES (?)', [data.openid]); // 简化方案:直接用 openid 当 token;正式项目应签 JWT 并设过期时间 res.json({ code: 0, data: { openid: data.openid } }); } catch (e) { res.json({ code: 5000, msg: '登录服务异常' }); } } module.exports = { login };

前端在app.js的onLaunch里调一次,把openid存到wx.setStorageSync,后续所有需要用户身份的接口都带上它。这套简化方案在课程设计里够用,但要知道它的风险:openid相当于明文令牌,泄露了就能冒充。正式项目里应该签一个带过期时间的 JWT,并且在服务端做校验中间件,把校验逻辑和业务逻辑分开。

4.4 订单状态机:状态少但流转不能乱

订单状态看着简单,实际上是最容易写出脏数据的地方。常见做法是定一个状态枚举,所有变更都走一个统一的updateStatus函数,函数里做「当前状态是否允许流转到目标状态」的校验。

状态值含义触发动作允许流转到
WAIT_PAY待支付下单成功PAID、CLOSED
PAID已支付支付回调SHIPPED、REFUNDING
SHIPPED已发货后台发货DONE
DONE已完成确认收货无
CLOSED已关闭超时未付、主动取消无
-- 超时关单:每分钟扫一次,超过 30 分钟未支付的全部关闭 UPDATE orders SET status = 'CLOSED' WHERE status = 'WAIT_PAY' AND created_at < DATE_SUB(NOW(), INTERVAL 30 MINUTE);

这条 SQL 依赖(status, created_at)上的索引,否则每分钟一次全表扫描,订单表涨起来之后数据库会被拖慢。另一个坑是关单之后必须把库存加回去,这一步同样要放在事务里,单独的UPDATE orders会造成库存永久丢失。

5. 本地联调与避坑排查:五条血泪记录

前面几章讲的是「应该怎么写」,这一章讲「写完之后为什么跑不起来」。我把这类项目里最高频的五个问题按「现象—原因—解决」整理出来,基本都是我自己或者身边人真实撞过的,遇到时按顺序对照,能省掉大量盲猜时间。

5.1 起服务的正确顺序与联调姿势

顺序错了会白白浪费半小时。正确的做法是:先把数据库建好、导入init.sql、确认表里有数据;再起服务端,用浏览器或接口工具单独调一遍/api/goods,确认返回正常;最后才导入小程序端。这个顺序的意义在于把问题隔离在单层——如果接口工具调不通,说明问题在服务端或数据库,跟小程序没关系,排查范围立刻缩小一半。

导入小程序端之后,第一件事是打开开发者工具的「不校验合法域名」选项。开发阶段接口是http://的,小程序默认只允许https且必须是配置过的域名,不开这个开关,请求会直接在发送前被拦截,控制台报域名不合法,页面表现为一直加载中。

5.2 五条踩坑记录:现象、原因、解决

第一条:开发者工具里一切正常,手机扫码后列表空白。

现象是手机真机预览时页面能渲染,但商品列表一直是空的,偶尔弹「网络异常」。原因是BASE_URL写的是127.0.0.1,工具跑在电脑上所以能通,手机跑在手机上,127.0.0.1指向手机自己,自然连不上。解决办法是换成电脑的局域网 IP(ipconfig或ifconfig查),手机和电脑连同一个 WiFi,同时确认电脑防火墙没拦 3000 端口。这一步还有个隐藏问题:换了 IP 之后要重新编译,工具不会自动热更这个常量。

第二条:接口返回 200,但前端res.data.code永远是 undefined。

现象是网络面板里能看到完整的 JSON,代码里读code却读不到,页面卡在成功和失败之间。原因通常是服务端没挂express.json(),req.body是 undefined,接口逻辑走偏返回了别的东西;或者返回时用了res.send(obj)而不是res.json(obj),content-type不是application/json,前端拿到的是字符串。解决办法是在app.js里把express.json()放在所有路由之前,返回统一用res.json。我习惯在request.js里加一行console.log('raw', res.data),先看原始数据长什么样再判断。

第三条:压测时库存被扣成负数。

现象是两个账号同时下单同一件只剩 1 件库存的商品,两笔订单都成功了,库存变成 -1。原因是查询和更新是两条独立语句,中间没有锁,两个事务读到了同一个旧值。解决办法是把SELECT换成SELECT ... FOR UPDATE,并且整个流程包在beginTransaction和commit之间。改完之后要验证:开两个终端同时发请求,应该只有一个成功,另一个返回「库存不足」。

第四条:服务跑一天之后所有接口超时。

现象是刚启动时一切正常,跑几个小时后接口开始变慢,最后全部超时,日志里出现连接数相关的报错。原因是某条异常分支里getConnection之后没走到release,连接被永久占用,池子慢慢被借空。解决办法是把release放进finally,或者在异常分支里也显式释放。这条坑的隐蔽之处在于它不会立刻暴露,本地开发经常重启服务,永远复现不了,一上线就出问题。

第五条:上传代码提示主包超过 2M。

现象是开发者工具里点上传,报包体积超限,项目根本传不上去。原因是商品图、分类图标、轮播图全都放在小程序包内,一张详情图几百 KB,十几张就满了。解决办法是把图片全部挪到对象存储或者图床,小程序里只存 URL,本地最多留一张占位图。如果图片实在多,可以再开分包,把订单、个人中心这些非首屏页面拆出去,主包只留首页和商品详情。

6. 二次开发:把模板改成能交付的商城

模板能跑通只是起点,真正要交出去还得改成自己的业务。改动最大的一块通常是数据源,其次是营销模块。我建议的顺序是先换数据再补功能,因为换数据的过程会逼你把整套表结构和接口约定重新过一遍,后面加功能才不会拧着来。

换数据源的做法很直接:把自己手上的商品整理成 CSV,字段对齐goods和sku两张表,用LOAD DATA或者写一个导入脚本灌进去。注意sku表里的specs字段存的是 JSON 数组,顺序必须和goods_spec里定义的规格顺序一致,否则前端matchSku会匹配不上。改完之后拿SELECT COUNT(*)和前端列表总数对一遍,数字对得上说明链路通了。

加一个优惠券模块是比较典型的二次开发练习,改动点集中在三处:新建coupon和user_coupon两张表,下单接口里在算完totalFee之后加一段抵扣逻辑,前端确认订单页加一个选择弹窗。抵扣逻辑要注意金额单位统一用分,并且做最低消费校验:

// 券抵扣:amount 为券面额(分),threshold 为门槛(分) function applyCoupon(totalFee, coupon) { if (!coupon) return { payFee: totalFee, discount: 0 }; if (totalFee < coupon.threshold) return { payFee: totalFee, discount: 0 }; // 抵扣不能超过订单金额,否则会出现负数支付 const discount = Math.min(coupon.amount, totalFee); return { payFee: totalFee - discount, discount }; }

改完之后别急着加新功能,先跑一遍回归。我自己的清单是四步:新用户首次下单能不能走通、库存不足时提示对不对、取消订单后库存有没有加回来、订单列表的分页在第二页之后还正不正常。这四步覆盖了事务、状态流转、分页三个最容易出问题的点,跑一遍大概十分钟,比上线后被人追着问强得多。

从那以后我每次改这类项目,都会先把init.sql重新导一遍、用一个全新账号走一次完整下单,确认没有依赖本地残留数据才敢往下加功能——模板代码里藏着的隐式依赖,往往就是这么被揪出来的。希望帮到你。

本文还有配套的精品资源,点击获取