ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

ThinkPHP+FastAdmin+uni-app上门预约系统源码拆解与二次开发

ThinkPHP+FastAdmin+uni-app上门预约系统源码拆解与二次开发

简介:一套基于ThinkPHP+MySQL+uniapp(FastAdmin后台)开发的上门家政按摩H5小程序与预约系统源码,面向需要快速落地同城O2O上门服务的开发者、外包团队及产品经理,可直接部署或二次开发。程序已测试通过,配套完整,包含公众号通知、后端主程序、前端uniapp源码、数据库SQL及通知音频文件。资源包共2000个文件,大小66.31MB,主要文件类型为865个JavaScript、546个Vue组件、350个Markdown文档、91个JSON配置,以及HTML、CSS、XML、Shell、SQL、Doc、Python等文件,覆盖前端页面、接口交互、数据库脚本和环境配置。运行环境需Linux+Nginx+PHP7.2+MySQL5.6并安装fileinfo、Redis扩展;系统基于微信生态,需备案域名、认证服务号、微信支付及小程序账号,H5指公众号H5,须在微信中访问。目前已有255人学习下载。可借源码理解FastAdmin权限管理与uniapp多端开发的完整协作流程,掌握预约下单、服务派单、支付回调、公众号通知和音频提醒等关键模块的落地实现,是学习同城上门预约类项目的实用参考。

1. 上门预约系统到底解决了什么问题:一套三端代码的本地生活服务样板

做本地生活服务的人应该都有同感:用户端想要小程序顺手还能分享H5,管理端要能派单、能结算、能调整服务价格,两头都不能少。这套基于 thinkphp + mysql + uniapp 组合的上门预约系统,把几个端一次性凑齐了——前台用 uni-app 生成微信小程序和 H5,后台用 fastadmin 搭好了管理界面,服务端用 ThinkPHP 写接口,MySQL 负责订单、技师、用户、地址这些核心数据。对想快速上线上门家政、按摩、保洁这类业务的团队来说,它省掉的不只是 UI 工作量,还有从下单、派单到完工结算的整条业务链路。下面按源码实际结构拆开讲。

2. 三端架构拆解:ThinkPHP、FastAdmin 与 uni-app 如何各司其职

这套系统的架构一句话能说清:一个基于 ThinkPHP 的 FastAdmin 应用提供全部后端能力,用户端是独立的 uni-app 工程,数据统一存在 MySQL 里。FastAdmin 跑在同一个 PHP 应用上,通过 admin 模块提供后台;用户端走 api 模块,两者共用同一套模型和配置。这种"一个应用两套入口"的结构,是 FastAdmin 系源码最常见的部署形态,好处是二次开发时不用维护两套后端,坏处是路由和权限要分清楚,别让前端接口和管理页面混在一起。

2.1 为什么是这套组合:二次开发视角的选型逻辑

先说结论:这套组合选的不是"最强"的技术,而是"最省人力"的技术。FastAdmin 基于 ThinkPHP,自带管理员登录、权限节点、通用列表和表单生成,后台管理页基本不用从零写;uni-app 用 Vue 语法写一套代码,编译输出微信小程序和 H5,对上门预约这种重分享、轻安装的业务刚好合适;thinkphp + mysql 的部署资料多,一台 Nginx 就能撑起一个小型平台。

从改造角度看,这套选型还有一个隐藏优点:api 模块和 admin 模块在同一个应用里,订单模型在 application/common/model 下是前后端共用的。后面加字段、改状态、接支付,只需要改一处模型,不会出现"后台改完了接口没跟上"的问题。等业务量上来,接口和后台也可以拆成两个应用分别部署,目录结构已经留好了拆分边界。

2.2 目录结构:后端、后台、前端各自的主力目录与用途

源码解压后主要看两部分:PHP 后端工程和 uniapp 前端工程。下面这张表把最常用的目录列出来,其他目录大多可以暂时忽略。

