简介:这份小程序商城源码包面向微信小程序开发初学者、课程设计与毕业设计学生,以及需要快速搭建在线购物演示项目的开发者,围绕商品展示、购物车、订单结算、用户中心与营销活动等模块提供可运行的前端实现参考。压缩包共68个文件,约3.32MB,以30张png与4张jpg/jpeg界面图、9个js逻辑脚本、7个wxss样式表、7个wxml页面结构及7个json配置为主,并附带说明压缩包,便于对照页面结构与代码组织方式。已有651人学习。读者可从中理清小程序商城的目录层级、页面跳转、组件样式与数据交互思路,借助现成页面和配置快速复现商品列表、详情、购物车、结算等流程,并在此基础上改造为课程设计或毕业设计原型;对需要理解微信小程序开发框架与商城业务闭环的读者较有参考价值。
1. 商城小程序从"能跑"到"能讲清楚":这套源码补的是哪块缺口
最常见的翻车现场是这样:模板下载下来能打开,首页轮播在转,商品列表也有数据,但翻两页代码就露馅——价格写死在 js 里,购物车用全局变量存,提交订单时前端自己判断"库存够不够",服务端只负责 insert。这种东西自己演示没问题,老师问一句"两个人同时下最后一件库存怎么办"就答不上来了。这份商城小程序源码要补的就是这块:它不是一堆页面拼出来的样子货,而是围绕"浏览商品 → 加购物车 → 提交订单 → 支付回调改状态"这条主链路组织起来的完整工程,客户端五个业务模块,服务端一套可跑通的接口,订单、库存、支付状态三处都留了可讲的设计点。它适合两类人:要交课程设计或毕业设计、需要一套结构经得起追问的项目骨架的同学;以及要快速搭一个能演示、能二次开发的商城原型的开发者。源码只是起点,真正值钱的是它背后那套组织方式。
2. 需求拆解与技术选型:先定页面清单,再定三处取舍
动手改代码之前,我一般会先把"有哪些页面、每个页面调哪个接口、数据从哪张表来"这三件事对齐。很多同学拿到源码就直接改样式,改到一半发现购物车和订单的数据结构对不上,只能推倒重来,这一类折腾完全可以在第一天避免。
2.1 角色与页面清单:买家端十一个页面怎么排
商城类项目的角色可以很复杂,也可以很克制。课程设计和毕设这个量级,我建议只做两类角色:买家端(小程序)和管理端(网页或小程序内嵌的简易后台)。管理端不需要做权限体系,一个账号密码登录够用,功能收敛到"商品增删改、订单查看发货、分类维护"三件事,把精力留给买家端的主链路。
买家端的页面数量看着多,其实可以按"流量页—决策页—交易页—资产页"四段来切:
| 分段 | 页面 | 路径 | 核心交互 | 主要依赖接口 |
|---|---|---|---|---|
| 流量页 | 首页 | pages/index | 轮播、分类入口、推荐位 | /api/banner、/api/category |
| 流量页 | 分类页 | pages/category | 左侧分类、右侧商品 | /api/category、/api/goods |
| 决策页 | 商品列表 | pages/goods/list | 下拉刷新、触底加载 | /api/goods |
| 决策页 | 商品详情 | pages/goods/detail | SKU 选择、加购、立即购买 | /api/goods/:id |
| 交易页 | 购物车 | pages/cart | 单选全选、改数量、结算 | /api/cart |
| 交易页 | 确认订单 | pages/order/confirm | 地址选择、金额核算、提交 | /api/order |
| 交易页 | 支付结果 | pages/order/result | 轮询订单状态 | /api/order/:no |
| 资产页 | 订单列表 | pages/order/list | 状态筛选、取消、确认收货 | /api/order |
| 资产页 | 订单详情 | pages/order/detail | 状态时间轴、物流占位 | /api/order/:no |
| 资产页 | 地址管理 | pages/address | 增删改、设默认 | /api/address |
| 资产页 | 个人中心 | pages/mine | 登录、订单入口 | /api/user/profile |
这张表的价值在于:每一个页面都能反查到接口,每一个接口都能反查到表。答辩时老师随便指一个页面,你都能顺着说下去——页面调什么、接口做什么、表里存什么,这条链路讲得顺,项目的完整度就立住了。反过来,如果某个页面找不到对应接口(比如"猜你喜欢"是前端写死的假数据),提前标出来,别等被问到才解释。
2.2 三个选型取舍:原生框架、自建服务端、关系型数据库
技术选型这一块,能讲出"为什么不用另一个"比"用了什么"重要得多。这个项目的场景是课程设计/毕业设计 + 可演示原型,我按三个维度做了取舍:
| 维度 | 选项 A | 选项 B | 这个项目选谁 | 理由 |
|---|---|---|---|---|
| 客户端框架 | 小程序原生框架 | 跨端编译框架 | 原生 | 包体小、平台能力完整、无编译层,答辩时每一行代码都能追到 |
| 服务端形态 | 平台云开发 | 自建 Node 服务 + MySQL | 自建 | 库存扣减、事务、幂等这些设计点能真实跑起来,讲得出细节 |
| 数据存储 | 文档型数据库 | MySQL | MySQL | 订单、库存、支付状态都需要事务和行级锁,关系型更稳 |
原生框架这条要展开说一句。跨端方案的好处是一套代码多端跑,代价是多一层编译和一套适配规则,样式对不上时你得先判断是写错了还是编译层的问题。课程设计阶段这个代价不划算——你需要的是"我写的每一行都在干什么"清清楚楚,而不是"它帮我生成了什么"。原生框架的 WXML/WXSS/JS 三层结构直白,调试工具里断点打下去,数据流一眼看到底,这对答辩和后续修改都是加分项。
自建服务端与云开发之间的选择,本质是"省事"和"可讲"的权衡。云开发把数据库、云函数、存储都包好了,两小时能跑通一个增删改查;但库存扣减这种需要事务的操作,在云函数里要么用原子操作凑,要么绕开,讲起来底气不足。自建 Node 服务虽然多配一套环境,但一笔订单从"校验库存 → 扣减 → 写单 → 回调改状态"完整走一遍事务,这个过程本身就是毕设里最有含金量的部分。我的建议是:如果时间只剩一周,云开发;如果有一个月以上,自建。
2.3 六张核心表:字段怎么定,索引怎么加
数据模型决定了后面所有代码的形状,这一步偷懒,后面全是补丁。商城最小闭环需要七张表:用户、商品、SKU、购物车、订单、订单明细、收货地址。下面给出关键几张的建表语句,重点看字段类型和索引:
-- 商品表:价格统一用“分”存整数,避免浮点误差 CREATE TABLE `goods` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `category_id` BIGINT UNSIGNED NOT NULL DEFAULT 0, `title` VARCHAR(120) NOT NULL, `cover` VARCHAR(255) NOT NULL DEFAULT '', `price` INT UNSIGNED NOT NULL COMMENT '售价,单位:分', `origin_price` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '划线价,单位:分', `stock` INT UNSIGNED NOT NULL DEFAULT 0, `version` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1上架 0下架', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_cate_status` (`category_id`, `status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 订单表:order_no 唯一索引既做查询键,也做支付回调的幂等锚点 CREATE TABLE `orders` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL, `user_id` BIGINT UNSIGNED NOT NULL, `total_fee` INT UNSIGNED NOT NULL COMMENT '订单总额,单位:分', `status` VARCHAR(16) NOT NULL DEFAULT 'UNPAID' COMMENT 'UNPAID/PAID/SHIPPED/DONE/CANCELED', `idem_key` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '客户端幂等键', `pay_time` DATETIME NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), UNIQUE KEY `uk_user_idem` (`user_id`, `idem_key`), KEY `idx_user_status` (`user_id`, `status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;几个字段的设计理由值得在答辩时讲:price用INT存"分",是这一整套代码里最容易被忽略又最容易出事的地方,浮点数做金额累加,0.1 + 0.2 会给你 0.30000000000000004,凑单满减一算就差分。version字段是给乐观锁留的,虽然下面下单示例用的是stock >= num的条件更新,两条路都通,留着不吃亏。uk_user_idem这个联合唯一索引是防重复下单的最后一道闸,客户端每次进确认页生成一个幂等键,重复提交会被数据库直接挡回去,比在应用层判断可靠得多。
购物车表建议做"服务端存一份 + 本地缓存一份",服务端表结构只有user_id / goods_id / sku_id / num / checked五六个字段,登录后合并本地数据。订单明细表把下单那一刻的商品标题、价格、图片快照下来,商品后来改价改名都不影响历史订单——这个细节很多模板会漏,老师一问"商品改价了历史订单金额会不会变"就尴尬了。
3. 总体架构设计:四层职责与目录导览
架构这件事,画图容易讲清难。我的习惯是不急着画方框,先把"一次点击从页面到数据库经过哪些文件"走一遍,走通了,架构自然就出来了。
3.1 四层结构:页面、服务、接口、数据的职责边界
客户端按四层切:页面层(pages)、组件层(components)、服务层(services/api)、工具层(utils)。页面层只做三件事——拿数据、渲染、响应用户操作,不写业务规则;组件层放商品卡片、数量步进器、空状态这些复用块;服务层把每个接口封成一个函数,页面里只调api.getGoodsList(params),不出现裸的wx.request;工具层放金额换算、时间格式化、缓存读写这些纯函数。这条边界守住了,页面文件不会膨胀到五百行,改接口时也只改一个地方。
服务端对应四层:路由(router)负责路径与方法映射,控制器(controller)做参数校验和响应组装,服务层(service)承载业务规则——库存够不够、订单能不能取消、状态能不能流转,数据访问层(dao 或直接 model)只跟 SQL 打交道。这套分层不是学院派讲究,它的实际收益是:业务规则集中在 service,写单元测试和答辩讲逻辑都在这一层;controller 薄到只有十几行,看代码的人三分钟能摸清一个接口在干什么。
整条链路长这样:小程序页面 → 服务层封装函数 → HTTPS 请求 → 路由匹配 → 控制器校验 → 服务层业务判断 → 数据访问层读写 MySQL → 原路返回 JSON。中间可以插 Redis 做商品缓存、对象存储放图片,但这两块在课程设计里属于加分项,先把主链路跑稳再考虑。
3.2 源码目录导览:先读哪五个文件再改代码
拿到一份陌生源码,最忌讳从头到尾顺着读。我的顺序是先读五个"骨架文件",把数据流摸清楚,再钻细节:
# 客户端 miniprogram/ ├── app.js # ① 全局登录态、启动参数,先看这里 ├── app.json # ② 页面注册、tabBar、分包配置 ├── utils/request.js # ③ 请求封装:baseURL、token、错误码 ├── services/api.js # ④ 所有接口函数集中在这里 └── pages/ ├── index/ ├── goods/ │ ├── list.js │ └── detail.js ├── cart/ └── order/ # 服务端 server/ ├── app.js # ⑤ 中间件、路由挂载、错误兜底 ├── router/ │ ├── goods.js │ ├── cart.js │ ├── order.js │ └── pay.js ├── controller/ ├── service/ │ └── order.js # 下单事务、状态机都在这里 ├── model/ └── config/db.js前四个文件解决"数据从哪来",第五个文件解决"请求打到哪"。这五个看完,剩下的页面文件就是填空题——照着已有页面的结构复制一份改,比从零写快得多,也不会破坏既有约定。改代码之前还有一步:把app.json里注册的页面和实际存在的目录对一遍,模板里经常有注册了但目录被删的空壳,跑起来报一堆 not found,白白浪费半小时。
3.3 请求层封装:baseURL、token 与错误码统一处理
请求封装是客户端里最值得先吃透的一段代码,因为它决定了后面所有页面怎么写。一个够用的封装要解决四件事:环境切换(开发/正式域名)、登录态注入、统一错误提示、loading 控制。
// utils/request.js const BASE_URL = 'https://api.example.com' // 换成自己的服务端域名 function request({ url, method = 'GET', data = {}, loading = false }) { return new Promise((resolve, reject) => { if (loading) wx.showLoading({ title: '加载中', mask: true }) wx.request({ url: BASE_URL + url, method, data, header: { 'content-type': 'application/json', // token 从本地缓存取,登录后写入;没有就不带,服务端按未登录处理 'Authorization': wx.getStorageSync('token') || '' }, success(res) { const body = res.data // 约定:code=0 成功,401 未登录,其余为业务错误 if (body.code === 0) return resolve(body.data) if (body.code === 401) { wx.removeStorageSync('token') wx.navigateTo({ url: '/pages/login/login' }) return reject(new Error('UNAUTHORIZED')) } wx.showToast({ title: body.msg || '请求失败', icon: 'none' }) reject(new Error(body.msg)) }, fail(err) { // 网络层失败单独提示,和业务错误区分开,排查时能少走弯路 wx.showToast({ title: '网络异常,请稍后重试', icon: 'none' }) reject(err) }, complete() { if (loading) wx.hideLoading() } }) }) } module.exports = { request }这段代码里有两个参数值得改:BASE_URL在开发阶段指向本地或测试环境,上线前换成正式域名,建议用一个配置文件按环境导出,别硬编码在请求文件里;loading默认false,只有首屏、提交订单这类需要明确反馈的请求才传true,列表加载更多不传,否则页面会一直闪 loading 遮罩。错误处理把网络失败和业务失败分开,是因为这两类问题的排查路径完全不同——一个是域名、证书、网络,一个是参数、权限、库存,混在一起提示,出问题时你根本不知道从哪查。
服务端接口的返回结构必须统一成{ code, msg, data }三件套,控制器里所有分支都走同一个响应函数,别有的接口直接res.json(list),有的包一层。统一之后,客户端那一段判断就能覆盖全部接口,新增接口不用改请求层。
4. 核心模块实现:商品、购物车、下单、回调
主链路四个环节,每个环节都有一个"能跑"和"能讲"的分界线。下面按顺序给出关键代码和设计说明,代码是示意性质,参数按自己项目改。
4.1 商品列表:分页、防重复请求与价格格式化
商品列表是用户进来看到的第一个数据页,它暴露的问题也最典型:触底加载时用户手快,同一个page连发三次请求,列表里出现三份重复数据;或者价格直接返回"分",页面上显示 2990 而不是 29.90。
// pages/goods/list.js const { request } = require('../../utils/request') Page({ data: { list: [], page: 1, size: 10, hasMore: true, loading: false // 请求锁,防止触底连续触发 }, onLoad() { this.fetchList(true) }, onPullDownRefresh() { // 下拉刷新必须重置分页,否则新数据和旧数据会拼接错位 this.fetchList(true).then(() => wx.stopPullDownRefresh()) }, onReachBottom() { if (!this.data.hasMore || this.data.loading) return this.fetchList(false) }, async fetchList(reset) { if (this.data.loading) return this.setData({ loading: true }) const page = reset ? 1 : this.data.page try { const res = await request({ url: '/api/goods', data: { page, size: this.data.size } }) // 服务端价格单位是“分”,只在这一层转成元,页面里不再做二次换算 const rows = res.list.map(it => ({ ...it, priceText: (it.price / 100).toFixed(2) })) this.setData({ list: reset ? rows : this.data.list.concat(rows), page: page + 1, hasMore: rows.length === this.data.size }) } finally { this.setData({ loading: false }) } } })loading这个锁是这段代码的核心,它在请求发出时置真、finally里置假,中间任何一次触底都会被return挡掉。hasMore用"本页返回条数是否等于 size"来判断,比让服务端返回总数再算页码简单,代价是最后一页刚好满额时会多发一次空请求,可以接受。价格转换只在小程序端做一次,服务端全程用分,这条约定写进团队规范,后面购物车、订单、支付三处都按它来,账就不会错。
服务端对应的查询要注意两点:WHERE status = 1过滤下架商品,ORDER BY id DESC保证分页稳定;如果做分类筛选,把category_id加到条件里,并确保idx_cate_status索引能命中,数据量上万条之后这个索引的差别很明显。
4.2 购物车:本地缓存与服务端同步的合并策略
购物车最容易纠结的是"数据存哪"。纯本地存,换设备就丢,下单时还得回传一次;纯服务端存,未登录用户加购直接失败,体验很差。我一般用"本地优先、登录合并"的策略:
未登录时,加购写进wx.setStorageSync('cart', [...]),结构就是{ goodsId, skuId, num, checked }的数组;登录成功后,把本地数组整体 POST 给/api/cart/merge,服务端按user_id + goods_id + sku_id做 upsert,数量取两者较大值或者直接累加——累加更符合用户预期,但要在页面上提示"已合并本地购物车"。合并完成后清掉本地缓存,后续所有操作走服务端。
金额计算单独抽成纯函数,方便单测:
// utils/cart.js // 所有金额以“分”为单位参与整数运算,只在展示时转元 function summarize(items) { let totalFen = 0 let count = 0 items.forEach(it => { if (!it.checked) return totalFen += it.price * it.num count += it.num }) return { totalFen, totalText: (totalFen / 100).toFixed(2), count } } // 全选/反选:返回新数组,不改原对象,避免 setData 后视图不刷新 function toggleAll(items, checked) { return items.map(it => ({ ...it, checked })) } module.exports = { summarize, toggleAll }toggleAll返回新数组而不是原地改it.checked,是因为小程序setData对同一引用对象的深层变化不一定能触发视图更新,这个坑踩过一次就会记住。数量步进器要限制num >= 1,减到 1 时按钮置灰,别让用户减出 0 或负数,服务端也得再校验一次——前端限制是体验,服务端校验才是底线。
4.3 下单事务:幂等键、条件更新与订单号规则
下单是整个项目里唯一必须用事务的地方,也是答辩最容易被追问的地方。一笔订单要动三张表:扣库存、写订单、写明细,任何一步失败都得回滚,否则就会出现"库存扣了订单没生成"这种对不上账的情况。
// server/service/order.js const db = require('../model/db') const { genOrderNo } = require('../utils/no') async function createOrder(userId, { goodsId, num, idemKey }) { const conn = await db.getConnection() try { await conn.beginTransaction() // 1. 幂等:同一用户 + 同一幂等键只允许一笔订单,重复提交直接返回原单 const [exist] = await conn.query( 'SELECT id, order_no FROM orders WHERE user_id = ? AND idem_key = ?', [userId, idemKey] ) if (exist.length) { await conn.rollback() return { orderNo: exist[0].order_no, repeated: true } } // 2. 条件更新扣库存:stock >= num 才更新成功,天然防超卖 const [r] = await conn.query( 'UPDATE goods SET stock = stock - ?, version = version + 1 ' + 'WHERE id = ? AND status = 1 AND stock >= ?', [num, goodsId, num] ) if (r.affectedRows === 0) { await conn.rollback() throw new Error('STOCK_NOT_ENOUGH') } // 3. 取商品快照价,金额以服务端为准,绝不信任前端传来的价格 const [[goods]] = await conn.query( 'SELECT title, cover, price FROM goods WHERE id = ?', [goodsId] ) const totalFee = goods.price * num const orderNo = genOrderNo(userId) // 4. 写订单主表 + 明细表 const [ins] = await conn.query( 'INSERT INTO orders (order_no, user_id, total_fee, status, idem_key) ' + "VALUES (?, ?, ?, 'UNPAID', ?)", [orderNo, userId, totalFee, idemKey] ) await conn.query( 'INSERT INTO order_items (order_id, goods_id, title, cover, price, num) ' + 'VALUES (?, ?, ?, ?, ?, ?)', [ins.insertId, goodsId, goods.title, goods.cover, goods.price, num] ) await conn.commit() return { orderNo, totalFee } } catch (e) { await conn.rollback() throw e } finally { conn.release() } }几个参数和写法的说明:idemKey由客户端在进入确认订单页时生成(时间戳 + 随机串即可),同一个页面的多次点击用的是同一个键,换一单才换键,这样既防连点又不会误挡正常复购。扣库存用stock >= num写进WHERE,靠数据库的行锁保证并发下不会扣成负数,这比"先查再改"可靠得多——两个请求同时查到库存 1,然后都去更新,后者会覆盖前者。totalFee必须由服务端按商品表当前价格重新计算,前端传来的金额只用来展示,不参与落库。
订单号生成规则最好别用单纯的自增 ID,容易暴露单量。常见做法是"年月日时分秒 + 用户 ID 后四位 + 两位随机数",长度控制在 20 到 32 位之间:
// server/utils/no.js function genOrderNo(userId) { const d = new Date() const pad = n => String(n).padStart(2, '0') const ts = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}` + `${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}` const tail = String(userId).slice(-4).padStart(4, '0') const rand = String(Math.floor(Math.random() * 100)).padStart(2, '0') // 时间到秒 + 用户尾号 + 两位随机,极端并发下仍可能撞号,所以库里还有唯一索引兜底 return ts + tail + rand }4.4 支付回调:状态机与重复通知的处理
支付回调是"看起来简单、出错最难查"的一段。平台的支付结果通知会重发,可能三次五次,也可能在你已经发货之后才到;如果不做状态判断,重复通知会把订单改成已支付、重复加积分、重复发消息。
先把订单状态机定死,只允许下面这几条边存在:
| 当前状态 | 允许迁移到 | 触发动作 |
|---|---|---|
| UNPAID | PAID | 支付回调成功 |
| UNPAID | CANCELED | 用户取消 / 超时关单 |
| PAID | SHIPPED | 后台发货 |
| SHIPPED | DONE | 用户确认收货 |
| PAID / SHIPPED | REFUNDING | 发起退款 |
回调处理的核心就是"验签 + 幂等 + 状态条件更新"三件事,缺一不可:
// server/router/pay.js router.post('/api/pay/notify', async (req, res) => { // 1. 验签:失败直接拒绝,不进入任何业务逻辑 if (!verifySign(req.headers, req.rawBody)) { return res.status(401).send('FAIL') } const { outTradeNo, tradeState } = parseNotify(req.body) if (tradeState !== 'SUCCESS') return res.send('SUCCESS') // 2. 状态条件更新:只有 UNPAID 才能变成 PAID // 重复通知时 affectedRows = 0,直接返回成功,不做任何副作用 const [r] = await db.query( "UPDATE orders SET status = 'PAID', pay_time = NOW() " + "WHERE order_no = ? AND status = 'UNPAID'", [outTradeNo] ) if (r.affectedRows > 0) { // 3. 副作用只在这里做一次:加销量、清购物车、发通知 await onPaid(outTradeNo) } // 无论是否更新成功都返回 SUCCESS,否则平台会持续重发 res.send('SUCCESS') })这段代码里最反直觉的一句是"无论更新成功与否都返回 SUCCESS"。很多人第一反应是"没更新成功说明有问题,应该返回失败让平台重试",但这里是重复通知,本来就不该再处理,返回失败只会引来更多重试。真正的异常(验签失败、订单不存在)在前面就拦掉了。onPaid里的副作用同样要做幂等,比如清购物车用DELETE ... WHERE user_id = ? AND goods_id IN (...),重复执行也无害。
前端支付结果页不要只信wx.requestPayment的 success 回调,那个回调只代表"用户完成了支付动作",不代表"服务端已确认到账"。正确做法是支付成功后跳结果页,轮询/api/order/:no查状态,最多轮询五到六次、间隔一秒,仍未变成 PAID 就展示"支付结果确认中,请稍后到订单列表查看"。
5. 避坑与排查:五个最容易翻车的点
下面五条是这个项目里出现频率最高的问题,每一条我都按"现象 → 原因 → 解决"写,遇到时可以直接对照。
5.1 金额差一分钱:浮点误差与"分"的约定不统一
现象是购物车合计 99.99,下单后订单金额 100.00,或者满减凑单怎么算都差 0.01。原因基本是两个:一处用元做浮点运算,另一处用分做整数,中间来回转换;或者toFixed(2)用在中间步骤而不是最终展示,四舍五入被反复叠加。解决方式是把"分"作为唯一内部单位写进规范:数据库存分、接口传分、计算用分,只有渲染到页面的那一刻才除以 100。所有* 0.01、parseFloat出现在业务计算里的地方,全部改成整数运算。满减、优惠券这类规则也按分定义门槛,别写"满 99 减 10",写"满 9900 分减 1000 分"。
5.2 同一单提交了两次:按钮没锁,幂等键也没做
现象是订单列表里出现两笔一模一样的订单,用户来问是不是重复扣款了。原因有两层:前端提交按钮点下去没有置灰,网络慢的时候用户连点;后端只做了"插入订单",没有幂等键或唯一索引兜底。解决要两头一起做——前端在submit函数入口立刻setData({ submitting: true })并禁用按钮,finally里恢复;后端用(user_id, idem_key)联合唯一索引,插入冲突时捕获ER_DUP_ENTRY错误,查出原订单直接返回。只做前端锁不够,接口被直接调用时照样会重复;只做后端幂等也不够,用户看到没反应会一直点,体验很差。
5.3 开发者工具正常,真机请求全部失败
现象是模拟器里列表刷刷地加载,扫码到手机上全是"网络异常"。原因通常不是代码,而是三件事之一:请求域名没加进小程序的合法域名白名单;域名没有配置 HTTPS 或者证书链不完整;本地调试用的localhost在手机上根本指不到你的电脑。排查顺序建议从外到内——先用手机浏览器直接访问接口地址,能出 JSON 说明域名和证书没问题;再检查小程序后台的服务器域名配置,注意白名单不含端口和路径;最后确认开发阶段是否勾选了"不校验合法域名"(这个选项只在开发版生效,体验版和正式版一律校验)。上线前必须把正式域名配好,别指望这个开关。
5.4 首屏白屏两三秒:图片没压缩,主包也超了
现象是首页进去先白一片,轮播图和商品图一张张往外蹦,弱网下更明显。原因一般是商品图直接上传原图,一张几 MB,列表一次加载十条就是几十 MB;加上主包体积逼近上限,启动时要下载的东西太多。解决分两步:图片在服务端上传时就做压缩和尺寸裁剪,列表用缩略图(宽度 300px 左右足够),详情页再加载大图,格式优先用平台支持的压缩格式;工程侧把订单、地址、个人中心这些非首屏页面拆进分包,主包里只留首页、分类、商品详情、购物车,app.json里配好subpackages,首屏加载时间能压下来一截。图片懒加载也要开,列表项进入视口再请求。
5.5 支付成功但订单还显示待付款
现象是用户付完钱回到订单列表,状态还是"待付款",过一会儿刷新才变。原因有三种可能:回调地址配错了,平台通知根本没打到你服务器;回调到了但验签失败被拒;回调成功了但前端页面用的是本地缓存数据,没有重新拉接口。排查顺序是先看服务端日志有没有收到通知,没有就查回调地址和网络可达性;有通知但订单没变,看验签和status条件是否满足;都正常就是前端问题,在onShow里重新拉一次订单详情,别依赖支付成功回调里带回来的状态。这类问题最忌讳"用户说没到账就手动改库",一定要先定位到是哪一环断的。
6. 验收与答辩:怎么证明这套代码你讲得清
交之前我会走一遍固定的自查,十项过完再打包,能挡掉八成现场尴尬:
| 检查项 | 通过标准 |
|---|---|
| 主链路 | 浏览 → 加购 → 下单 → 支付 → 订单状态变更,全程无报错 |
| 金额一致性 | 购物车合计 = 订单金额 = 支付金额,三处一分不差 |
| 库存 | 库存 1 时下单成功、再下一单提示不足,库存不为负 |
| 幂等 | 连点提交按钮,订单列表只有一笔 |
| 状态机 | 已取消的订单不能再支付,已支付不能重复支付 |
| 空态 | 购物车空、订单空、搜索无结果都有提示页 |
| 异常 | 断网、接口 500 时有提示,不白屏不卡死 |
| 数据 | 商品改价后历史订单金额不变(快照生效) |
| 配置 | 正式域名、HTTPS、分包配置齐全,体验版能跑 |
| 代码 | 没有 console.log 残留、没有写死的测试数据 |
答辩讲的时候不要按页面顺序念功能,那样十分钟讲完全场记不住任何东西。我的建议是抓三条主线:第一条讲架构,客户端四层、服务端四层,各层职责一句话说完,配一张自己画的图;第二条讲主链路,从加购到支付回调,重点讲下单那段事务和条件更新,这是最能体现你理解深度的地方;第三条讲一个你踩过的坑,比如金额浮点误差或者重复下单,按"现象—排查—怎么改—改完怎么验证"讲完整,比罗列十个功能有说服力得多。老师追问时别慌,答不上来的就说"这块我目前是按 X 实现的,更严谨的做法可能是 Y,还没做",诚实比硬编强。
我自己的习惯是答辩前一天把主链路从零走三遍,第三遍故意把库存改成 1、把网络断掉,看系统怎么反应。有一次就是这么试出来的——库存不足时前端弹的是"网络异常",因为服务端抛错没走统一响应,前端落到了 fail 分支。这种问题不亲手点一遍根本发现不了。从那以后我每次交项目前都强制走一遍"断网 + 边界值"的组合测试,宁可自己先翻车,也别在台上翻。希望这套结构和清单能帮到你,源码拿去改的时候,先把第 2 章那几张表和接口对齐,剩下的都是体力活。
本文还有配套的精品资源,点击获取