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

发布时间:2026/10/29 4:02:22
小程序商城源码实战:跑通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.mdapp.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做了上限截断这一点在真实项目里很重要如果不限制有人传size100000就能把整张表拉走接口直接被打挂。WHERE status 1是软删除标记商品下架不物理删除订单里的历史记录才不会断掉。3.3 SKU 规格组合把二维规格摊平成一维数组商品详情页最绕的是规格选择。数据库里通常存两张表goods_spec存规格名和可选值颜色黑/白尺码S/Msku存每一种组合对应的价格和库存。前端要做的两件事一是把规格渲染成可点的按钮二是用户点完之后拼出当前组合去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和countaddressId是收货地址主键。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、CLOSEDPAID已支付支付回调SHIPPED、REFUNDINGSHIPPED已发货后台发货DONEDONE已完成确认收货无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指向手机自己自然连不上。解决办法是换成电脑的局域网 IPipconfig或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重新导一遍、用一个全新账号走一次完整下单确认没有依赖本地残留数据才敢往下加功能——模板代码里藏着的隐式依赖往往就是这么被揪出来的。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询