关注点目录说明
站点入口public/Web 根目录必须指向这里,下有 index.php 与 uploads
后台控制器application/admin/controller/FastAdmin 管理端,按订单、服务、技师等模块分文件
前台接口application/api/controller/H5 / 小程序请求的控制器,Order、Service、Technician 等
数据层application/common/model/订单模型、技师模型,前后端共用
数据库配置application/database.php数据库连接参数、表前缀
前端页面uniapp/pages/首页、服务列表、确认订单、订单详情等页面
前端公共uniapp/common/request.js 等公共方法

几个重点目录展开说。application/api/controller 是用户端实际打到的接口层,FastAdmin 的 API 模块路由通常是 index.php?s=/api/控制器/方法,前端请求路径会拼成类似 /api/order/create 的形式。public 下的 uploads 存放后台上传的服务图、技师头像,站点根目录必须指向 public,否则上传文件会落到外部目录导致前端加载不到。uniapp 前端里,pages.json 控制页面路由和 tabBar,manifest.json 是小程序 appid、H5 域名这些配置的集中地,联调时改的最多的就是这两个文件加 common/request.js。

2.3 数据库设计:订单表、技师表、服务表怎么配合

表前缀默认是 fa_,安装时也可以改。核心就三张业务表加一张用户表。服务项目表结构大致是这样:

字段类型说明
idint主键
namevarchar服务名称,如保开关、推拿
pricedecimal(10,2)单价,单位元
unitvarchar计费单位,次 / 小时
service_minutesint预计服务时长
category_idint所属分类
statustinyint1 上架,0 下架

技师表 fa_technician 除了姓名、头像、手机号之外,最重要的是 lng、lat、server_radius 三个字段。lng 和 lat 是技师当前位置坐标,server_radius 是服务半径,单位公里,派单时用这两个字段做距离筛选。订单表 fa_order 则是业务核心,包含 order_no 订单号、user_id、service_id、technician_id、address、address_coordinate、amount、status、serve_time、pay_time、finish_time、remark 和创建时间。

address_coordinate 字段存的是"经度,纬度"逗号拼接的字符串,派单 SQL 会直接用它做计算。订单状态 status 是这套源码的灵魂:常见的定义是 1 待支付、2 已支付待派单、3 已派单、4 服务中、5 已完成、6 已取消。价格字段统一 decimal(10,2),不要用 float,否则累计对账时会出现精度问题。这些字段定义是理解后面核心代码的前提。

3. 核心代码链路:一次预约从下单到派单的完整实现

把用户操作到后台派单的过程串一遍:用户选服务,填地址和时间,提交订单,后台按距离找技师,技师接单,开始服务,完成订单。对应到代码上是三个核心文件:前端的请求封装、后端的下单控制器、派单控制器。下面按这条链路逐个拆。

3.1 统一请求封装:token 与 401 跳转都收在一处

所有前端请求走同一个封装函数,是这类项目最基础也最重要的约定。uniapp 工程里 common/request.js 负责统一拼接域名、带 token、处理登录过期。

// uniapp/common/request.js const BASE_URL = 'https://api.yourdomain.com/api'; function request(path, data = {}, method = 'GET') { return new Promise((resolve, reject) => { uni.request({ url: BASE_URL + path, method, data, header: { 'token': uni.getStorageSync('token') || '', 'Content-Type': 'application/json' }, success: res => { // 后端统一返回 code 字段,401 表示登录态失效 if (res.data.code === 401) { uni.removeStorageSync('token'); uni.navigateTo({ url: '/pages/login/login' }); reject(res.data); return; } resolve(res.data); }, fail: reject }); }); } export default request;

逻辑说明:每个请求自动从本地缓存拿 token 放进 header,后端靠这个 token 识别用户身份;一旦接口返回 401,说明 token 失效或未登录,前端统一清理本地状态并跳转登录页。BASE_URL 是联调时第一个要替换的地方,后续所有页面都通过 import request 来发请求,不要分散在各页面里用 uni.request 裸调。

