简介:一套基于ThinkPHP框架与PHP7环境开发的小程序点餐系统设计源码,主要面向毕业设计学生和初级小程序开发者,可用于搭建堂食、外卖、奶茶、水果、手工艺品等多种场景的在线点餐服务。项目后台采用ThinkPHP框架,要求PHP 7及以上版本,并推荐PHPStudy 2018搭配MySQL 5.5使用;前端采用微信小程序原生代码编写,无需uniapp,可直接在微信开发者工具中运行,并参考了黑马教程的小程序示例,便于初学者对照学习小程序请求发起、后端接口返回及数据库读写等完整流程。资源包共包含436个文件,以254个PHP逻辑文件为核心,辅以56个webp、38个jpg与17个png图片素材,以及HTML页面、JS/CSS前端脚本、JSON配置、数据库SQL脚本和安装备份文件等,压缩包大小约5.43MB,目录结构清晰,适合按模块快速定位后台、前端和数据库相关内容。源码中自带的SQL脚本和环境配置参考,可帮助使用者在本地一键初始化数据库,再将商品、分类、订单等模块改为奶茶、水果等专用点餐服务,有效缩短毕业设计或课程项目的开发周期。目前已有608人学习下载,值得作为真实项目参考。
1. ThinkPHP + PHP7 的点餐小程序源码,先想清楚这四个组件
「基于ThinkPHP的PHP7点餐小程序设计源码」这类项目在源码站里常年排在前列,下载的人多,真正能在本地跑起来、接上微信支付的人少。它解决的事情其实很具体:微信小程序端负责点餐界面和交互,ThinkPHP 后端暴露一组 JSON 接口,PHP7 环境提供运行底座,MySQL 存菜品、购物车和订单。它和商城源码最大的区别是流程短:浏览、加购、下单、支付、取餐,没有退款退货那种复杂状态机,所以看这份源码的重点应该放在订单结构、库存扣减和支付回调上,而不是页面有多花哨。适合的人群是能改 PHP 但不打算从零写管理端的开发者,也包括给餐饮小店做技术交付的独立开发者。想真正把它变成能上线接单的东西,先得把目录、数据表、接口和小程序端四条线串起来。
2. 从目录结构拆源码:入口、路由与 ThinkPHP 配置加载位置
拿到源码先别急着配数据库。先花十分钟把项目根目录看明白,就能判断出这套源码用的是 ThinkPHP 5.x 还是 6.x,这直接决定了后面调试写法。两类版本的外层结构基本一致:public 目录是唯一 Web 访问入口,application 或 app 目录下放控制器和模型,route 目录定义接口路径,config 目录保存数据库和微信支付配置,runtime 目录存日志与缓存。很多点餐小程序源码是在 5.1 或 6.0 基础上改出来的,认清这四个位置,改配置和加接口就不会迷路。
2.1 public/index.php 入口与 Nginx 站点指向
// public/index.php(ThinkPHP 5.1 常见的入口写法) namespace think; require __DIR__ . '/../thinkphp/base.php'; $http = (new App())->http; $response = $http->run(); $response->send(); $http->end($response);入口文件做的事情只有两件:加载框架基础文件,把请求交给 Http 对象处理并输出响应。Nginx 部署时站点 root 要指向 public 目录,不能把整个项目暴露出去,否则别人可以直接访问 application 里的 PHP 文件。ThinkPHP 6.x 的入口结构也类似,只是基础文件路径和容器初始化方式略有不同,判断标准是看入口引用的是 thinkphp/base.php 还是框架自带的 vendor 自动加载。
2.2 路由定义:先找出所有 JSON 接口
// route/route.php use think\facade\Route; Route::rule('food/list', 'api/food/list', 'GET'); Route::rule('food/detail/:id', 'api/food/detail', 'GET'); Route::rule('cart/add', 'api/cart/add', 'POST'); Route::rule('cart/list', 'api/cart/list', 'GET'); Route::rule('order/create', 'api/order/create', 'POST'); Route::rule('order/pay', 'api/order/pay', 'POST'); Route::rule('order/notify', 'api/order/notify', 'POST');路由文件是整个源码的索引。先读一遍这里就能知道后端暴露了哪些接口,也能反向对应小程序页面调用的 URL。上面这段是典型点餐项目的最小路由集合,注意最后一条 order/notify 是微信支付回调入口,它不能套登录鉴权中间件,验签逻辑只能在控制器内部做。route 文件里如果出现带参数缩略语法如 food/detail/:id,对应控制器方法里要接收 id 参数。若源码里没有 route 文件,则走默认的控制器/方法名,URL 形如 /api/food/list,也能正常访问,只是可读性差一些。
2.3 数据库与微信配置的位置,以及 SQL 监听一般加在哪
配置集中在 config 目录:database.php 里是数据库连接、表前缀和是否开启 SQL 日志;wechat.php 这类自定义配置保存小程序 appid、secret,以及支付商户号。修改配置后清空 runtime 缓存再测试,否则改动不生效。很多人问 thinkphp 监听 sql 的代码一般添加在哪里,最常见的做法是放在应用初始化处:ThinkPHP 5.1 写在 application/common.php,ThinkPHP 6.0 写在 app/AppService.php 的 boot 方法里。
// ThinkPHP 5.1 写法,放在 application/common.php \think\Db::listen(function ($sql, $time, $explain) { \think\Log::write('[SQL] ' . $sql . ' [' . $time . 's]', 'debug'); }); // ThinkPHP 6.0 写法,放在 app/AppService.php 的 boot() 中 \think\facade\Db::listen(function ($sql, $time, $explain) { \think\facade\Log::write('[SQL] ' . $sql . ' [' . $time . 's]'); });挂上这段监听后,每次接口产生的 SQL 都会落到 runtime 日志里。排查「接口慢」「某个字段没查出数据」这类问题,直接看日志里 SQL 的执行顺序和耗时,比在控制器里到处写 dump 高效得多。另一个需要确认的是 PHP 版本与框架的匹配:ThinkPHP 6.x 要求 PHP 7.2.5 以上,PHP 7.4 是最稳妥的运行版本,6.0 的 LTS 版本在 PHP 7.4 上表现稳定;如果源码是 ThinkPHP 5.0 老版本改出来的,在 PHP 7.4 上容易出现 count() 参数类型报错;如果是 ThinkPHP 3.2 那种老古董,PHP 7 还能凑合跑,别想着兼容 PHP 8,升级框架比打补丁划算。
| 目录/文件 | 作用 |
|---|---|
| public/ | 唯一对外 Web 入口,Nginx root 指向这里 |
| application/ 或 app/ | 控制器、模型、视图 |
| route/ | 接口路由规则 |
| config/ | 数据库、微信、支付配置 |
| runtime/ | 日志与缓存,部署后可写 |
3. 点餐源码的数据表设计:菜品、购物车、订单怎么落库
数据库设计是这类源码里信息量最大的部分。点餐和商城不一样的地方在于:菜品没有复杂的 SKU 规格维度,多数项目用一个字符串字段记录规格描述;订单的终点状态是「取餐完成」,不需要售后退款;购物车甚至可以不入库,但源码里通常会留一张 cart 表方便用户换设备后还能看到。读源码时先把表结构过一遍,重点看价格字段是整数还是小数,状态字段的注释是否完整,索引是否覆盖查询条件。
3.1 菜品表:价格单位用「分」,避免浮点误差
CREATE TABLE `food` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `category_id` int(11) NOT NULL DEFAULT 0 COMMENT '分类ID', `name` varchar(100) NOT NULL DEFAULT '' COMMENT '菜品名', `price` int(11) NOT NULL DEFAULT 0 COMMENT '单价,单位:分', `image` varchar(255) NOT NULL DEFAULT '' COMMENT '图片', `stock` int(11) NOT NULL DEFAULT 0 COMMENT '库存,0表示不限', `status` tinyint(1) NOT NULL DEFAULT 1 COMMENT '1上架 0下架', `sales` int(11) NOT NULL DEFAULT 0 COMMENT '销量', `create_time` int(11) NOT NULL DEFAULT 0, PRIMARY KEY (`id`), KEY `idx_category_status` (`category_id`, `status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;字段说明里最值得留意的是 price 用 int 且单位是分。很多二次开发的人把价格改成 decimal(10,2) 直接用元存储,结果在计算总价和微信支付金额转换时反复出现精度问题。用整型存分,后端算总价是整数乘法,传给微信支付也是整型,完全避开浮点数。stock 字段在点餐场景一般写 0 表示不限库存,只有需要限量供应的菜品才启用;但正式接单的项目最好统一用数值,避免判断逻辑写两套。idx_category_status 联合索引覆盖了小程序首页「按分类查上架菜品」这个最频繁的查询路径。
3.2 订单表:order 是保留字,表名建议用 orders 或加前缀
CREATE TABLE `orders` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL DEFAULT '' COMMENT '订单号', `user_id` int(11) NOT NULL DEFAULT 0 COMMENT '用户ID', `total_amount` int(11) NOT NULL DEFAULT 0 COMMENT '总金额,单位:分', `status` tinyint(1) NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2制作中 3可取餐 4已完成 5已取消', `pay_time` int(11) NOT NULL DEFAULT 0 COMMENT '支付时间', `remark` varchar(255) NOT NULL DEFAULT '' COMMENT '备注', `create_time` int(11) NOT NULL DEFAULT 0, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user_status` (`user_id`, `status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里有个很典型的坑:order 是 SQL 保留字,直接用 order 做表名在原生 SQL 里必须加反引号,ThinkPHP 的查询构造器虽然会自动处理,但碰到复杂联表或原生查询还是容易踩语法错误。源码里如果确实用了 order 表名,改起来又麻烦,可以在配置里把表前缀改掉然后在模型里映射,不过最干净的做法是直接建 orders 表,模型里指定 $name = 'orders'。订单状态用整型数字表达比字符串直观,配合注释就能把状态机读明白:0 到 5 的每个数字对应业务里的哪个节点,小程序端和 PC 管理端共用同一套枚举。订单明细表 order_items 以 order_id 关联主表,冗余 food_name 和 price,因为菜品下架或改名后旧订单仍然要能显示当时买的什么。
3.3 模型层:时间戳、访问器与预加载
<?php namespace app\common\model; use think\Model; class Food extends Model { protected $name = 'food'; protected $autoWriteTimestamp = true; public function category() { return $this->belongsTo(FoodCategory::class, 'category_id', 'id'); } // 输出给前端时把分转成元 public function getPriceAttr($value) { return $value / 100; } }模型层负责把表和业务逻辑解耦。autoWriteTimestamp 开启后,create_time 和 update_time 由框架自动维护,不用在插入代码里手写 time() 函数。getPriceAttr 是 ThinkPHP 的访问器语法,读取 price 字段时自动做分转元,前端拿到的就是常见的「12.50」格式。配合 with(['category']) 做预加载,查询菜品列表时一次性把分类信息带出来,避免循环里逐条查分类表的 N+1 问题。看一套源码的模型层写得规不规整,就看它字段处理逻辑是放在模型里还是散落在控制器里,后者改需求时要挨个控制器排查,容易漏。
注意:订单金额相关字段,前后端都不要用浮点数参与计算,整型分是唯一可靠的单位。
4. 点餐主流程的 API 实现:加购、下单事务与防超卖
点餐后端接口说穿了就是四类:菜品查询、购物车操作、下单支付、订单查询。前两类是常规增删改查,后两类藏着真正容易出问题的地方。菜单接口注意用缓存减少数据库压力,购物车接口要确认用户身份来源,下单接口必须使用事务并重新计算金额。
4.1 菜品列表与购物车加购
public function list() { $list = Food::with(['category']) ->where('status', 1) ->order('sales', 'desc') ->select(); return json(['code' => 0, 'data' => $list]); } public function addCart() { $userId = $this->getUserId(); // 从 token 解析,不能信任前端传的 user_id $foodId = (int) input('post.food_id'); $num = max(1, (int) input('post.num')); $exist = Db::name('cart') ->where('user_id', $userId) ->where('food_id', $foodId) ->find(); if ($exist) { Db::name('cart')->where('id', $exist['id'])->setInc('num', $num); } else { Db::name('cart')->insert([ 'user_id' => $userId, 'food_id' => $foodId, 'num' => $num, 'create_time' => time(), ]); } return json(['code' => 0, 'msg' => '已加入购物车']); }菜品列表接口的要点是只查 status = 1 的上架菜品,配合 with 预加载分类信息,首页渲染时不用再按分类逐个发请求。加购接口里两个细节值得照抄:用户 ID 必须从登录态里解析,源码里如果出现前端传 user_id 就直接入库的写法,说明存在越权漏洞;setInc 是数据库层的原子自增操作,多人同时加同一道菜时不会丢更新。num 用 max(1, ...) 把非法输入兜底到 1,防止负数把购物车数量改成负值。这些防御看起来基础,但很多二次开发版本恰恰是在这些位置被改坏的。
4.2 下单接口:事务、防超卖与服务端重算金额
public function create() { $userId = $this->getUserId(); $items = json_decode(input('post.items'), true); if (empty($items)) { return json(['code' => 1, 'msg' => '订单不能为空']); } Db::startTrans(); try { $total = 0; foreach ($items as $item) { // 条件更新扣库存,stock > 0 是防超卖的关键 $affected = Db::name('food') ->where('id', $item['food_id']) ->where('status', 1) ->where('stock', '>', 0) ->setDec('stock', $item['num']); if (!$affected) { throw new \Exception('菜品已售罄'); } $food = Db::name('food')->find($item['food_id']); $total += $food['price'] * $item['num']; } $orderNo = date('YmdHis') . str_pad(mt_rand(1, 9999), 4, '0', STR_PAD_LEFT); $orderId = Db::name('orders')->insertGetId([ 'order_no' => $orderNo, 'user_id' => $userId, 'total_amount' => $total, 'status' => 0, 'remark' => input('post.remark', ''), 'create_time' => time(), ]); foreach ($items as $item) { $food = Db::name('food')->find($item['food_id']); Db::name('order_items')->insert([ 'order_id' => $orderId, 'food_id' => $food['id'], 'food_name' => $food['name'], 'price' => $food['price'], 'num' => $item['num'], ]); } Db::commit(); return json(['code' => 0, 'order_no' => $orderNo, 'order_id' => $orderId]); } catch (\Exception $e) { Db::rollback(); return json(['code' => 1, 'msg' => $e->getMessage()]); } }下单接口能看出整套源码的成色。三件事是底线:金额以后端重算的为准,前端传什么 total 都忽略;扣库存用 setDec 配合 stock > 0 条件,影响行数为 0 就是库存不足,这是防超卖的标准做法;事务保证扣库存和写订单同生共死,中间任何一步异常都回滚。订单号用时间加随机数拼接,高并发下可能重复,更稳的做法是加一个唯一索引兜底,MySQL 报重复键异常时换个随机数重试。这里有个值得注意的细节:循环里每条菜品都查一次 food 表,菜品多时性能一般,但对点餐场景足够,不要为了性能把逻辑改复杂,事务完整性和数据准确性优先。
提示:微信支付 v3 的回调可能比客户端响应晚到几秒,前端不要依赖 wx.requestPayment 的 success 判断订单状态,以后端订单查询接口为准。
| 现象 | 排查位置 | 常见原因 |
|---|---|---|
| 加购数量变成 0 或负数 | cart 表 num 字段 | 前端传了负值且后端未过滤 |
| 下单提示菜品已售罄 | food 表 stock/status | 库存为 0 或菜品被下架 |
| 支付成功但订单仍待支付 | orders 表 pay_time/status | 回调验签失败、金额不一致 |
| 订单重复提交 | orders 表 order_no | 手写订单号重复,缺少唯一索引兜底 |
4.3 支付参数生成:后端组装好签名,前端只负责调起
public function pay() { $orderNo = input('post.order_no'); $order = Db::name('orders')->where('order_no', $orderNo)->find(); if (!$order || $order['user_id'] != $this->getUserId()) { return json(['code' => 1, 'msg' => '订单不存在']); } if ($order['status'] != 0) { return json(['code' => 1, 'msg' => '订单状态不可支付']); } $params = [ 'appid' => config('wechat.appid'), 'mchid' => config('wechatpay.mchid'), 'description' => '点餐订单-' . $orderNo, 'out_trade_no' => $orderNo, 'notify_url' => config('wechatpay.notify_url'), 'amount' => ['total' => $order['total_amount']], 'payer' => ['openid' => $this->getOpenid()], ]; // 用商户私钥对 params 签名,请求微信支付 API v3 统一下单 // 拿到 prepay_id 后,在服务端组装 timeStamp/nonceStr/package/paySign 返回 }支付接口的钱相关参数都从订单表读取,前端只能传 order_no。金额单位是分,total_amount 本身就是整型,这里不会再产生元转分的精度问题。真正的微信支付 v3 签名过程就是三件事:构造请求体、用商户私钥生成 Authorization 头、携带证书序列号;拿到 prepay_id 后,再用同样的私钥对小程序端调起支付的参数做 RSA 签名。源码里如果支付逻辑用 v2 的 MD5 签名,在小程序端也能用,但新商户号基本都要求 v3,改造时把签名工具类单独抽出来,别把证书路径和密钥硬编码在控制器里。
5. 小程序端对接:请求封装、登录态、动态标题与支付调起
看完整套后端,小程序端的代码其实就是个会发请求的界面。点餐小程序页面通常包括首页菜品列表、购物车、确认订单、订单列表和个人中心,所有页面共用一套请求封装和登录逻辑。拿到源码后先找 utils/request.js 或 api 目录,里面的 BASE_URL 和后端路由对应起来,整个链路就通了。如果是 uniapp 编写的源码,请求封装是 uni.request,逻辑相同,可以用 HBuilderX 直接运行到微信开发者工具。
5.1 请求封装与登录态:wx.login 换 code,再换 token
// utils/request.js const BASE_URL = 'https://api.example.com'; function request(path, data = {}, method = 'POST') { return new Promise((resolve, reject) => { wx.request({ url: BASE_URL + path, method, data, header: { 'Content-Type': 'application/json', 'Authorization': wx.getStorageSync('token') }, success: (res) => { if (res.data.code === 0) { resolve(res.data); } else { wx.showToast({ title: res.data.msg || '请求失败', icon: 'none' }); reject(res.data); } }, fail: reject }); }); } module.exports = { request };请求封装统一做三件事:拼接 BASE_URL、把 token 放进 Authorization 头、按后端约定的 code 字段统一处理错误提示。后端通过 token 解析用户身份,token 由 wx.login 获取的 code 调用后端的 login 接口换得。完整的登录流程是:小程序 wx.login 拿到一次性 code,传给后端;后端拿 code 请求微信的 jscode2session 接口,得到 openid;后端生成自己的 token 返回,小程序存进 storage,后续请求带上。注意 code 五分钟内有效且只能用一次,不要在每次请求时都调 login。
| 小程序页面 | 后端接口 | 说明 |
|---|---|---|
| pages/index | food/list | 首页菜品列表 |
| pages/cart | cart/list、cart/add | 购物车 |
| pages/confirm | order/create | 提交订单 |
| pages/pay | order/pay | 调起支付 |
| pages/order-list | order/list | 订单列表 |
5.2 支付调起:requestPayment 的参数一个都不能错
request('/order/pay', { order_no: orderNo }).then(res => { wx.requestPayment({ timeStamp: res.data.timeStamp, nonceStr: res.data.nonceStr, package: res.data.package, // 形如 prepay_id=xxx signType: 'RSA', paySign: res.data.paySign, success: () => { wx.redirectTo({ url: '/pages/order/detail?id=' + orderId }); }, fail: (err) => { wx.showToast({ title: '支付取消', icon: 'none' }); } }); });调起支付的参数全部由后端返回,前端只做透传。一个常见的问题是支付后跳转页面拿不到支付结果,正确做法不是在前端 success 里更新状态,而是等微信支付回调通知后端,后端更新订单状态后,小程序再发一次订单查询刷新页面。支付回调可能延迟几秒,前端要做好轮询或下拉刷新的兜底。signType 在微信支付 v3 下是 RSA,v2 才是 MD5,源码里如果前端写死 MD5 而后端是 v3 签名,调起时会报签名错误。
5.3 页面细节:动态设置标题与顶部导航栏高度适配
// 页面 onLoad 里根据菜品分类动态改标题 wx.setNavigationBarTitle({ title: categoryName }); // 自定义导航栏时,用胶囊按钮位置反推出导航栏高度 const menuRect = wx.getMenuButtonBoundingClientRect(); const { statusBarHeight } = wx.getSystemInfoSync(); const navBarHeight = (menuRect.top - statusBarHeight) * 2 + menuRect.height;小程序动态设置标题只需要调 wx.setNavigationBarTitle,接口返回的分类名直接当标题,适合点餐首页按分类切换的场景。如果源码用了自定义导航栏,最常见的坑是顶部导航栏高度在各种机型上不一致,通过 wx.getMenuButtonBoundingClientRect 拿胶囊按钮的位置反推导航栏高度,适配效果要比写死 44px 或 64px 稳定得多。这两个点属于小程序端的高频改动需求,拿到源码后先确认导航栏是原生还是自定义,再决定改哪里。
6. 上线前的安全收尾:反编译检查、支付回调验证与 SQL 监听核对
上线前最后一遍检查,按三件事做即可:假设小程序包会被反编译、收款会被伪造回调、订单数据可能因日志缺失无法追溯。小程序发布后,用户手机本地会存 wxapkg 包,网上的解包工具能很轻松地把 wxml、js、json 还原出来,所以 AppSecret、API v3 密钥、数据库密码这种敏感信息绝对不能出现在前端代码里,也不要用「前端隐藏判断」代替后端鉴权。另外关注 ThinkPHP 自身的官方安全通告,尤其是历史曝出的任意文件包含和 SQL 注入类漏洞,框架版本别停在已知有洞的老版本上。
# 在解包出来的小程序代码目录里搜密钥关键字,搜到即删 grep -riE "appsecret|apiv3key|mch_private|password" . --include="*.js" --include="*.json"支付回调是伪造重灾区。微信支付 v3 的回调通知会带签名头,后端必须先用微信支付平台证书验证签名,再用 API v3 密钥解密 resource 字段,拿到的订单号和金额要与本地订单完全一致,且订单状态为待支付才能更新。验证逻辑里最容易漏的是金额不一致也要拒绝,防止有人拿支付一分钱的回调把大额订单标记成已支付。
最后用前面挂好的 SQL 监听做一次全链路核对。清空 runtime 日志,在小程序里走一遍加购、下单、支付、查询,然后打开日志文件看 SQL 顺序:先有查菜品和库存的 select,接着是扣库存的 update,然后才是插入 orders 和 order_items 的 insert。日志里没有出现 orders 和 order_items 的 insert 时,优先检查事务块里抛出的异常是否被 catch 吞掉,ThinkPHP 的异常处理会把具体错误记录在 runtime 日志里,先看异常再看业务代码。
本文还有配套的精品资源,点击获取