ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

宠物店小程序开发实战:ThinkPHP+Laravel构建商城预约闭环系统

宠物店小程序开发实战:ThinkPHP+Laravel构建商城预约闭环系统 我做宠物店小程序项目时第一版只做了商品展示和在线留言结果上线没两天就被店长吐槽客人还是在微信里问“我家猫明天能洗澡吗”“疫苗还有没有位置”后台也完全看不到服务进度。后来我把整个系统重做成了一站式闭环微信小程序做用户入口ThinkPHP 6 负责商城和预约的接口层Laravel 8 负责后台管理MySQL 存业务数据Redis 管登录态、库存和预约锁。这篇文章就是这次开发实战的完整记录包括数据库设计、下单扣库存、服务预约并发处理、两套 PHP 框架怎么分工协作以及上线阶段踩过的真实坑。如果你是拿来做课设、毕设或者是准备给自家宠物店配一套源码系统这里面的思路和代码片段都可以直接参考。1. 为什么我把商城、预约和会员体系设计进同一个后端1.1 宠物店业务的闭环角色宠物店和普通电商最大区别是它不仅卖猫粮狗粮、猫砂零食还卖“时间”——美容、洗澡、寄养、疫苗、驱虫这些服务都依赖固定的时段和技师。如果只做商城用户买完东西就走了店铺的佣金和复购完全靠运气如果只做预约又浪费了商品销售的流量入口。所以我在设计时把系统分成了三个角色用户端小程序负责商城浏览、下单、服务预约管理后台负责商品上架、库存维护、技师排班、订单核销运营端则通过报表看销售额和预约完成率。三个角色共用同一套会员体系用户在小程序里登录一次既能在商城买东西也能给宠物建档并预约服务不需要考虑两套账号数据合并的问题这是最关键的产品决策。1.2 ThinkPHP和Laravel的边界划分标题里同时出现了 ThinkPHP 和 Laravel这在实际项目里不是“同时运行两个框架”的意思而是按职责拆开小程序 API 接口用 ThinkPHP 6管理后台用 Laravel 8。选择 ThinkPHP 6 写接口是因为它路由简单、验证器用着顺手、单个请求的响应速度足够快对中小型项目来说“开箱即用”的体验很好。管理后台用 Laravel 8是因为后台大量涉及关联查询、嵌套数据展示、队列任务和定时脚本Eloquent ORM 在这些场景下写起来比 ThinkPHP 的查询构造器更舒服而且迁移、事件、队列这些组件足够成熟。两个项目不直接互相调用只共享 MySQL 数据库和 Redis 服务。小程序的请求一律打到 ThinkPHP 接口上管理员的请求一律打到 Laravel 后台里。这样做带来的直接好处是接口层可以随时调整返回格式而不影响后台展示后台的报表查询再复杂也不会拖慢小程序端接口。1.3 为什么不换成Java或纯Node我也见过有人用 Spring Boot 做这套系统能跑但对一个开在社区里的宠物店来说运维成本偏高。PHP 的部署模型非常简单一套 Nginx 加 PHP-FPM 就能搞定两个应用。Node 做小程序 API 也没问题但管理后台需要大量 ORM 关联和成熟后台框架支撑Laravel 在这方面积累更厚。选框架不是越复杂越好而是看团队能不能在两周内把它跑起来并持续维护。2. 数据表设计普通商品走SKU服务项目走排班表2.1 核心表总览哪些表该拆哪些表该合我最初犯过一个错误想把服务预约直接复用商品表给“洗澡”一个库存数字。后来发现完全走不通因为商品买卖是一次性交易服务预约要对应技师、宠物档案和具体时段。因此数据库被拆成了两大块。模块表名核心字段作用会员memberopenid, unionid, nickname, avatar, phone用户统一身份宠物档案pet_infomember_id, name, breed, weight, vaccine_date服务预约前置数据商城goods_categoryname, sort商品分类商城goodscategory_id, title, cover, status商品主体商城goods_skugoods_id, spec_name, stock, price规格库存商城cartmember_id, goods_id, sku_id, num购物车商城orderorder_no, member_id, status, total_fee商城订单商城order_itemorder_id, goods_id, sku_id, num, price订单明细服务service_itemname, duration, price, is_deposit服务项目服务service_technicianshop_id, name, avatar, skill_ids技师信息服务service_scheduleservice_id, technician_id, work_date, start_time, end_time, status可预约时段服务appointment_orderorder_no, member_id, pet_id, schedule_id, status预约订单商品和 SKU 拆分是必须的同一款猫粮可能有 2kg 和 10kg 两种规格价格和库存都不同同一款猫包可能有灰色和蓝色。把公共属性放在 goods 表把价格库存放在 goods_sku 表后面做购物车和订单明细会省很多事。2.2 服务排班表的设计细节服务排班是这套系统里最容易想简单的地方。我采用的方案是“预生成时段”当一个服务项目和技师配置好后后台按日期批量生成服务时段放入service_schedule表。例如美容师小王每天上午 10:00-10:50、11:00-11:50 各一个洗护时段系统就生成对应两条记录。每条时段记录都有status字段0 表示可预约1 表示已被锁定2 表示已完成3 表示已过期。这样查询某个日期还有哪些时段可预约就变成了简单的WHERE work_date2025-06-01 AND status0不需要在代码里做复杂的时段碰撞计算。付出的代价是数据量稍大但以宠物店的规模一天几十个时段完全可接受。2.3 订单状态机与预约状态机分开商城的order表状态机是0 待支付1 已支付待发货2 已发货3 已完成4 已取消。服务预约的appointment_order状态机则是0 待支付1 待服务2 进行中3 已完成4 已取消5 爽约。为什么不在同一张表里做因为商城的发货流程和服务履约流程完全不同。商城订单发了货就等用户确认服务预约则必须经历“到店签到 → 服务进行中 → 完成核销”的过程而且预约还有爽约概念普通商品订单没有。状态机分开后后台的筛选列表可以各查各的不会互相干扰。3. 小程序登录、手机号授权和宠物档案的三步打通3.1 wx.login到自建Token的登录链路小程序端登录基本都绕不开微信的wx.login。前端把code传给后端后端用code2session换取openid再用 openid 去member表里找用户找到就返回 token找不到就自动注册。?php namespace app\api\controller; use think\facade\Cache; use think\facade\Db; class Auth { public function wxLogin() { $code input(post.code); $appid config(wechat.appid); $secret config(wechat.secret); $url https://api.weixin.qq.com/sns/jscode2session?appid{$appid}secret{$secret}js_code{$code}grant_typeauthorization_code; $result json_decode(file_get_contents($url), true); if (empty($result[openid])) { return json([code 1, msg 微信登录失败]); } $member Db::name(member)-where(openid, $result[openid])-find(); if (!$member) { $memberId Db::name(member)-insertGetId([ openid $result[openid], unionid $result[unionid] ?? , nickname 微信用户 . mt_rand(1000, 9999), create_time time(), ]); } else { $memberId $member[id]; } $token md5(uniqid(pet, true) . $result[openid]); Cache::set(member_token_ . $token, $memberId, 7 * 86400); return json([code 0, data [token $token]]); } }token 我放在 Redis 里并设置 7 天过期。后续每个接口的中间件从Authorization头里取出 token再查 Redis 拿到 member_id。这样实现简单也方便后台强制踢人下线——删掉 Redis 里对应的 key 即可。3.2 手机号快速验证组件的接入微信官方现在不推荐手动输入手机号而是用button open-typegetPhoneNumber拉起授权弹窗。前端把回调里的code传给后端后端用phonenumber.getPhoneNumber接口解密出真实手机号。有个细节必须注意这个接口返回的phone_info.phoneNumber并不是每次都完整部分场景需要通过code换取完整信息。所以我设计了member.phone_is_verified字段专门记录手机号是否已验证未验证的用户在下单时会先弹窗引导补齐。3.3 宠物档案和“先建档再预约”的流程服务预约如果不关联宠物档案后台看到的就是一条“用户预约了洗澡”但不知道这只猫是什么品种、有没有打过疫苗、是否有攻击性。因此用户第一次点“预约服务”时如果pet_info表里没有数据小程序会强制跳转到建档页面填写宠物昵称、品种、体重、生日、疫苗日期。我当时特意加了一个字段is_neutralized是否绝育因为部分美容项目对未绝育宠物有多收费规则。这些字段看起来不起眼但真正排班时技师能提前了解宠物情况减少现场扯皮。4. 商城库存扣减Redis预扣与数据库事务双保险4.1 购物车、下单和Redis预扣的完整链路商城下单和普通电商类似但库存扣减要做两层保护。我采用的方案是 Redis 预扣 数据库事务兜底。商品上架时后台会把库存同步到 Redis比如stock:1001存了 100。用户点击下单时接口先对 Redis 执行decr扣减结果小于 0 就直接返回库存不足并把 Redis 加回去如果扣减成功再进入数据库事务用SELECT ... FOR UPDATE锁住 SKU 行确认数据库里的真实库存足够后更新库存并生成订单。public function createOrder() { $goodsId input(post.goods_id); $skuId input(post.sku_id); $num input(post.num); $memberId $this-memberId; $sku Db::name(goods_sku)-where(id, $skuId)-find(); if (! $sku || $sku[status] ! 1) { return json([code 1, msg 商品已下架]); } $redis new \Redis(); $redis-connect(127.0.0.1, 6379); $key stock: . $skuId; $left $redis-decr($key); if ($left 0) { $redis-incr($key); return json([code 1, msg 库存不足]); } Db::startTrans(); try { $lockSku Db::name(goods_sku)-where(id, $skuId)-lock(true)-find(); if ($lockSku[stock] $num) { Db::rollback(); $redis-incr($key); return json([code 1, msg 库存不足]); } Db::name(goods_sku)-where(id, $skuId)-dec(stock, $num)-update(); $orderNo date(YmdHis) . mt_rand(1000, 9999); $orderId Db::name(order)-insertGetId([ order_no $orderNo, member_id $memberId, goods_id $goodsId, sku_id $skuId, num $num, total_fee bcmul($sku[price], $num, 2), status 0, create_time time(), ]); Db::name(order_item)-insert([ order_id $orderId, goods_id $goodsId, sku_id $skuId, num $num, price $sku[price], ]); Db::commit(); return json([code 0, data [order_id $orderId]]); } catch (\Exception $e) { Db::rollback(); $redis-incr($key); return json([code 1, msg 创建订单失败]); } }为什么不只依赖 Redis因为 Redis 的高可用和持久化配置在小项目里往往不完善万一 Redis 重启导致计数不准数据库中的库存才是唯一真相。所以我把 Redis 当作快速拦截的第一道门数据库事务兜底保证最终一致性。4.2 微信支付V3回调的验签与幂等用户在小程序端调用wx.requestPayment之前后端要先调用微信支付 V3 的统一下单接口拿到prepay_id。回调通知进来后最重要的一步是验签。微信支付 V3 使用平台证书验签我用的是官方 SDK没有自己手写验签逻辑。public function notify() { $input file_get_contents(php://input); $data json_decode($input, true); // 解密 resource 节点得到 out_trade_no、transaction_id 等 $decrypted $this-wechatPay-decrypt($data[resource]); $outTradeNo $decrypted[out_trade_no]; $order Db::name(order)-where(order_no, $outTradeNo)-find(); // 幂等处理只有待支付状态才流转到已支付 if ($order $order[status] 0) { Db::name(order)-where(id, $order[id])-update([ status 1, transaction_id $decrypted[transaction_id], paid_at time(), ]); } return json([code SUCCESS, message 成功]); }幂等这句话我写了很多遍了但还是要强调回调接口可能因为网络重试被调用多次如果不对每次回调都判断当前状态就可能出现订单状态被覆盖或重复发货的问题。4.3 回调延迟和漏单的兜底方案微信支付回调偶尔会延迟几分钟甚至极少数情况下会丢失。为此我写了一个定时任务每小时扫描一次待支付但超过 15 分钟的订单主动调用微信支付“查单接口”。如果查到的支付状态是成功就直接把订单改成已支付。这个兜底方案在实际运行中确实救回了好几笔订单否则用户明明扣了款后台却一直显示未支付投诉率会很高。5. 服务预约并发唯一的“抢时段”和核销闭环5.1 时段预生成 唯一索引兜底两个人同时抢同一个洗护时段不能两个都约成功。如果能预生成时段最稳的做法是给服务预约表加一个唯一索引。这里共享的是schedule_id即一个时间段只有一条可预约记录。ALTER TABLE appointment_order ADD UNIQUE KEY uk_schedule_appointment (schedule_id, status);不过这个唯一索引有个前提status 字段里只有“待支付/待服务”才占用时段如果用户取消预约status 改成 4那么索引应该允许相同 schedule_id 再次出现。但 MySQL 里唯一索引无法直接只约束 status IN (0,1)。我的实际做法是把预约状态设计成独立字段occupy_status只有占用时这个字段的值是 0让唯一索引只包含schedule_id和occupy_status取消后把占用释放也就是把occupy_status改成 1。5.2 用Redis锁做快速失败数据库唯一索引能兜底但同一秒内大量并发请求都打在数据库上会产生很多无用的写冲突和等待。所以我在业务层又加了一层 Redis 锁。$redis new \Redis(); $redis-connect(127.0.0.1, 6379); $lockKey appointment_lock: . $scheduleId; $locked $redis-set($lockKey, 1, [NX, EX 5]); if (! $locked) { return json([code 1, msg 该时段刚刚被约走请选择其他时段]); } try { // 更新 schedule 表状态为已锁定 // 创建 appointment_order 记录 // 提交事务 $redis-del($lockKey); } catch (\Exception $e) { $redis-del($lockKey); throw $e; }Redis 锁的过期时间设成了 5 秒正常下单业务不可能超过这个时间。如果业务中有大量慢 SQL那应该优化 SQL而不是把锁过期时间调大否则锁超时后两个请求同时进入就只能靠数据库唯一索引兜底了。5.3 取消、爽约和到店核销预约用户可能在服务前几小时取消。我在后台设置了一个规则距离服务开始超过 2 小时可免费取消取消后立刻释放时段不足 2 小时取消扣除定金。定金主要起挡爽约的作用不然节假日时段经常被无故占用。到店核销我是这样实现的用户到店后在小程序订单详情页出示动态二维码后台管理员用手机或收银台扫码枪扫一下appointment_order状态从“待服务”变成“进行中”服务完成后点“完成服务”状态变成“已完成”。这一步看起来简单但它是整个服务闭环里体验差距最大的功能。没有核销功能用户和服务人员只能靠口头对暗号出了问题后台根本追溯不到。6. ThinkPHP写接口、Laravel做后台两个框架怎么协作6.1 两套框架的协作约定项目源码的目录结构我建议这样组织清晰且互不干扰pet-shop/ miniapp/ # 微信小程序原生前端 api-thinkphp/ # ThinkPHP 6 接口项目 app/ api/controller/ # AuthController、GoodsController、OrderController api/model/ # Member、Goods、AppointmentOrder common/ # 返回格式、异常处理 config/ wechat.php # 小程序 appid、secret 等配置 admin-laravel/ # Laravel 8 后台项目 app/ Http/Controllers/Admin/ Models/ database/migrations/ routes/admin.php两个项目共享 MySQL 数据库因此约定就非常重要。我用的规则是所有金额字段统一用“分”存储避免浮点误差所有时间字段统一用 int 类型存 unix 时间戳所有表名、字段名统一小写下划线风格。这样 ThinkPHP 查出来的数据给 Laravel 做统计时不需要任何转换。6.2 排班生成命令和报表统计后台的排班不能用页面表单一条条录入效率太低。我在 Laravel 里写了一个 artisan 命令一条命令就能生成未来 7 天的排班php artisan schedule:generate --date2025-06-01命令逻辑是读取启用的service_item列表和技师表根据每个技师的可工作时间段生成service_schedule记录。这样后台管理员只需要在页面上调整个别日期或时段不用从头建立排班。报表统计我用 Laravel 的 Eloquent 关联会非常顺手。例如统计每个服务项目的预约完成率$result AppointmentOrder::selectRaw(service_item_id, COUNT(*) as total) -selectRaw(SUM(CASE WHEN status 3 THEN 1 ELSE 0 END) as finished) -groupBy(service_item_id) -get();然后把这个结果渲染到后台首页看板。同样的查询如果在 ThinkPHP 里写也能写但涉及模型关联和聚合分组时Laravel 的语法更直观一些。6.3 一套框架到底的中肯建议尽管我在这套系统里同时用了 ThinkPHP 和 Laravel也顺利跑起来了但如果你不是专门想对比学习两个框架我的建议是统一用其中一个。小型项目里折腾两套框架意味着两套 Composer 依赖、两套部署配置、两套日志体系后续维护成本接近翻倍。标题里的“Thinkphp-Laravel”更像一个学习路径先用 ThinkPHP 快速理解路由和控制器再通过 Laravel 理解服务容器、ORM 和队列而不是鼓励所有生产项目都这样拆分。7. 部署上线翻过的几个坑域名、证书、审核与对账7.1 Nginx双域名双入口配置我线上用了两个二级域名api.pet.example.com指向 ThinkPHP 项目的public目录admin.pet.example.com指向 Laravel 项目的public目录。两个入口文件都叫index.php但属于不同项目所以 Nginx 的root配置必须分别指定不能混用。server { listen 443 ssl; server_name api.pet.example.com; root /www/pet-shop/api-thinkphp/public; index index.php; location / { try_files $uri $uri/ /index.php?$query_string; } } server { listen 443 ssl; server_name admin.pet.example.com; root /www/pet-shop/admin-laravel/public; index index.php; location / { try_files $uri $uri/ /index.php?$query_string; } }这里最容易踩的坑是把 Laravel 的伪静态规则直接复制到 ThinkPHP 项目里导致所有接口都返回 404。两个框架的路由规则不完全一致调通之前先在本地用php think run测试接口再上 Nginx。7.2 微信支付证书和合法域名的坑微信支付商户证书是极其敏感的文件绝不能提交到 Git 仓库。我见过有人把apiclient_key.pem提交到公开仓库后果就是别人能拿着证书去重放支付回调。正确做法是证书文件放到服务器非 Web 目录通过.env环境变量把路径注入应用。小程序真机访问的接口域名必须配置在小程序后台的“request 合法域名”里而且必须是 HTTPS。开发工具里可以勾选“不校验合法域名”但真机必挂这个问题几乎每周都有人问。7.3 小程序审核需要准备什么小程序审核被拒最多次数的原因不是代码而是业务闭环不完整。审核人员会拿一个测试账号进去点能不能注册登录、能不能下单、下单之后后台有没有响应、服务预约能不能选真实时段。如果你只放了一个静态商品列表审核大概率会打回。我后来专门做了“审核演示模式”后台预置一只测试宠物档案、两个服务项目、三个技师还有几个不会过期的时段。审核人员按引导走完“登录 → 选服务 → 选宠物 → 预约 → 后台看到订单”的流程后很快就通过了。提交审核前务必把所有未完成的按钮隐藏不要留“敬请期待”之类的占位入口。7.4 我后来补的“订单对账”任务上线第二周我发现一笔订单支付成功了但小程序里一直显示未支付原因是最早漏了主动查单兜底。后来我在 Laravel 后台加了一个每日对账定时任务每天凌晨 3 点调微信支付“下载账单”接口把账单里的out_trade_no和本地订单表比对发现本地状态是未支付但账单显示已经扣款就自动补单并给用户发模板消息提醒。这个对账脚本上线后我再也没被店家追着问“钱扣了怎么没显示订单”。如果你也打算用这套架构我的建议很简单先把服务预约模块跑通再去做商城 SKU。宠物店真正的黏性来自服务——洗澡、美容、寄养都是高频需求商品销售反而是低频的。排班表设计清楚了预约状态机理清楚了这个项目就成功了一大半。商城逻辑到处都有标准方案但一套好的服务预约系统才是这个源码项目里最值钱的部分。
返回列表