参数说明:path 是接口路径,data 是请求体,method 默认 GET,POST 接口调用时显式传入第三个参数。后端要求的 token 字段名是 token,如果你的项目改成了 Authorization,这里和 FastAdmin 的 API 验权配置要同步改,否则会出现"登录成功但业务接口全部未登录"的问题。

3.2 创建订单接口:必传参数、状态初值、事务

用户点"提交订单"时,前端把服务 ID、地址、期望上门时间、备注 POST 到 api/order/create。后台控制器的核心逻辑是校验服务是否存在,组装订单数据,写库并返回订单 ID。

// application/api/controller/Order.php public function create() { $uid = $this->user_id; $service_id = $this->request->post('service_id'); $address = $this->request->post('address'); $serve_time = $this->request->post('serve_time'); $remark = $this->request->post('remark', ''); $service = ServiceModel::get($service_id); if (!$service || $service->status != 1) { $this->error('服务项目不存在或已下架'); } $order_data = [ 'order_no' => date('YmdHis') . mt_rand(1000, 9999), 'user_id' => $uid, 'service_id' => $service_id, 'amount' => $service->price, 'address' => $address, 'serve_time' => $serve_time, 'remark' => $remark, 'status' => 1 // 待支付 ]; Db::startTrans(); try { $order = OrderModel::create($order_data); Db::commit(); $this->success('下单成功', ['order_id' => $order->id]); } catch (\Exception $e) { Db::rollback(); $this->error('下单失败,请重试'); } }

逻辑说明:先查服务表确认服务存在且在上架状态,再组装订单数据。order_no 用时间加随机数生成,保证不会重复;amount 直接取服务价格,不让前端传价格,这是防止用户篡改金额的基本做法。status 初始化为 1 待支付。事务在这里虽然没有跨表写入,但后续如果加优惠券核销、加积分记录,事务就非常必要了,所以保留这个结构。

参数说明:service_id 和 address 是必传,serve_time 是预约时间,remark 可选。前端传价格一律不接收。下单成功后返回 order_id,前端再用这个订单 ID 唤起支付或跳转订单详情。如果想加"提交订单时锁定技师",在 order_data 里加 technician_id 字段即可,第 6 章会专门讲这个改造。

3.3 附近技师查询:一段基于经纬度的派单 SQL

派单是整个上门预约系统的核心难点。这套源码里的做法很直接:以订单地址的经纬度为中心,计算技师表里每个在岗技师的直线距离,过滤出服务半径内的技师,按距离升序返回。

// application/api/controller/Assign.php public function findNearby() { $order_id = $this->request->post('order_id'); $order = OrderModel::get($order_id); if (!$order) { $this->error('订单不存在'); } list($lng, $lat) = explode(',', $order->address_coordinate); $list = TechnicianModel::where('status', 1) ->field("id, name, avatar, server_radius, (6378.138 * 2 * ASIN(SQRT(POW(SIN(({$lat} * PI() / 180 - lat * PI() / 180) / 2), 2) + COS({$lat} * PI() / 180) * COS(lat * PI() / 180) * POW(SIN(({$lng} * PI() / 180 - lng * PI() / 180) / 2), 2)))) AS distance") ->having('distance < server_radius') ->order('distance') ->select(); $this->success('附近技师', $list); }

逻辑说明:这段 SQL 用的是 Haversine 公式,6378.138 是地球半径公里数,distance 的单位是公里。查询条件有两个:技师状态为 1(在岗),且算出来的距离小于技师自己设定的服务半径。结果按距离从近到远排,前端拿到的就是一个可选的技师列表。

参数说明:address_coordinate 字段必须是"经度,纬度"格式,如果为空,这条 SQL 会直接报错。常见的兜底做法是退化成按城市加状态筛选:先在地址表里解析出城市字段,然后查 city_id 匹配的技师。这个兜底建议在改造时加上,能避免订单因为缺坐标卡死在派单环节。

3.4 FastAdmin 后台:订单列表与状态切换怎么配置

