简介:一份微信小程序云商城前后端完整案例源码,配套PHP后端接口,适合想从零掌握小程序电商开发、或希望了解前后端协作的开发者。资源共3532个文件,约20.34MB,核心由1394个JS、683个HTML、381个PHP、321个CSS及321张PNG等组成,涵盖小程序页面逻辑、后端API、后台管理模板与静态资源;其中LESS、JSON、WXML/WXSS等文件则对应样式预编译、配置与小程序结构,目录划分清晰,便于按模块阅读。已有238人学习浏览,可用作课程设计或毕业设计参考。通过阅读可系统学习商品分类、购物车、订单流程、用户登录、支付回调等实现方式,同时理解PHP与MySQL的接口设计、RESTful API约定及微信支付安全处理;附带的AdminLTE/bootstrap后台界面,也为快速搭建管理端提供了可复用的模板。
1. 云商城小程序源码包,真正值得读的是 PHP 后端那几条接口
拿到这套“微信小程序 + PHP 后端”的云商城源码包,先别急着只盯着小程序页面。解压后能看到 bootstrap.css、bootstrap.min.css、AdminLTE.css、AdminLTE.min.css 这一组后台资源,说明它并不是一个单纯输出 JSON 的 API 项目,而是连 MySQL 后端的 PHP 管理端也一并打了包。前端小程序负责浏览、加购、支付动作,PHP 后端提供接口并承担订单、商品的持久化;两者合在一起,才算一个完整的前后端分离项目实战。
对刚接触商城类小程序的开发者,这套包能同时看到 WXML 页面组织和 PHP 控制器的对应关系;对准备把老 PHP 项目改造成小程序数据源的人来说,更值得关注的是接口路径、用户识别方式和回调验签这三个细节。下面按拆包顺序来看:先处理小程序请求层,再映射 PHP 路由,然后落地微信支付,最后用回调脚本把整套链路验证到闭环。
2. 小程序网络层改造:从 WXML 到 wx.request 数据流
2.1 先看资源包结构:AdminLTE 与 bootstrap 暗示了后端形态
解压 zip 之后第一件事,不是去找 app.json,而是先看静态资源里有什么。看到 AdminLTE.css、AdminLTE.min.css 和 bootstrap.css、bootstrap.min.css 这一组文件,基本可以确定后端是带管理界面的 PHP 站点,而不是纯 API。AdminLTE 是后台管理模板,bootstrap 是它的基础样式,这意味着资源包里既有小程序端页面,又带一套商品管理后台页面。这个结构对应到业务上非常常见:小程序用户端与 PHP 管理端共用同一个数据库,接口单独暴露给小程序调用。
既然拿到的是一整套案例源码,就不要把小程序端和管理后台割裂开理解。小程序端执行浏览、下单、支付等动作,PHP 后端一方面提供这些动作依赖的 JSON 接口,另一方面通过 AdminLTE 页面完成商品上架、订单发货。下面用一张表把典型模块与 PHP 接口的对应关系列出来:
| 功能模块 | 小程序端动作 | PHP 端接口 | AdminLTE 管理端 |
|---|---|---|---|
| 商品 | 列表、详情 | goods/list、goods/detail | 添加、上下架商品 |
| 购物车 | 加入、修改数量 | cart/add、cart/update | 不涉及 |
| 订单 | 提交、支付、取消 | order/create、order/pay、order/cancel | 订单列表、发货 |
| 用户 | 登录、收货地址 | user/login、user/address | 用户查询 |
这组接口路径是这类 PHP 商城案例最常见的设计,不一定与本包内完全一致,但管理端和小程序端共用控制器层、RESTful 风格命名的思路是通用的。拿到包后先找到index.php里路由定义,就能找到等价的映射关系。
2.2 统一 request 封装:把 token 注入和返回码归一
小程序页面一旦变多,最糟糕的写法是每个页面自己直接调 wx.request,让成功和失败回调散落在几十个文件里。我接手这类案例时,会先抽出utils/request.js统一管三件事:基础域名、token 注入、返回码判断。这样后面接支付、加统一鉴权,只需要改一个文件。
// utils/request.js —— 云商城小程序统一请求层 const BASE_URL = 'https://your-domain.com/index.php' function request(path, method = 'GET', data = {}) { return new Promise((resolve, reject) => { wx.request({ url: `${BASE_URL}?r=${path}`, method: method.toUpperCase(), data, header: { 'content-type': 'application/json', 'X-Auth-Token': wx.getStorageSync('token') || '' }, success(res) { // PHP 接口通常返回 { code: 0, msg: '', data: [] } if (res.data && res.data.code === 0) { resolve(res.data.data) } else { wx.showToast({ title: (res.data && res.data.msg) || '请求失败', icon: 'none' }) reject(res.data) } }, fail(err) { reject(err) } }) }) } module.exports = { request }这段代码里最值得注意的细节是 URL 拼法:http://域名/index.php?r=goods/list。不少老 PHP 项目没有配置 nginx rewrite,直接用 pathinfo 形式访问容易 404,?r=前置控制器风格在老商城源码里非常常见。X-Auth-Token是登录后由 PHP 下发的业务 token,小程序端存入 Storage,每次请求带上它,服务端从$_SERVER['HTTP_X_AUTH_TOKEN']就能读取当前用户。method.toUpperCase()是防止外层传入小写导致部分请求异常。
2.3 商品列表的分页请求:避免滚动重复加载
商城首页逃不开商品列表,而且一旦涉及上拉加载,分页逻辑就必须一次做对。onReachBottom触发频率很高,如果不加 loading 保护,用户快速滚动时同一页会被请求好几次。
// pages/goods/list.js const { request } = require('../../utils/request') Page({ data: { list: [], page: 1, size: 10, hasMore: true, loading: false }, onLoad() { this.fetchGoods() }, fetchGoods() { if (this.data.loading || !this.data.hasMore) return this.setData({ loading: true }) request('goods/list', 'GET', { page: this.data.page, size: this.data.size }) .then((res) => { this.setData({ list: this.data.list.concat(res.list), hasMore: res.list.length >= this.data.size, page: this.data.page + 1, loading: false }) }) .catch(() => this.setData({ loading: false })) }, onReachBottom() { this.fetchGoods() } })这里用concat而不是整段替换,是因为上拉加载是在原有列表基础上追加。hasMore的判断依据是本次返回条数是否等于请求的 size,相等就认为可能还有下一页。loading标志位是为了防止并发重复请求。
新手容易漏掉的一点是:前端传的 size 后端必须做上限限制。如果不限制,用户把 size 改成 99999,一次请求就拉走全表数据,对 PHP 接口和 MySQL 的压力都很大。下一章服务端实现里我会把这个边界补上。
3. PHP 后端路由控制器、数据表映射与管理后台联动
3.1 单入口路由把 API 与 AdminLTE 页面串起来
打开案例的index.php,第一件要确认的是路由分发方式。典型实现是一个单入口文件同时决定请求是 API 还是后台页面,用?r=参数做控制器与动作的映射。
// index.php —— 简化后的单入口路由 $r = $_GET['r'] ?? 'site/index'; [$ctrlName, $action] = array_pad(explode('/', $r), 2, 'index'); $controllerMap = [ 'goods' => GoodsController::class, 'order' => OrderController::class, 'user' => UserController::class, 'site' => SiteController::class, ]; if (!array_key_exists($ctrlName, $controllerMap)) { http_response_code(404); echo json_encode(['code' => 404, 'msg' => 'invalid route']); return; } $controller = new $controllerMap[$ctrlName](); $response = $controller->{$action . 'Action'}(); if (is_array($response)) { header('Content-Type: application/json'); echo json_encode($response); } else { echo $response; // AdminLTE 页面模板输出 }这里把动作名拼上Action后缀,是因为 PHP 控制器容易碰到list、print这类保留字或内置函数名,加后缀可以避免命名冲突。返回值是数组就按 JSON 输出,是字符串就当作 HTML 模板输出。这样index.php?r=site/index加载后台首页模板,index.php?r=goods/list输出商品 JSON,两件事在一个入口里分流完成。
接口约定的返回结构统一为{ code, msg, data },业务正常时 code 为 0,异常时用非 0 值,小程序端 request 封装里做统一拦截。这个约定虽然简单,但能避免小程序每个页面自己写一套错误处理逻辑。
3.2 商品表设计与查询参数防滥用
商城系统的表结构绕不开商品、订单、用户、购物车四张核心表。商品表最少量应覆盖标题、封面、价格、库存、销量与上下架状态,下面这张建表语句可以直接用于本案例的本地复现:
CREATE TABLE `shop_goods` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `title` varchar(200) NOT NULL DEFAULT '' COMMENT '商品标题', `cover` varchar(500) NOT NULL DEFAULT '' COMMENT '主图地址', `price` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '售价', `stock` int(11) NOT NULL DEFAULT '0' COMMENT '库存', `sales` int(11) NOT NULL DEFAULT '0' COMMENT '销量', `status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '1上架 0下架', `created_at` datetime DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='云商城商品表';对应商品列表接口,服务端要对 page 和 size 做边界限制,并用 PDO 预处理方式查询:
public function listAction() { $page = max(1, (int) ($_GET['page'] ?? 1)); $size = min(50, max(1, (int) ($_GET['size'] ?? 10))); $stmt = $this->db->prepare( 'SELECT id, title, cover, price, sales FROM shop_goods WHERE status = 1 ORDER BY id DESC LIMIT :offset, :size' ); $stmt->bindValue(':offset', ($page - 1) * $size, PDO::PARAM_INT); $stmt->bindValue(':size', $size, PDO::PARAM_INT); $stmt->execute(); return [ 'code' => 0, 'data' => [ 'list' => $stmt->fetchAll(PDO::FETCH_ASSOC), 'page' => $page, 'size' => $size ] ]; }参数用bindValue绑定时必须带PDO::PARAM_INT,避免整型参数被当作字符串处理。min(50, ...)这一层就是把前面小程序端 size 不可信的问题兜住,所以后端永远不要信任前端传上来的边界值。涉及金额和库存扣减的接口,后面还要加事务,商品列表这种只读查询不需要。
3.3 AdminLTE 管理后台复用同一套控制器
后台管理页面和小程序接口共用数据库,但后台需要看到库存、上下架、订单状态这类内部字段,所以不建议直接复用小程序那些对外接口。更常见的做法是在同一个控制器里拆两个动作:
| 数据需求 | 小程序端走 | 管理后台走 | 差异点 |
|---|---|---|---|
| 上架商品列表 | goods/list | goods/adminList | adminList 额外返回 stock、status |
| 订单列表 | order/my | order/adminList | 按状态与时间筛选 |
| 发货操作 | 不开放 | order/ship | 仅后台可调用,需要登录态校验 |
AdminLTE 页面里渲染表格时,可以直接在模板中调用控制器方法取数据,也可以在前端页面用 ajax 请求?r=goods/adminList。我偏向后一种,因为后台页面的筛选、排序在浏览器端做更容易,PHP 端只要把数据组合好输出 JSON 即可。这样小程序和后台共用一套参数绑定、一套表结构,不用为后台再写一遍重复查询。
4. 微信支付集成:code2Session、统一下单与订单幂等
4.1 用 code 换 openid 的那一步,叫 code2Session
微信小程序登录是云商城的起点,而“小程序用 code 换 token”热搜里讲的正是 code2Session。wx.login 返回的 code 是一次性的临时凭证,不能直接当业务登录态使用,要交给 PHP 后端去微信服务器换取 openid 和 session_key,再由后端自己生成一个业务 token 回传给小程序。
小程序端登录代码:
wx.login({ success(loginRes) { if (!loginRes.code) return wx.request({ url: 'https://your-domain.com/index.php?r=user/login', method: 'POST', data: { code: loginRes.code }, success(res) { const token = res.data && res.data.data && res.data.data.token if (token) wx.setStorageSync('token', token) } }) } })PHP 后端对应的处理:
public function loginAction() { $body = json_decode(file_get_contents('php://input'), true); $code = $body['code'] ?? ''; $api = 'https://api.weixin.qq.com/sns/jscode2session' . '?appid=' . $this->cfg['appid'] . '&secret=' . $this->cfg['secret'] . '&js_code=' . urlencode($code) . '&grant_type=authorization_code'; $resp = file_get_contents($api); $wx = json_decode($resp, true); if (!isset($wx['openid'])) { return ['code' => 401, 'msg' => 'login failed']; } $userId = $this->findOrCreateUser($wx['openid']); $token = bin2hex(random_bytes(16)); $this->saveToken($token, $userId); return ['code' => 0, 'msg' => 'ok', 'data' => ['token' => $token, 'user_id' => $userId]]; }file_get_contents是 PHP 里最直接的调用方式,但线上建议换成 curl 并设置超时,避免微信接口无响应时 PHP 进程卡死。openid 是用户在小程序内的唯一标识,但不要直接拿它做数据库主键,仍建议用自增 user_id 关联,openid 单独加唯一索引。code 只能使用一次且有效期很短,前端不应保存它,换完 token 后这个 code 就没有任何用途了。
4.2 统一下单:金额单位与签名算法
微信支付 JSAPI 下单时金额单位是“分”。数据库里 decimal(10,2) 存的是元,传参时要做intval($order['pay_amount'] * 100)。统一下单参数组织与签名生成是最容易出错的部分:
private function buildUnifiedOrderParams($order, $openid) { $params = [ 'appid' => $this->cfg['appid'], 'mch_id' => $this->cfg['mch_id'], 'nonce_str' => uniqid('', true), 'out_trade_no' => $order['order_no'], 'total_fee' => intval($order['pay_amount'] * 100), 'body' => '云商城商品', 'spbill_create_ip' => $_SERVER['REMOTE_ADDR'], 'notify_url' => $this->cfg['notify_url'], 'trade_type' => 'JSAPI', 'openid' => $openid, ]; $params['sign'] = $this->buildSign($params, $this->cfg['api_key']); return $params; } private function buildSign($params, $key) { ksort($params); $stringA = ''; foreach ($params as $k => $v) { if ($v === '' || $v === null) { continue; } $stringA .= $k . '=' . $v . '&'; } return strtoupper(md5($stringA . 'key=' . $key)); }签名的规则是:参数按字典序排序,拼接成k=v&k=v形式,最后拼接key=,做 MD5 再转大写。空值参数不参与签名,sign 字段本身不参与签名。total_fee 单位换算写错是最常见的线上事故,数据库 0.01 元如果不乘 100,传给微信就变成 1 分钱;反过来如果浮点数精度出问题,也会导致回调时金额校验失败。
4.3 支付回调:验签、金额校验、幂等
支付回调是整条链路里最容易翻车的环节。微信服务器发起回调后,先验签,再校验订单号和金额,最后更新订单状态,并且必须处理重复通知。通知可能因网络原因被微信重发多次,这是正常行为而不是异常。
public function notifyAction() { $xml = file_get_contents('php://input'); $data = xmlToArray($xml); if (!$this->verifySign($data, $this->cfg['api_key'])) { $this->replyXml('FAIL', 'sign error'); return; } $orderNo = $data['out_trade_no']; $order = $this->findOrder($orderNo); // 关键幂等处理:已支付订单直接返回成功 if ($order['status'] == 1) { $this->replyXml('SUCCESS', 'OK'); return; } if ($data['result_code'] === 'SUCCESS' && (int) $data['total_fee'] === (int) ($order['pay_amount'] * 100)) { $this->db->beginTransaction(); try { $this->updateOrderStatus($orderNo, 1, $data['transaction_id']); $this->db->commit(); $this->replyXml('SUCCESS', 'OK'); } catch (Exception $e) { $this->db->rollBack(); $this->replyXml('FAIL', 'db error'); } return; } $this->replyXml('FAIL', 'amount mismatch'); }回调返回的 XML 必须是固定格式,微信收到 FAIL 会按退避策略重试,不需要人为阻止。幂等判断放在最前面,即使同一笔订单收到多次回调,状态也不会被改乱。事务里只更新订单表和记录 transaction_id,库存扣减应该在订单创建时预扣,或者在回调成功后再扣;后者实现更简单,但要注意重复回调不能重复扣库存。
5. 把支付回调连到本地:用回调模拟脚本验证订单状态机
5.1 开发者工具本地联调开关
小程序开发者工具联调本地 PHP 服务时,打开右上角“详情 -> 本地设置 -> 不校验合法域名”,就能直接请求http://127.0.0.1/index.php。这个方法只在本地联调阶段使用,真机预览或上线前必须把域名配成 HTTPS 合法域名。联调时先用开发者工具的 Network 面板确认请求头和响应体,确认X-Auth-Token和{code,msg,data}结构都正确,再去处理支付回调,否则问题会被层层掩盖。
5.2 回调模拟脚本
支付回调在开发环境很难触发真实支付,所以我会写一个 bash 脚本模拟微信服务器向本地接口推送 XML。验签部分是本地测试的关键,这里用 PHP 命令行生成签名,再拼进 XML 用 curl 提交:
#!/usr/bin/env bash OUT_NO="20240101001" SIGN=$(php -r ' $cfg = ["appid" => "wx123", "mch_id" => "100001", "api_key" => "test_api_key"]; $arr = [ "appid" => $cfg["appid"], "mch_id" => $cfg["mch_id"], "nonce_str" => "abc123", "out_trade_no" => "20240101001", "result_code" => "SUCCESS", "return_code" => "SUCCESS", "total_fee" => "100", "transaction_id" => "42000000000020240001" ]; ksort($arr); $str = ""; foreach ($arr as $k => $v) { $str .= $k . "=" . $v . "&"; } echo strtoupper(md5($str . "key=" . $cfg["api_key"])); ') curl -s -X POST "http://127.0.0.1/index.php?r=order/notify" \ -H "Content-Type: text/xml" \ --data-binary "<xml> <appid><![CDATA[wx123]]></appid> <mch_id><![CDATA[100001]]></mch_id> <nonce_str><![CDATA[abc123]]></nonce_str> <out_trade_no><![CDATA[${OUT_NO}]]></out_trade_no> <result_code><![CDATA[SUCCESS]]></result_code> <return_code><![CDATA[SUCCESS]]></return_code> <total_fee><![CDATA[100]]></total_fee> <transaction_id><![CDATA[42000000000020240001]]></transaction_id> <sign><![CDATA[${SIGN}]]></sign> </xml>"这个脚本里的 total_fee=100 对应订单 1.00 元。签名内容必须与 PHP 端 verifySign 的排序规则完全一致,否则会触发验签失败。脚本执行后,如果流程正常,数据库里订单状态应从 0 变为 1,并且连续执行多次不会产生重复变更,因为幂等判断在最前面拦截了。
5.3 核对状态机的三条检验点
脚本跑通后,不要只看页面上是否跳转成功,直接进 MySQL 核对字段变化:
| 订单状态 | 数据库 status | 预期现象 |
|---|---|---|
| 待支付 | 0 | 库存未减,transaction_id 为空 |
| 已支付 | 1 | 库存已减,transaction_id 有值 |
| 已发货 | 2 | 状态由 AdminLTE 后台操作变更 |
验证库存是否被重复扣减,可以执行SELECT stock FROM shop_goods WHERE id = 目标商品id,在回调前后各跑一次对比。线上环境如果出现回调问题,优先在 notifyAction 入口记录完整 XML 和验签结果,不要为了赶时间把验签临时去掉,支付类接口不验签等于把钱包打开让别人刷。
本文还有配套的精品资源,点击获取