首页 / 资讯中心 / 文章详情

微信小程序商店完整代码:库存并发、订单事务与回调幂等

微信小程序商店完整代码:库存并发、订单事务与回调幂等 ★ FEATURED ARTICLE
简介面向微信小程序开发者与计算机专业学习者的商店类项目源码包围绕在微信内浏览、搜索与分发小程序的常见需求提供可研究、可复用的完整实现参考。代码覆盖商店前端展示、页面模板、登录入口、图片素材、全局配置与业务逻辑等层次适合用来理解WXML/WXSS页面组织、JavaScript交互处理、JSON路由配置以及工具模块的协作方式也能为自建小程序商店的架构设计与功能拆分提供样本。压缩包共57个文件以17个png图片资源、9个js脚本、9个json配置、8个wxml页面与8个wxss样式为主另有说明文档整体约131KB体量轻便便于逐层阅读与本地对照。已有4042人学习说明其在同类商店项目参考中具备一定关注度。读者可从中获取商店页面结构、配置组织、静态素材与基础业务逻辑的完整组合并据此梳理前端展示与数据请求之间的衔接关系作为课程实践、毕业设计或功能原型的起步材料。1. 从一份能跑通的代码开始这套小程序商店到底解决了什么去年帮一个做本地零售的朋友看项目他招了个兼职开发者对方交付时留了句“页面都能点接口也在”结果商品详情页的 SKU 切换会错位下单后库存不扣、支付回调重复触发两次。这类“看着完整、一跑就露馅”的小程序商店代码在接私活和课程设计里太常见了。这份微信小程序商店完整代码给我的第一印象是它把商品列表、分类筛选、购物车、订单创建、支付回调、库存扣减这些商店核心链路都串了起来而且每个环节没有停在“能演示”的程度下单事务和回调幂等这两处明显是踩过坑之后补的。适合两类人一类是想拿一套可运行的底座改出自己店铺的开发者另一类是在课程设计里需要讲清楚“订单状态为什么不能随便改”的学生。后面的内容不泛讲小程序生命周期只拆这套代码里真正需要你动手调的部分——目录怎么读、数据库怎么建、库存并发怎么防、回调验签怎么验。2. 把项目跑起来目录分层、云环境初始化与最短联调闭环2.1 先看清目录里有什么页面层、逻辑层、云函数层的职责边界拿到代码包后不要急着点编译先花十分钟把目录结构过一遍。这套代码的分层比较克制没有为了“架构感”堆中间件前端就是原生小程序目录后端集中在云函数里数据库初始化脚本单独放。常见做法是下面这种布局你对照自己拿到的包确认各目录是否齐全即可目录/文件职责你通常要改什么pages/商品列表、详情、购物车、订单、个人中心五个页面页面路径、分享标题、样式变量components/商品卡片、数量步进器、空状态三个复用组件卡片字段映射、步进器最大最小值utils/请求封装、价格格式化、订单状态枚举云环境 ID、请求超时时间cloudfunctions/goods、cart、order、payCallback四个云函数数据库集合名、支付密钥配置db/集合初始化 JSON 与索引说明按你的类目调整字段需要留意的是utils/request.js里通常有一个envId常量这是整套代码能不能跑起来的第一道开关。很多人把代码导入开发者工具后直接点“预览”报错“云函数调用失败”翻半天代码最后发现是云环境 ID 还是作者模板里的占位值。我一般会先全局搜envId和your-env-id这类字样把它替换成自己云开发控制台里的环境 ID再往下走。2.2 云开发环境初始化与三个基础集合的建表这套代码默认走微信云开发不依赖自建服务器。你需要在开发者工具里开通云开发拿到环境 ID然后在云开发控制台的数据库面板里建三个基础集合goods、categories、orders。购物车数据这套代码放在本地缓存加云函数合并所以不强制建carts集合但如果你要多端同步购物车可以自己补一个。建集合时最容易忽略的是权限设置。goods和categories需要在小程序端直接读取权限要设成“所有用户可读仅创建者可写”orders涉及用户订单设成“仅创建者可读写”。我见过有人图省事全部设成“所有用户可读写”结果测试时一个账号能把别人的订单改掉这种配置带到线上就是事故。数据库建好后把db/目录里的初始化 JSON 导入对应集合。如果控制台导入时报字段类型不匹配检查一下price字段这套代码的价格统一用“分”存整数避免浮点误差导入前确认 JSON 里没有19.9这种小数。导入完成后在goods集合上建一个categoryId的普通索引列表页按分类筛选时会用到再建一个status加createTime的组合索引用于只查上架商品并按时间倒序。2.3 本地联调把商品列表从数据库拉到页面上的最短闭环环境配好后先不要跑完整流程用一个最小闭环验证“前端能调到云函数、云函数能读到数据库”。打开cloudfunctions/goods/index.js确认列表查询逻辑类似下面这样// cloudfunctions/goods/index.js const cloud require(wx-server-sdk) cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }) const db cloud.database() exports.main async (event) { const { categoryId , page 1, pageSize 10 } event const where { status: 1 } // 1 表示上架0 表示下架 if (categoryId) where.categoryId categoryId // skip 按页码偏移limit 控制单页条数避免一次拉全表 const list await db.collection(goods) .where(where) .orderBy(createTime, desc) .skip((page - 1) * pageSize) .limit(pageSize) .get() const total await db.collection(goods).where(where).count() return { code: 0, data: list.data, total: total.total } }这段逻辑有三个参数需要你按实际情况调pageSize默认 10商品图较大的类目建议降到 6 到 8否则首屏加载会明显变慢status字段要和你的商品表约定一致有的代码用onSale布尔值别混用orderBy(createTime)要求每条商品都有这个字段导入数据时缺这个字段的商品不会出现在列表里联调时如果发现“数据库有数据但页面是空的”先查这个字段。前端侧在pages/index/index.js的onLoad里调用云函数拿到结果后setData到列表变量。验证顺序建议是先在云开发控制台手动造两条status: 1的商品记录再编译预览看到列表渲染出这两条说明环境、权限、云函数、数据库四层都通了。这一步跑通之前不要往下做购物车否则后面报错你分不清是环境问题还是业务逻辑问题。3. 商品与分类的数据结构列表分页、详情渲染与 SKU 联动的实现细节3.1 商品集合的字段设计与索引策略这套代码的商品字段不算多但有几个字段的命名和类型如果一开始定错后面改起来会牵动列表、详情、订单快照三处。常见做法是按下面这张表来对齐字段类型说明容易踩的坑_idstring云数据库自动生成不要用商品编码当_id改价改规格时不好迁移titlestring商品名称列表页截断用 CSS不要在后端截pricenumber单位分前端展示时除以 100不要在数据库存小数originPricenumber划线价单位分没有划线价时传 0不要传 nullcategoryIdstring关联分类集合分类删除前先查有没有商品引用statusnumber1 上架 / 0 下架下架商品详情页要返回明确提示不能直接 404skusarray规格数组含name、price、stock库存扣减按 SKU 维度不按商品维度detailstring富文本详情图片域名要加进小程序合法域名skus是这套代码里比较关键的设计。很多简易商店把库存放在商品级别结果用户选了“红色 XL”下单扣的是总库存另一个规格明明还有货却显示售罄。这套代码要求每个 SKU 有自己的stock和price下单时按 SKU 扣减。你如果接手后要加规格记得同步改三处详情页的 SKU 选择器、下单云函数的库存校验、订单快照里的规格记录。索引方面除了前面说的categoryId和status createTime如果商品量超过几千条建议在title上建一个文本索引用于搜索。但要注意云数据库的文本索引对中文分词支持有限商品名短、类目明确时够用商品名长且口语化时搜索命中率会下降这时候不如在前端加一个分类加关键词的双重筛选。3.2 列表分页加载下拉刷新与上拉触底的参数控制列表页的分页逻辑看起来简单但翻车点集中在“重复请求”和“页码错乱”两处。这套代码在onReachBottom里做了加载锁核心逻辑如下// pages/index/index.js 片段 Page({ data: { list: [], page: 1, pageSize: 10, loading: false, hasMore: true }, async loadGoods(reset false) { if (this.data.loading) return if (!reset !this.data.hasMore) return this.setData({ loading: true }) const page reset ? 1 : this.data.page const res await wx.cloud.callFunction({ name: goods, data: { page, pageSize: this.data.pageSize, categoryId: this.data.categoryId } }) const { data, total } res.result const newList reset ? data : this.data.list.concat(data) this.setData({ list: newList, page: page 1, hasMore: newList.length total, loading: false }) }, onReachBottom() { this.loadGoods(false) }, onPullDownRefresh() { this.loadGoods(true).then(() wx.stopPullDownRefresh()) } })这里loading锁是必须的否则用户快速上滑会连发多次请求同一个页码被拉两遍列表出现重复商品。hasMore用累计条数和total比较而不是用“本页返回条数小于 pageSize”判断因为后端可能因为权限过滤导致实际返回条数少于请求条数用累计值更稳。reset参数在切换分类时传true同时要把page重置回 1否则会出现“切了分类还从第 3 页开始拉”的错位。还有一个细节下拉刷新结束后一定要调wx.stopPullDownRefresh()这套代码放在then里如果云函数报错then不执行刷新动画会一直转。稳妥写法是放在finally里或者用try/finally包住。这个点不致命但用户体感很差测试时很容易被当成“小程序卡死”。3.3 详情页的富文本渲染与 SKU 联动选择商品详情页有两个独立问题富文本里的图片宽度溢出以及 SKU 选择后价格和库存不联动。富文本用rich-text组件渲染时后端返回的 HTML 里图片如果带固定宽度在窄屏上会撑出横向滚动。这套代码在utils/format.js里做了一个简单的正则替换把img标签的width样式去掉并加上max-width:100%。如果你不想动 HTML 字符串也可以在外层容器上加overflow-x: hidden但那是遮盖问题不是解决。SKU 联动的核心是“选中规格组合后找到对应 SKU 对象”。代码里的数据结构大致是skus: [{ name: 红色-M, price: 2990, stock: 12 }, ...]选择器每次变更时做一次查找// pages/detail/detail.js 片段 onSkuChange(e) { const selected e.detail.value // 例如 [红色, M] const key selected.join(-) const sku this.data.goods.skus.find(item item.name key) if (!sku) { wx.showToast({ title: 该规格暂时缺货, icon: none }) return } this.setData({ currentSku: sku, displayPrice: (sku.price / 100).toFixed(2), canBuy: sku.stock 0 }) }这里name用“颜色-尺码”拼接作为匹配键好处是直观坏处是运营在后台改规格名时容易多空格或者中英文横杠混用导致匹配不到。血泪经验是SKU 的name不要手工输入后台加规格时用选择器拼出来或者干脆在 SKU 对象里单独存color和size两个字段前端按字段匹配别依赖字符串拼接。这套代码目前是字符串方案你如果要上生产建议改成字段匹配改动量不大但能省掉一批“规格选不了”的客服问题。4. 购物车与下单链路状态同步、库存预扣与订单状态机的落地4.1 购物车本地状态与云端同步加购、改数量、删除的三条路径这套代码的购物车默认走“本地缓存为主、云函数合并为辅”的方案未登录时购物车存在wx.setStorageSync里登录后调cart云函数把本地条目合并到云端。这样做的原因是小程序端直接操作云数据库的购物车集合会带来频繁的读写和权限校验开销而纯本地又无法多端同步。加购逻辑在商品详情页触发写入本地缓存前要先查是否已存在相同goodsId skuName的条目存在则数量累加不存在则新增。改数量时要注意上下限下限是 1上限取sku.stock和业务限制的较小值。删除操作建议做成“左滑删除加二次确认”这套代码用的是长按弹窗确认实现更简单但体感上不如左滑。无论哪种删除后要同步更新本地缓存和页面上的合计金额不要只删列表项忘了算总价。合并逻辑在登录回调里触发常见做法是把本地条目逐条推给云函数云函数按openid goodsId skuName做 upsert数量取“本地加云端”还是“取较大值”要提前定。这套代码取的是相加适合“换设备后想把两边都保留”的场景但如果你担心用户重复加购导致数量虚高可以改成取较大值。这个策略没有绝对对错关键是前后端要一致别前端显示 3 件、云端算出 5 件。4.2 下单事务库存预扣、订单快照与超时释放下单是整套代码里最值得细看的部分。很多简易商店的下单流程是“查库存、插订单、扣库存”三步分开执行并发一上来就超卖。这套代码把扣库存放在云函数里用数据库事务包住核心逻辑如下// cloudfunctions/order/create.js 片段 const cloud require(wx-server-sdk) cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }) const db cloud.database() const _ db.command exports.main async (event, context) { const { OPENID } cloud.getWXContext() const { goodsId, skuName, count } event return db.runTransaction(async transaction { const goodsRes await transaction.collection(goods).doc(goodsId).get() const goods goodsRes.data const sku goods.skus.find(s s.name skuName) if (!sku || sku.stock count) { throw new Error(库存不足) } // 按 SKU 精准扣减条件里带 stock 大于等于 count防止并发覆盖 await transaction.collection(goods).doc(goodsId).update({ data: { skus: goods.skus.map(s s.name skuName ? { ...s, stock: _.inc(-count) } : s ) } }) // 订单里存商品快照避免商品改价后历史订单金额对不上 const order { openid: OPENID, goodsId, skuName, count, snapshot: { title: goods.title, price: sku.price, skuName }, status: pending, createTime: db.serverDate() } const addRes await transaction.collection(orders).add({ data: order }) return { orderId: addRes._id } }) }事务里先读后写读的时候拿到当前skus数组写的时候用_.inc(-count)而不是直接赋新值是为了让数据库层面做原子增减减少“读到的库存和写回的库存不一致”的窗口。不过要注意云数据库事务对同一文档的并发写有重试机制高并发下仍可能抛事务冲突外层要捕获错误并提示用户重试不要直接把原始错误抛给前端。订单快照snapshot字段是另一个容易被省略但后期很要命的设计。商品改价、改规格名之后历史订单如果只存goodsId回头查订单详情时显示的是新价格对账就对不上。这套代码在创建订单时把标题、单价、规格名固化下来后面商品怎么改都不影响历史订单展示。超时释放这块代码里用的是定时触发器加状态扫描订单创建后状态为pending超过 15 分钟未支付则改为closed并回补库存。15 分钟这个值在cloudfunctions/order/config.js里你可以按业务调但不要设得太短微信支付调起、用户输密码、网络抖动都需要时间设 5 分钟以内很容易出现“用户正在付、订单已被关”的尴尬。回补库存同样要用事务或者至少用_.inc(count)不要读出来加一写回去。4.3 订单状态机的流转与回调幂等订单状态不要散落在各个页面里用字符串硬编码这套代码在utils/orderStatus.js里集中定义了状态和允许的流转当前状态允许流转到触发动作pendingpaid/closed支付成功 / 超时或手动取消paidshipped/refunding发货 / 发起退款shippedcompleted/refunding确认收货 / 申请退款refundingrefunded/paid退款完成 / 退款驳回状态机集中管理的好处是支付回调里只做一件事把pending改成paid并且用条件更新保证只改一次。这套代码的回调云函数里更新语句带了status: pending条件如果订单已经是paid更新影响行数为 0直接返回成功不重复发货、不重复记账。这个就是回调幂等的最小实现。需要提醒的是微信支付的回调可能因为网络原因重试多次也可能和前端主动查询支付结果的请求同时到达。如果你的代码里前端在onShow里查一次订单状态、回调又改一次两处都要走同一个条件更新逻辑不要一处用条件更新、另一处直接set。我见过一个项目前端查询到“未支付”就自己把订单标成关闭结果回调晚到一秒钱收了订单却关了最后人工对账才捞回来。订单状态的写入口越少越好这套代码把写入口收敛到云函数里前端只读不写这个约束值得保留。5. 常见问题排查五个真实翻车场景与处理路径现象一本地预览正常真机上下单提示“库存不足”但后台看库存明明够。原因通常是真机请求走了正式环境而商品数据只导入到了测试环境两个环境的goods集合数据不一致也可能是 SKU 的name在真机上多了一个空格匹配不到对应 SKU代码把“找不到 SKU”和“库存不足”合并成了同一个报错。解决方式是先把错误信息拆开找不到 SKU 时单独提示“规格不存在”再检查两个环境的集合数据是否同步最后在详情页把选中的skuName打印出来和数据库里的值逐字符比对。现象二支付成功后订单状态还是待支付刷新页面才变。原因是前端在wx.requestPayment的success回调里直接改了本地订单状态没有等支付回调到达或者回调云函数抛错但没被记录。先看云开发控制台的云函数日志确认payCallback有没有被调用、有没有报签名校验失败。如果回调根本没到检查支付配置里的回调地址和云函数名是否对应如果到了但报错大概率是验签密钥配错或者订单号字段取错。前端success回调只应该做“跳转到结果页并轮询查询”不应该直接写订单状态。现象三列表下拉刷新后上拉加载出现重复商品。原因是loading锁只加在了上拉加载里下拉刷新没有重置page或者没有清空旧列表。检查onPullDownRefresh里是否调用了loadGoods(true)以及reset分支里page是否回到 1、list是否被替换而不是拼接。另一个隐蔽原因是云函数分页用了skip如果在两次请求之间有人新增了商品排序会整体后移导致第二页和第一页出现重叠。商品更新频繁的场景分页应该改用createTime游标而不是skip。现象四购物车合计金额和订单结算金额差几分钱。这是典型的浮点计算问题。这套代码在数据库里存分但购物车页面如果有一处用了price / 100之后再做乘加就会出现0.1 0.2类误差。排查方法是全局搜/ 100确认所有金额计算都在“分”的单位下用整数完成只在最终展示时除一次。如果已经出现了历史脏数据写一个一次性脚本按订单快照重新计算合计不要靠前端四舍五入遮盖。现象五定时触发器的超时关单没有生效待支付订单越积越多。先看云开发控制台的定时触发器有没有配置、触发日志有没有执行记录。常见原因是触发器配置的 cron 表达式写成了本地时区习惯而云环境按 UTC 执行导致实际触发时间和预期差 8 小时。另一个原因是关单逻辑里查询条件写的是createTime 15分钟前但createTime存的是db.serverDate()查询时用了前端传的时间戳两边时区或格式不一致查不到数据。统一用数据库服务端时间做比较并且每次扫描限制条数避免一次拉太多导致云函数超时。6. 上线前的三道验证回调验签、库存对账与并发压测支付回调这块很多人只测“支付成功”一条路径不测“重复回调”和“伪造回调”。这套代码的payCallback里做了签名校验但密钥是配在云函数环境变量里的你部署时确认环境变量名和代码里读的键名一致别用默认占位值。验证重复回调的方法很简单在本地用相同的订单号手动调两次回调云函数看第二次是否返回成功但订单状态和库存不再变化。如果第二次仍然扣了库存说明条件更新的status: pending没生效或者被绕过了。库存对账建议写一个独立的云函数每天跑一次逻辑是“订单表里所有已支付订单的 SKU 数量之和”对比“商品表当前库存加已售数量”。这个脚本不需要多复杂关键是能发现“库存被扣了但订单没生成”或者“订单生成了但库存没扣”的偏账。下面是一个最小对账片段// cloudfunctions/checkStock/index.js const cloud require(wx-server-sdk) cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }) const db cloud.database() const _ db.command exports.main async () { const paidOrders await db.collection(orders) .where({ status: _.in([paid, shipped, completed]) }) .limit(1000) .get() const soldMap {} paidOrders.data.forEach(order { const key ${order.goodsId}_${order.skuName} soldMap[key] (soldMap[key] || 0) order.count }) const goodsList await db.collection(goods).limit(1000).get() const diffs [] goodsList.data.forEach(goods { goods.skus.forEach(sku { const key ${goods._id}_${sku.name} const sold soldMap[key] || 0 // 这里假设商品表里另存了 initialStock没有的话只做日志告警 if (goods.initialStock sku.stock sold ! goods.initialStock[key]) { diffs.push({ key, stock: sku.stock, sold }) } }) }) return { checked: goodsList.data.length, diffs } }这段脚本的参数你要按自己表结构调整initialStock如果没存就改成只输出soldMap供人工比对。limit(1000)是云数据库单次查询上限附近的值数据量大时要分页拉不要指望一次全取。并发压测不用上重型工具在开发者工具里写一个循环调下单云函数的脚本同时开 20 个并发对同一个 SKU 下单数量设为库存的一半跑完看库存是不是刚好扣完、有没有出现负库存、订单数和扣减数是否一致。我一般会连跑三轮第一轮看功能第二轮看重复回调第三轮把网络调慢看超时关单和库存回补。从那以后我每次接这类商店代码都强制先跑一遍“重复回调加并发下单”再谈改需求因为这两个场景不验证后面所有功能都建立在流沙上。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站