后端接口就绪后,管理后台的订单列表不需要手写页面,FastAdmin 提供了命令行生成器。在应用根目录执行以下命令,就能给 fa_order 表生成一套标准的后台增删改查:

php think crud -t fa_order -u 1

执行后,application/admin 下会出现 Order 控制器和视图文件,后台菜单自动挂到 admin/order 下。列表展示哪些字段、字段怎么格式化,由 application/admin/view/order/index.html 里对应的 JS 配置 control 决定。订单状态切换是运营最频繁的操作,对应的代码逻辑通常长这样:

// application/admin/view/order/index.html 对应的事件绑定 $('.btn-change-status').on('click', function () { var id = $(this).data('id'); $.ajax({ url: 'order/changeStatus', data: { id: id, status: 3 }, // 3 表示已派单 success: function (res) { if (res.code === 1) { window.location.reload(); } } }); });

逻辑说明:changeStatus 是后台控制器里自定义的方法,接收订单 ID 和目标状态,更新后刷新列表。这套结构的好处是,每个状态流转动作都可以挂独立的校验,比如从"服务中"改成"已完成"时强制填写完结金额,从"已派单"改成"已取消"时记录取消原因。

参数说明:data 里的 id 是订单主键,status 是目标状态码。后台控制器里对应接收这两个参数,并在操作前用管理员权限做一次验权,防止越权改单。

4. 本地搭建与运行:从空服务器到 H5+小程序跑通

这一章目标很直接:把这套源码在你自己的机器上跑起来。先说后端初始化,再说前端联调,最后给一个三端已经连通的判断方法。整个过程中 90% 的问题集中在配置和路径上,代码本身基本不用动。

4.1 后端初始化:导库、改配置、配伪静态

环境准备按 FastAdmin 的常规要求来:PHP 7.1 到 7.4 最稳,MySQL 5.7 以上,Nginx 或 Apache 都行。先把数据库建好,再把源码自带的 SQL 文件导入。SQL 文件一般在根目录或 sql 目录下,不同发行版位置不同,先找一下。

mysql -uroot -p -e "create database fastadmin_home default charset utf8mb4;" mysql -uroot -p fastadmin_home < install.sql

两条命令的含义:第一条创建名为 fastadmin_home 的数据库,指定 utf8mb4 字符集;第二条把 install.sql 里的表结构和初始数据导入这个库。导入成功后会看到 fa_user、fa_order、fa_technician 等表。

接着修改 application/database.php,把数据库连接参数换成你自己的。需要改的字段是 hostname、database、username、password、prefix。prefix 默认 fa_,如果导入的 SQL 里表前缀不是 fa_,这里必须改成一致的,否则后台登录会报表不存在。

最后是伪静态配置。FastAdmin 的路由是 pathinfo 风格,Nginx 下站点根目录要指向 public,并加上 rewrite。配置示例:

server { listen 80; server_name localhost; root /path/to/项目根目录/public; index index.php; location / { if (!-e $request_filename) { rewrite ^(.*)$ /index.php?s=$1 last; } } }

提示:根目录指错是最常见的启动失败原因。一定要指到 public 目录,不是项目根目录。

配置完成后,访问 http://localhost/index.php?s=/admin 进入后台,默认管理员账号通常为 admin,密码 admin 或 123456。上线前第一件事就是改掉默认密码,不要在这上面偷懒。

4.2 前端联调:H5 与微信小程序对接后端的地方不一样

前端工程用 HBuilderX 打开 uniapp 目录,第一步修改 common/request.js 里的 BASE_URL。如果后端跑在本机,H5 开发模式建议用代理解决跨域,不用改 BASE_URL 的域名。manifest.json 的 h5 节点配置本地代理:

// uniapp manifest.json 的开发配置片段 "h5": { "devServer": { "proxy": { "/api": { "target": "http://127.0.0.1:8080", "changeOrigin": true } } } }

