这个项目是我业余时间开发的一套基于微信小程序的电商购物平台,源码、部署文档、调试记录都整理在了一个仓库里。和市面上那些只有静态页面、点两下就断了的课程 demo 不一样,这版把登录授权、商品列表、购物车、下单、微信支付、订单管理完整串了起来,前端用原生小程序,后端用 Spring Boot,数据库脚本直接导入就能跑。今天不打算把代码逐行贴出来,而是想聊聊这套项目从设计到落地最值得参考的部分:为什么这么拆、请求层怎么封装、加载更多的状态怎么处理、真机调试时有哪些文档里没写透的坑。正在做小程序电商项目、接外包或者拿它当毕业设计的同学,这篇应该能帮你省不少时间。
1. 项目整体设计与源码架构拆解
1.1 为什么我会选微信小程序做这个电商平台
先说选型。这套项目的核心诉求是“能跑通交易闭环”,在这个前提下,微信小程序几乎是现阶段中小电商最稳的载体。用户侧不用下载安装,微信里扫码或搜索就能进店,支付和登录也天然带完备的开放能力;开发侧原生小程序语法不复杂,一套代码同时覆盖 iOS 和 Android,发版审核比 App Store 快捷得多。
对比 H5 方案,小程序最大的优势是交易闭环的完整性。微信内 H5 支付需要走公众号支付配置,还要拼 openid 和授权链路,用户动不动就卡在“请先在浏览器打开”的提示上。小程序自带wx.login、wx.requestPayment,拿到 code 换 openid,后端统一下单,前端拉起收银台,链路短得多。对比 App 方案,小程序不用考虑应用市场审核和机型适配,迭代成本低,尤其适合流量从微信生态里来的商家。所以这套源码我选了原生小程序做前端,而不是 H5 套壳。
当然原生小程序也有要忍受的地方,比如包体积限制、部分能力依赖第三方插件、自定义组件写法需要遵守官方规范。但考虑到这个项目要给不同基础的人二次开发,原生结构反而更透明,不会因为引入 uni-app 或 Taro 的编译层多一道黑盒。
1.2 源码目录与前后端分层
拿到这套源码,第一眼看到的目录结构是这样的:
project/ ├── miniprogram/ # 小程序前端源码 │ ├── pages/ # 页面:首页、分类、购物车、我的等 │ ├── components/ # 自定义组件:商品卡片、导航栏、空状态 │ ├── utils/ # request 封装、工具函数、常量配置 │ ├── app.js # 全局逻辑 │ ├── app.json # 页面注册与 tabBar 配置 │ └── app.wxss # 全局样式 ├── server/ # 后端服务,Spring Boot 工程 │ ├── src/main/java/ # controller、service、mapper │ ├── src/main/resources/ # application.yml、mapper xml │ └── pom.xml ├── sql/ # init.sql:建库、建表、初始数据 └── docs/ # 部署文档、接口文档、FAQ前后端分开,是我自己比较坚持的一个习惯。即使这个项目大部分时间由我一个人维护,分开之后压力也小很多:前端跑在微信开发者工具里,后端用 IDEA 启动,互不干扰;接口只要约定好参数和返回结构,两边可以并行推进。将来如果有人想换掉前端或者换掉后端,也不会牵一发动全身。
后端我选了 Spring Boot + MyBatis + MySQL。没有引入特别复杂的微服务,因为电商购物平台的核心场景是商品查询、购物车、订单、支付,单体应用完全够用,部署也简单。MySQL 里主要维护用户、商品、分类、购物车、订单、订单明细、收货地址等数据。为了演示效果,init.sql里预置了几十个商品数据和图片链接,启动后首页不会空荡荡。
1.3 核心数据模型与权限设计
电商平台的数据模型不算复杂,但有几个地方第一次做很容易漏。我在这套源码里按这个思路建表:
| 表名 | 核心字段 | 说明 |
|---|---|---|
user | id, openid, nickname, avatar, phone, role | openid 唯一,role 标记普通用户或管理员 |
product | id, title, image, price, stock, status, sales | status 控制上下架,stock 是库存 |
category | id, name, sort | 商品分类 |
cart_item | id, user_id, product_id, quantity, checked | 登录后购物车记录 |
order | id, order_no, user_id, total_amount, status, pay_time | status 区分待支付、已支付、已发货等 |
order_item | id, order_id, product_id, product_name, price, quantity | 下单时商品快照 |
address | id, user_id, name, phone, region, detail | 收货地址 |
权限设计上没有搞花活:后端拦截器统一校验请求头里的 token,解析出用户 id 和角色;需要管理员权限的接口(比如后台商品管理)再校验role字段。前端则根据登录态显示不同入口,但真正的权限判断永远要在后端做,不能只靠前端隐藏按钮。
这里最需要提醒的是订单金额和库存校验。前端购物车展示的总价只是给用户看的参考,后端在下单接口里必须重新查一遍商品价格,用数据库里的实时价格计算总金额,并检查库存是否足够。扣库存和创建订单要放在同一个事务里,不然会出现超卖或订单数据不完整的问题。这套源码的下单接口里我用@Transactional保证了这两步的原子性,调试时也重点测过并发下单的场景。
2. 前端核心功能实现与调试细节
2.1 请求封装:把登录态和错误码统一收口
小程序原生wx.request用起来最烦人的地方是没有 Promise、返回结构不统一、每个页面都要写一遍 header。我在utils/request.js里做了封装,所有页面都走同一个入口。
const request = (url, method = 'GET', data = {}) => { return new Promise((resolve, reject) => { wx.request({ url: baseUrl + url, method, data, header: { 'Content-Type': 'application/json', 'Authorization': wx.getStorageSync('token') || '' }, success(res) { if (res.statusCode === 200 && res.data.code === 0) { resolve(res.data.data) } else if (res.data.code === 401) { handleTokenExpired() reject(res.data) } else { wx.showToast({ title: res.data.msg, icon: 'none' }) reject(res.data) } }, fail(err) { reject(err) } }) }) }所有接口返回固定结构{ code, msg, data },code === 0表示成功。请求发出时自动带 token,后端返回 401 就统一跳转登录;业务错误比如“库存不足”则直接弹 toast。这样页面里不需要到处 try-catch,只要关心成功后的data。
这个封装在实际调试中还有一个很关键的优化:避免 token 过期时并发请求同时触发多次登录弹窗。我加了一个isRefreshing锁,第一个请求遇到 401 开始重新登录,后面的请求进队列,等登录完成后再依次重放。这个坑在文档里没写,但只在真机高频操作时会出现,属于“不压测不知道”的问题。
2.2 首页商品流与“加载更多”列表实现
商品列表是电商平台最核心的交互。这套源码里首页和分类页都用了分页加载,核心逻辑是维护page、pageSize、loading、nomore四个字段。页面上拉触底时触发onReachBottom,判断当前状态再请求下一页。
onReachBottom() { if (this.data.loading || this.data.nomore) return this.setData({ loading: true }) this.loadProducts() }, async loadProducts() { const page = this.data.page + 1 try { const res = await api.getProducts({ page, pageSize: 10 }) const products = this.data.products.concat(res.list) this.setData({ products, page, nomore: products.length >= res.total }) } finally { this.setData({ loading: false }) } }这里有几个细节直接影响体验。第一,请求发出前必须判断loading,否则用户快速上拉会同时请求多个同样的下一页,导致列表重复数据。第二,nomore的判断最好用后端返回的hasMore字段,而不是length >= total,因为商品删除或不同筛选条件会让总数不稳定。第三,下拉刷新和上拉加载要共用一个loading状态锁,避免互相打断。
我在源码里特意把“加载更多”封装成了一个组件,底部自动显示“加载中”“没有更多了”“加载失败点击重试”三种状态。失败时允许点击重试,而不是只能重新进入页面,这个是用户主动反馈后加的功能。
2.3 购物车、下单与支付的完整闭环
购物车模块我拆成了两层:未登录时数据存在本地wx.setStorageSync,登录后同步到后端。这样用户逛着逛着再登录,购物车内容不会丢。登录成功后会调用一个合并接口,把本地购物车逐条上传,后端按用户和商品去重合并。
下单流程遵循一个原则:前端只负责收集用户的选择和收货地址,后端负责所有计算和校验。前端拿到用户点击“去结算”的命令后,带着选中的购物车 item id 和 address id 请求后端创建订单。后端重新查价格、查库存,生成订单号,在事务里扣库存并创建订单明细。如果踢掉一个“用户看着商品页两分钟,价格已经被运营改过”的场景,后端不重新计算就会出问题。
支付环节走的是微信支付统一下单。后端拿到支付参数后调用微信接口,返回timeStamp、nonceStr、package、signType、paySign,前端再调wx.requestPayment:
wx.requestPayment({ timeStamp: res.timeStamp, nonceStr: res.nonceStr, package: res.package, signType: 'RSA', paySign: res.paySign, success() { // 跳转订单详情 }, fail(err) { // 用户取消或支付失败 } })这里最容易踩的坑是签名算法和后端不一致,或者package的格式传错。调试支付必须用真机,开发者工具里的模拟支付只能验证“能拉起”,真正支付成功回调要靠后端收到微信支付通知后更新订单状态。随后前端通过轮询或主动查询订单详情刷新状态。
2.4 登录授权与用户资料的合规写法
登录授权几乎是每个小程序项目都会遇到“版本坑”的地方。在这套源码里,用户点击微信登录后:
wx.login拿到临时code;- 后端拿
code调用微信code2Session接口,换取openid和session_key; - 后端用自己的 token 机制生成登录凭证,小程序存到 storage;
- 后续所有请求带上 token,后端从 token 解析用户。
这里必须强调,现在小程序不能像早期版本那样直接弹窗获取用户头像和昵称了。新的合规写法是用户主动点击带有open-type="chooseAvatar"的 button 选择头像,昵称则通过一个 type 为 nickname 的 input 让用户填写。源码里个人中心已经按这个方式实现,如果直接复制老的wx.getUserProfile代码,线上很容易被审核打回。
手机号信息也是一样的道理,点击open-type="getPhoneNumber"的按钮,回调里拿到的code只能交给后端去换手机号,前端拿不到明文。这个项目里手机号主要用来处理联系方式和部分营销场景,没有做成强制绑定。
3. 文档编写、调试工具与小程序适配避坑
3.1 源码附带的文档应该写什么,才能叫“可交付”
很多开源项目或者毕设项目的通病是源码一堆,文档一句话“导入即可运行”。等别人真去运行,光环境就能折腾两天。我在这套项目的docs/目录里塞了四份文档:部署文档、接口文档、功能清单和 FAQ。
部署文档要求做到“照着敲命令就能跑”。从安装 JDK、MySQL,到创建数据库、导入init.sql,再到修改application.yml里的数据库账号密码、把端口改成 8080,最后用mvn spring-boot:run启动,每一步都单独写清楚。小程序端则要写清楚怎么在微信开发者工具里导入miniprogram目录、填自己的 AppID、开启“不校验合法域名”选项,以及真机预览前要把baseUrl改成局域网或线上域名。
接口文档我用了最笨也最实用的方法:在 Apifox 里维护接口集合,导出 Markdown 放在docs/api.md。每个接口写清请求方式、路径、参数类型、必填项、返回示例和错误码。这样做的好处是,自己回头看或者交给别人接手时,不会出现“这个字段当初为什么这么命名”的疑问。
文档里最“值钱”的部分其实是 FAQ。比如 MySQL 导入报错可能是字符集问题,后端启动失败大概率是数据库密码没改,小程序请求失败记得先看合法域名。我把开发过程中真实遇到过的十几类问题按标题整理成问答,后面调试时直接按图索骥。
3.2 开发者工具三板斧:Console、Network、AppData
微信开发者工具是我调试这个项目时花时间最多的环境。很多人拿到源码后第一反应是看代码,其实遇到问题先看这三个面板能省一大半时间。
Console面板会输出前端打印的日志、组件警告和错误堆栈。小程序里的console.log可以直接在工具里看到,排查数据流时我会在关键节点打印当前data或请求返回,定位是前端逻辑错还是后端返回错。Network面板则能看到每个请求的 URL、请求体、响应体和耗时。这里最有用的操作是点击一个请求,直接看“预览”里的 JSON,能立刻判断后端数据有没有问题。如果请求都看不到,那就是前端根本没发出,先检查页面代码。
AppData面板是很多人忽略的调试利器。小程序页面数据都存在data对象里,工具会自动实时展示。调试“加载更多”时,我直接展开products数组看长度有没有递增;购物车勾选状态不对时,展开cart看checked字段有没有变化。比在页面里加一堆临时按钮快得多。
真机调试则必须用真机跑一遍。开发者工具菜单栏“真机调试”会生成一个调试二维码,扫了之后在手机上操作,能看到设备上的 Console 和 Network。支付、授权、扫码这类依赖微信客户端能力的场景,工具里再怎么模拟都不如真机来一次。
3.3 导航栏高度、单选框这些容易翻车的页面细节
页面适配里面,“顶部导航栏高度”是我每次封装自定义导航栏都要重新写一遍的逻辑。微信小程序的胶囊按钮位置在不同机型上不一样,所以不能写死高度。
const menuRect = wx.getMenuButtonBoundingClientRect() const statusBarHeight = wx.getSystemInfoSync().statusBarHeight this.setData({ navBarHeight: (menuRect.top - statusBarHeight) * 2 + menuRect.height + statusBarHeight })这段代码的意思是:导航栏总高度等于胶囊上方留白加上胶囊自身高度再加状态栏高度。两种算法算出来的是同一套结构,但只有真机适配后才能确定不会出现按钮重叠或错位。源码里自定义导航栏组件已经封装了这个逻辑,换机型时不需要改页面代码。
另一个容易翻车的是单选框。原生radio组件的样式很有限,在电商场景里常常要自定义“选择框”的视觉。我的做法是用view模拟单选:选中时显示一个带对勾的圆形,未选中时显示一个灰色圆圈。数据驱动渲染,点击时更新selectedIndex或checked字段。这样样式完全可控,也避开了原生组件在不同机型上渲染不一致的问题。
底部的安全区也要处理。iPhone X 之后的机型底部有黑条,如果页面有提交按钮,要用padding-bottom: env(safe-area-inset-bottom)把按钮抬高,否则文字会被手势条挡住。这个细节不调会显得很业余。
3.4 体验版分享、版本管理与线上发布
小程序开发完成后,不是把代码往开发者工具里一放就完事。工具右上角点“上传”,填好版本号,再到微信公众平台把该版本设为“体验版”,指定若干体验成员,成员扫码后就能在手机上用接近生产环境的方式体验。我在项目文档里专门写了体验版配置流程,方便你把小程序发给朋友或导师收集反馈,而不是只能对着开发者工具截图。
正式发布前的检查清单我也会写在文档里:确认后端已部署到有 HTTPS 证书的线上服务器,在公众平台配置好request合法域名,小程序基础库版本不要太老,支付商户号已经和 AppID 绑定。还有一个容易忽略的点,如果你在公众平台填了业务域名,H5 页面唤起小程序时也要确保域名备案和校验文件都配置正确,否则就出现“链接无法访问”的提示。这个我在联调时遇到过,最后发现只是校验文件没放到服务器根目录。
4. 高频问题排查与性能优化实录
4.1 网络请求失败、10002 错误与域名配置
网络请求失败是小程序调试里最常见的现象。很多人一看到报错就怀疑后端崩了,但排查顺序其实应该固定下来:先看开发者工具 Network 面板请求有没有发出来,再看请求 URL 是不是https开头,再看后端日志有没有收到。最常见的问题其实是本地开发时没有开启“不校验合法域名”。开发工具里可以在“详情-本地设置”那勾上,但上线前必须在公众平台配置合法域名,且域名必须是备案过并支持 HTTPS。
我在文档里整理了一个“错误码速查表”,其中一个小程序端常见的10002类型错误,多和数据访问权限或非法请求参数有关。遇到这类错误先检查 AppID 是否配置正确、请求参数是否包含特殊字符、接口路径是否和后端路由匹配。很多 10002 看着吓人,其实只是开发环境配置问题,而不是业务逻辑 bug。
后端如果是404或405,可能是接口路径对不上,或者 Spring Boot 的请求方法和前端不一致。后端如果是500,优先看日志里的异常栈,不要只盯着开发者工具里的报错。前后端联调最忌讳两边同时猜,直接把两个日志时间对上就能定位大半问题。
4.2 上拉加载更多与下拉刷新的状态冲突
列表页同时有下拉刷新和上拉加载时,状态冲突是必然要处理的。第一次做的时候,我遇到过下拉刷新还没结束,用户又上拉触底,结果列表被重置的同时又追加了一堆数据,整个页面乱套。
解决方案就是给每个“加载动作”加状态锁。下拉刷新时把page重置为 0,清空列表,请求完成后赋值;上拉加载时先判断loading || nomore,直接 return。同时要注意wx.stopPullDownRefresh()一定要在数据加载完成后再调用,不能进onPullDownRefresh就立刻停止,否则动画停了数据还没刷新完。
还有一个隐蔽问题:当列表内容不足以撑满一屏时,onReachBottom可能不会触发,页面看起来就“加载不出来”。这时可以在page.json里把onReachBottomDistance设置得小一点,或者使用scroll-view的bindscrolltolower来自行控制。源码里商品列表用的是页面级别的onReachBottom,并且默认距离 50px,实测在大部分设备上感知良好。
4.3 setData 过大导致的列表卡顿
小程序性能问题八成出在setData。电商列表页最容易犯的错误是一次性把整个大数组放进去。举例来说,每次加载下一页,如果都this.setData({ products: allProducts }),数组一旦超过几百条,页面渲染卡顿感会非常明显。
解决办法是尽量局部更新。比如用户只点击了某一项的“加入购物车”按钮,我只需要更新这一个product的状态:
this.setData({ [`products[${index}].cartCount`]: 1 })这种以索引为 key 的更新方式,小程序只会重新渲染那一个节点,开销比整数组更新小得多。列表渲染时也别忘加wx:key,不然每次 diff 都不知道复用哪个节点,性能会再打个折扣。
图片懒加载也要做一个。商品列表的图片多且大,我在这套源码里给image组件加上了lazy-load属性,图片会出现在视口附近时才加载。上线前我还统一处理过一遍商品图的尺寸和压缩,首屏加载速度明显改善。
4.4 支付拉不起来时的排查顺序
支付问题是最难通过代码描述讲清的,因为涉及小程序、后端、微信支付平台三方。我在这套项目里调试时总结了一套排查顺序:
- 确认小程序 AppID 和微信支付商户号确实完成了绑定,中间不能漏绑定步骤。
- 确认后端调用统一下单时用的
openid是当前用户真实openid,不是测试数据。 - 确认
wx.requestPayment的所有参数都是后端返回的原始字段,前端不能自己组装或修改。 - 确认后端签名用的证书和密钥没有混淆,尤其注意商户 API 密钥是否和小程序
appsecret搞混。 - 真机操作时如果弹出“支付失败”或“当前商户号未开通此能力”,先去微信支付商户平台看产品权限。
用户取消支付的时候,wx.requestPayment的fail回调会收到cancel,这是正常行为,不应该当错误上报。我在源码里对支付失败态做了区分,只有非取消的失败才跳转“支付失败页”,取消则停留在订单详情页给用户重新支付的入口。
另外要特别提醒,支付回调一定要做幂等处理。微信支付的异步通知可能因为网络原因发多次,如果后端收到一次通知就修改一次订单状态,可能从“已支付”改成“待支付”。我在后端加了个判断:只有当前状态是“待支付”时才更新为“已支付”,否则直接忽略后续通知。这个细节虽然不大,但线上出问题会很致命。
最后分享一点实际体会
这套项目我零零散散写了一个多月,最大的感悟是写源码之前先想清楚边界条件,比多写十个页面都重要。一开始我总盯着 UI 做,觉得商城只要好看就行,结果联调支付和分享功能时,连续两天卡在签名、域名、回调状态不一致的问题上。后来我把每个接口的边界状态都列成表,比如 token 过期、购物车为空、库存不足、支付回调重复,再在统一封装里逐个处理,整个项目才真正变成可以交给别人使用的状态。如果你也在写类似的项目,我建议别急着堆功能,先把这些“特殊情况”处理干净,代码的完成度会立刻高一个档次。这套源码之后我还会继续维护,最近在加优惠券和多规格商品,等跑通了再单独写一篇扩展玩法。