逻辑说明:开发环境下,前端请求 /api 开头的地址会自动转发到本机 8080 端口的 PHP 应用,绕开浏览器跨域限制。生产环境 H5 直接配 HTTPS 域名,不需要代理。

微信小程序和 H5 的差别在域名校验。微信开发者工具里,默认会校验 request 合法域名,本地联调时可以在"详情 → 本地设置"里勾选"不校验合法域名"。但真机预览和体验版必须在小程序后台把 API 域名加入 request 合法域名,而且要求是已备案的 HTTPS 域名。这点后面避坑章还会再提。

4.3 三端已连通的判断方法

跑通后怎么确认链路是通的?按三条线自检:第一,打开后端 runtime/log 目录下的日志文件,看请求是否进来、在哪一步停下;第二,用接口调试工具先调登录接口拿 token,再带 token 调一次订单列表接口,接口能通说明 PHP 应用本身没问题;第三,在后台创建一个测试订单,然后查 MySQL 的 fa_order 表,能看到新记录就说明数据链路完整。

这三条自检顺序固定:日志确认请求到达,接口确认逻辑正确,数据库确认落库成功。按这个顺序排查,能把"前端问题"和"后端问题"快速分开,避免在两端来回猜。

5. 避坑记录:搭建与联调中最常踩的五个坑

这一章是实际操作里反复遇到的坑,按现象到原因再到解决展开。每一条都对应一个具体的排查路径,希望你看完之后遇到同类问题能直接定位。

5.1 小程序请求全部失败:url not in domain list

现象:微信开发者工具里,所有 uni.request 请求都报 request:fail url not in domain list,页面数据全空。

原因:小程序平台对 request 接口有域名白名单限制。开发者工具默认开启合法域名校验,而你的 API 域名没在小程序后台配置,或者开发者工具里的"不校验合法域名"选项没勾上。

解决:本地联调阶段,在微信开发者工具的"详情 → 本地设置"里勾选"不校验合法域名";真机预览和发布前,到微信公众平台把 API 域名加入 request 合法域名。注意域名必须是 HTTPS、已备案,且协议和域名要完全一致,http 和 https 不能混着填。

5.2 后台图片上传成功,前端加载不出来

现象:FastAdmin 后台上传的服务图片、技师头像显示正常,但小程序和 H5 页面请求这些图片时 404 或空白。

原因:上传文件默认落在 public/uploads 目录,正确访问路径是 /uploads/xxx.jpg。但很多部署把站点根目录指到了项目根目录,导致上传文件实际在项目根目录以外或路径不对,前端拼接的 URL 自然打不开。

解决:先把 Nginx 站点根目录固定到 public,这是根本;前端图片地址用相对路径 /uploads/xxx.jpg,不要在前端写死带域名和端口号的绝对地址,避免域名一换就大面积裂图。

5.3 登录成功但其他接口全部提示未登录

现象:登录接口返回 token 正常,前端也存进了缓存,但一点进订单详情就跳回登录页,打开接口返回 401 未登录。

原因:前后端 token 字段名不一致。前端 header 里发送的是 token,后端 API 验权默认也从 header 的 token 取;如果某一端改成了 Authorization,或者大小写不一致,验权就会失败。另一种情况是 token 本身已过期,后端正确返回 401,前端统一跳转,看起来像"没登录上"。

解决:找到后端 API 验权的入口,确认它从哪个 header 取 token,把前端 request.js 的 header 改成一致。同时在登录接口里检查 token 有效期配置,开发阶段可以把过期时间调长一点,减少干扰。

5.4 SQL 导入失败:字符集与版本不匹配

现象:导入 install.sql 时报错,常见信息有 Index column size too large 或 Unknown collation: 'utf8mb4_unicode_ci'。

原因:SQL 文件是按 MySQL 5.7 和 utf8mb4 生成的,你本地 MySQL 版本过低,或者建库时没有指定字符集,导致表结构里的索引和排序规则不被支持。

解决:建库时显式指定 utf8mb4,命令用 create database xxx default charset utf8mb4;把 MySQL 升级到 5.7 以上。如果还有报错,检查 sql_mode 是不是严格模式,临时可以关掉 strict mode 再导入。

5.5 订单状态对不上:前端永远显示待支付

现象:后台订单已经是"已支付",用户端却一直显示"待支付",状态名和实际流转对不上。

原因:订单状态枚举的前后端定义不一致。后台代码把 1 定义为待支付,前端页面把 0 定义为待支付,两边没有共用同一份状态字典。

解决:在前后端各维护一份公共状态字典,后端在 common 常量里定义,前端在 common/status.js 里定义,字段顺序和数值必须完全一致。凡是展示状态的地方都从字典取值,禁止在页面里写死状态数字。改完顺手把 fa_order 表里历史数据的 status 也校正一遍。

6. 二次开发实战:把后台派单改成用户自选技师

最后做一个最典型的业务改造:把默认的"后台指派技师"改成"用户自己选技师"。这个改动覆盖数据库、接口、前端三处,做完之后,这套源码的骨架你基本就摸清了。

6.1 先锁定改动范围

要改的地方总共三处加一个字段。数据库给 fa_order 表加 technician_id 字段,存用户选的技师 ID;后端创建订单接口加一个可选参数 technician_id,并在传值时把订单状态改成"待技师接单";前端确认订单页加一个技师选择列表,展示附近技师和距离。后台派单流程保留,作为无人选择时的兜底。

ALTER TABLE `fa_order` ADD COLUMN `technician_id` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '用户自选技师ID' AFTER `service_id`;

逻辑说明:technician_id 默认 0 表示未指定,非 0 表示用户指定。订单详情查询时联查 fa_technician 表,就能拿到技师姓名和头像。

6.2 前后端的具体改法

前端在用户选完服务后,调一次附近技师接口,把结果渲染成可选列表。距离字段在后端返回时单位是公里,展示前格式化一下会更友好。

// 预约确认页的技师选择逻辑 async function loadTechnicians(serviceId) { const res = await request('/order/canAssign', { service_id: serviceId }, 'POST'); const list = res.data.map(t => ({ ...t, distanceText: t.distance > 1 ? t.distance.toFixed(2) + 'km' : Math.round(t.distance * 1000) + 'm' })); this.technicians = list; }

逻辑说明:canAssign 接口内部就是第 3 章那段 Haversine 查询,只是入口从订单 ID 换成了服务 ID。前端拿到列表后,用户点选某个技师,就把技师 ID 存进表单,提交订单时随请求体一起发送。

后端在创建订单接口里加一段判断:

$technician_id = $this->request->post('technician_id', 0); if ($technician_id) { $tech = TechnicianModel::get($technician_id); if (!$tech || $tech->status != 1) { $this->error('技师不可用,请重新选择'); } $order_data['technician_id'] = $technician_id; $order_data['status'] = 2; // 待技师接单(原 2 是已支付待派单,这里沿用 2) }

逻辑说明:传了 technician_id 才会校验技师状态,并把订单状态置为待技师接单;不传则维持原来的待支付流程,由后台派单。这两个分支互不影响。

6.3 改造后的验证

验证要覆盖两条路径:在 FastAdmin 后台给某技师填上经纬度,用前端选该技师下单,查 fa_order 表确认 technician_id 有值、status 为 2;再走一遍不选技师的旧流程,确认后台派单仍然正常。两条路径都通,改造才算完成。

拆这类源码最值钱的地方,其实不是某个接口怎么写,而是状态和字段的约定怎么贯穿三端。我这次改的时候,第一件事不是写代码,而是先翻 fa_order 的 status 注释和前端的公共字典,把"待派单、待接单"的定义对齐才动手。从那以后,我接手任何 FastAdmin 项目,第一件事都是先导出数据库字典再碰控制器,这个习惯帮我少走了不少弯路。希望这份拆解和改造思路对你也有用。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进