简介:这份源码是一套基于ThinkPHP与UniApp双框架搭建的租赁商城小程序,面向需要快速搭建租赁平台的开发者、企业及创业者,尤其适用于家居、户外装备、服装、电子产品等常见租借场景的快速落地。系统将用户端与员工端分开:用户浏览商品、下单并支付租金和押金,员工端负责出库、归还与库存跟踪,整体能支撑线上租赁业务的日常运营闭环。压缩包共2000个文件,以1285个js、67个vue等前端代码为主,辅以超文本标记语言、层叠样式表、JSON、Markdown说明及FastAdmin后台PHP模块,另有SQL文件可初始化数据,整体压缩后仅22.91MB,十分轻量。目前已有372人学习。借助清晰的前后端目录和内置的多角色管理逻辑,开发者既可直接部署使用,也能围绕业务快速做二次开发,适合作为租赁类小程序项目的起步模板。
1. 租赁小程序和普通商城的差别,比想象中大
一套"全新租赁小程序系统源码"放到面前,很多人的第一反应是:不就是商城把"购买"改成"租用"吗?真动手改订单流程就会发现,租赁的本质是把一次性买卖拆成了"借出—使用—归还—结算"四个阶段,每一段都牵扯到押金、计费周期和状态流转。普通商城处理完支付就结束了,租赁系统在支付之后才进入主战场。这套基于ThinkPHP+UniApp的租赁商城小程序,适合两类人:一类是手里有设备、服装、工具需要按天出租的线下商家,想用一套源码快速搭起自己的线上租赁入口;另一类是接外包的技术团队,拿到源码后需要快速定位租赁相关的核心表结构和接口来做二次开发。看懂这套系统的关键在于先理解租赁订单的独特生命周期,再去看ThinkPHP后端和UniApp前端各自写了什么,而不是急着改界面。
2. ThinkPHP后端:租赁订单的库表设计与下单接口实现
租赁商城和后端框架之间的关系,说到底是订单状态和计费逻辑怎么落库的问题。ThinkPHP在这套方案里承担的是接口服务端职责,处理小程序端提交的租赁请求、管理库存、生成订单、对接微信支付,以及后续的归还和退款。
2.1 先分清"商品基础表"和"租赁专属表"
常见做法是保留商城通用的商品表、分类表和规格表,再单独扩展租赁业务需要的表。用ThinkPHP的模型关联来描述,一般会看到这种关系:
// 商品模型 class Goods extends Model { // 关联租赁方案表:一个商品对应多个租期方案 public function rentPlans() { return $this->hasMany(RentPlan::class, 'goods_id'); } } // 租赁方案表模型 class RentPlan extends Model { // 租金和押金在方案里定义 protected $name = 'rent_plan'; }相对应的数据库表结构,租赁方案表是理解这套系统的钥匙:
CREATE TABLE `rent_plan` ( `id` int(11) NOT NULL AUTO_INCREMENT, `goods_id` int(11) NOT NULL COMMENT '商品ID', `plan_name` varchar(50) NOT NULL COMMENT '方案名称,如:日租/周租/月租', `price` decimal(10,2) NOT NULL COMMENT '该方案单价', `price_unit` tinyint(1) NOT NULL DEFAULT '1' COMMENT '计费单位:1按天 2按小时 3按次', `deposit` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '押金金额', `min_days` int(11) NOT NULL DEFAULT '1' COMMENT '最短租期(天)', `sort` int(11) NOT NULL DEFAULT '0' COMMENT '排序权重', `status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '0下架 1上架', PRIMARY KEY (`id`), KEY `idx_goods_id` (`goods_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租赁方案表';租赁方案表单独拆出来的原因很直接:同一件商品可能同时支持日租、周租、月租,价格不同、押金可能也不同。如果把价格硬编码在商品表里,后续调整任何一个方案都要改主表,业务上会很别扭。押金放在方案表里而不是订单表里,是为了下单时能直接取出快照。
还需要一张租赁订单表,它与普通订单表的区别在于多了几个关键字段:rent_start_time、rent_end_time、deposit(押金快照)、order_status(状态机比普通商城复杂)。ThinkPHP的关联删除(with)在这种结构里很实用,商品下架或删除时可以直接联动清理方案数据:
// 删除商品时联动删除租赁方案 $goods = Goods::find($id); $goods->together(['rentPlans'])->delete();这里的together是ThinkPHP的关联删除写法,避免了手动先删子表再删主表的冗余代码。需要注意,物理删除要慎重,租赁订单表里的goods_id和plan_id都是历史快照的一部分,商品删了订单还要能查到名称和单价,所以订单表里通常会冗余一份商品名称和方案名称字段。
2.2 下单接口:事务、库存和状态机的配合
下单是租赁系统最核心的接口。普通商城下单只是扣库存,租赁下单还要校验租期方案的有效性、计算押金和租金总额、锁定商品在租赁期内的库存。
ThinkPHP里用事务包裹整个下单流程是标准做法:
public function createOrder() { $goodsId = input('post.goods_id'); $planId = input('post.plan_id'); $startTime = strtotime(input('post.start_time')); // 用户选择的起租日期 $plan = RentPlan::find($planId); if (!$plan || $plan->status != 1) { return json(['code' => 1, 'msg' => '租赁方案不可用']); } // 计算租期:按天计费时结束时间是开始时间 + 天数 $days = input('post.days/d', 1, 'intval'); if ($days < $plan->min_days) { return json(['code' => 1, 'msg' => '未达到最短租期']); } $endTime = $startTime + $days * 86400; $rentAmount = $plan->price * $days; // 租金 $deposit = $plan->deposit; // 押金 Db::startTrans(); try { // 扣减租赁库存:库存充足才继续 $result = Db::name('goods') ->where('id', $goodsId) ->where('rent_stock', '>', 0) ->setDec('rent_stock'); if (!$result) { throw new \Exception('租赁库存不足'); } // 生成订单,状态为待支付 $orderId = Db::name('rent_order')->insertGetId([ 'order_sn' => $this->generateOrderSn(), 'goods_id' => $goodsId, 'plan_id' => $planId, 'start_time' => $startTime, 'end_time' => $endTime, 'rent_amount' => $rentAmount, 'deposit' => $deposit, 'total_amount' => $rentAmount + $deposit, 'order_status' => 0, // 0待支付 1租赁中 2待归还 3已完成 4已取消 'create_time' => time() ]); Db::commit(); return json(['code' => 0, 'data' => ['order_id' => $orderId]]); } catch (\Exception $e) { Db::rollback(); return json(['code' => 1, 'msg' => $e->getMessage()]); } }这段代码里的setDec是ThinkPHP的原子递减操作,配合where('rent_stock', '>', 0)条件可以防止超卖。需要特别注意的是,rent_stock和普通库存stock是两回事。同一件商品可以既支持买断也支持出租,两种库存需要各自维护。很多二手改造的项目在这里翻车,只扣了stock,导致租赁单把货出掉了,销售单那里库存却没变。
2.3 支付回调:把"已支付"变成"租赁中"
微信支付的回调处理是ThinkPHP后端绕不开的环节。用户在小程序端完成支付后,微信服务器会异步通知你的接口,这时候要校验订单金额、更新订单状态、把订单从"待支付"推到"租赁中"。
public function notify() { $xml = file_get_contents('php://input'); // 解析微信回调XML,验签 $result = $this->parseAndCheck($xml); if ($result['return_code'] == 'SUCCESS') { $orderSn = $result['out_trade_no']; $order = Db::name('rent_order')->where('order_sn', $orderSn)->find(); if ($order && $order['order_status'] == 0) { Db::name('rent_order') ->where('id', $order['id']) ->update(['order_status' => 1, 'pay_time' => time()]); // 记录支付流水 Db::name('pay_log')->insert([ 'order_id' => $order['id'], 'transaction_id' => $result['transaction_id'], 'amount' => $result['total_fee'] / 100, 'create_time' => time() ]); } // 告诉微信别再重试了 echo '<xml><return_code><![CDATA[SUCCESS]]></return_code></xml>'; } }回调接口的逻辑核心是幂等:同一笔订单被微信重复通知时,不会重复更新状态。上面的写法用order_status == 0做了状态前置判断,这是最简单的防重复处理。支付金额校验在真实项目里一定要做,对比$result['total_fee']和订单total_amount转成分后的值,不一致就拒绝更新状态并记日志。
3. UniApp前端:小程序端从登录到支付的关键路径
UniApp在这套方案里的价值是跨端复用——一套代码编译成微信小程序、支付宝小程序和H5。实际开发中大部分流量来自微信小程序,所以前端的实现要按微信小程序的运行规则来约束自己。
3.1 manifest配置决定打包的上限
打开manifest.json,以下几个配置直接影响后续打包和真机调试:
| 配置项 | 位置 | 说明 |
|---|---|---|
| 微信小程序AppID | mp-weixin节点 | 在小程序后台申请,测试号也能跑通基本流程 |
| Vue版本 | vueVersion | 小程序端推荐Vue2,插件兼容性更好 |
| 定位权限 | app-plus节点 | 做"附近门店"功能时需要勾选 |
| 小程序隐私协议 | mp-weixin节点 | 2023年后微信强制要求配置隐私保护指引 |
| 打包渠道 | 发行菜单 | HBuilderX里选择"小程序-微信"输出微信开发者工具可识别的目录 |
常见的坑是manifest.json里改了AppID后,本地开发环境不生效。需要重新运行到小程序模拟器,且微信开发者工具里要手动清缓存再编译一次。如果遇到"appid不合法"的报错,先检查manifest里保存后是否真的触发了重新编译,再检查微信公众平台的AppSecret是否和代码里一致。
3.2 登录流程和小程序请求封装
小程序登录的典型链路是:uni.login拿到code,传给后端换取openid和自定义登录态。代码写起来不长,但请求封装决定了后续所有接口的调用体验。
// api/request.js const BASE_URL = 'https://api.example.com/index.php'; function request(path, data = {}, method = 'POST') { return new Promise((resolve, reject) => { uni.request({ url: BASE_URL + path, method: method, data: data, header: { 'token': uni.getStorageSync('token') || '' }, success: (res) => { if (res.data.code === 0) { resolve(res.data.data); } else if (res.data.code === 401) { // token过期,重新登录 uni.removeStorageSync('token'); loginAndThen(() => request(path, data, method)); } else { uni.showToast({ title: res.data.msg, icon: 'none' }); reject(res.data); } }, fail: (err) => reject(err) }); }); } function loginAndThen(callback) { uni.login({ provider: 'weixin', success: (loginRes) => { uni.request({ url: BASE_URL + '/user/login', method: 'POST', data: { code: loginRes.code }, success: (res) => { uni.setStorageSync('token', res.data.data.token); callback && callback(); } }); } }); } export default { request, loginAndThen };几个容易被忽视的点:uni.request的success回调里已经走到了HTTP层,但业务状态码还要靠res.data.code再判断一次——很多新手在这里漏了一层,导致错误码没有拦截。uni.login拿到的code有效期为5分钟,且只能用一次,所以不能提前换好存起来,必须在用户操作的那一段时间内拿code去换。
3.3 租赁商品详情页与下单页的联动
租赁和普通商城在界面上最大的差异是租期选择。用户选了"日租3天",页面要实时算出起租日期、预计归还日期、租金小计和押金,最后给出应付总额。这块计算逻辑放前端还是后端,取决于产品要求:前端做实时展示,后端在提交订单时做最终校验。
// pages/detail/detail.vue export default { data() { return { goods: {}, currentPlan: {}, days: 1, startDate: '', endDate: '' }; }, methods: { // 选择租期方案后自动计算金额 selectPlan(plan) { this.currentPlan = plan; this.days = plan.min_days || 1; this.calcDate(); this.calcFee(); }, calcDate() { // 以当天为起租日 const start = new Date(); const end = new Date(start.getTime() + this.days * 86400000); this.startDate = this.formatDate(start); this.endDate = this.formatDate(end); }, calcFee() { this.rentAmount = (this.currentPlan.price * this.days).toFixed(2); this.totalAmount = (parseFloat(this.rentAmount) + parseFloat(this.currentPlan.deposit)).toFixed(2); }, formatDate(date) { const y = date.getFullYear(); const m = String(date.getMonth() + 1).padStart(2, '0'); const d = String(date.getDate()).padStart(2, '0'); return `${y}-${m}-${d}`; } } }前端的日期计算用了毫秒时间戳相减再除以一天的毫秒数,这里有个跨时区隐患:如果用户在晚上22点下单,new Date()是本地时间,传给后端后如果后端用strtotime处理的是东八区的时间,算出来的日期边界可能偏一天。稳妥做法是前端直接传时间戳,或者传YYYY-MM-DD格式的日期字符串,由后端来决定"零点"在哪。
3.4 发起支付与动态标题处理
UniApp通过uni.requestPayment拉起微信支付。需要注意,小程序端的支付参数paySign必须由后端用微信支付密钥和统一下单返回值计算,前端不能用任何方式自己拼签名。
submitOrder() { // 先调用后端下单接口 request('/order/create', { goods_id: this.goods.id, plan_id: this.currentPlan.id, days: this.days, start_time: this.startDate }).then((orderData) => { // 再用订单号调后端支付接口,拿到支付参数 return request('/pay/getPayParams', { order_id: orderData.order_id }); }).then((payParams) => { uni.requestPayment({ provider: 'wxpay', timeStamp: payParams.timeStamp, nonceStr: payParams.nonceStr, package: payParams.package, signType: 'MD5', paySign: payParams.paySign, success: () => { uni.redirectTo({ url: '/pages/order/detail?id=' + orderData.order_id + '&status=paid' }); }, fail: (err) => { uni.showToast({ title: '支付未完成', icon: 'none' }); } }); }); }支付成功跳转后,订单详情页的标题会随着订单状态变化。比如待支付显示"确认订单",租赁中显示"租赁详情"。用uni.setNavigationBarTitle动态设置就行,这是小程序端的标准API,UniApp直接透传。放onShow里执行比onLoad更稳,因为支付后从支付面板返回页面时走的是onShow生命周期。
onShow() { if (this.order) { const titles = { 0: '确认订单', 1: '租赁详情', 2: '归还确认', 3: '已完成' }; uni.setNavigationBarTitle({ title: titles[this.order.order_status] || '订单详情' }); } }同时,如果要支持用户转发租赁商品给好友,需要在页面里开启分享。uni.showShareMenu配合onShareAppMessage是常见做法。分享路径要注意带上goods_id参数,否则好友点进来看到的是首页而不是对应的商品。
4. 押金、逾期与计费:租赁业务三个绕不开的结算场景
如果说下单和支付是租赁小程序的门面,那计费、押金退还和逾期处理就是真正决定运营成本的后台逻辑。这三块做不好,线下商家用两个月就会退回Excel表格管理。
4.1 计费引擎:按天、按小时与跨天边界
租金计算的核心函数需要处理三种情况:按天、按小时、按次。其中按天最容易出错的是跨天逻辑——用户租3天,从下午两点开始,到第三天下午两点结束,中间跨越了三个自然日,但实际使用时长不足72小时。纯按毫秒差除以86400000会算出2.几的结果,向下取整就少算一天。
// 计费函数:根据方案和起止时间计算租金 function calcRent($plan, $startTime, $endTime) { $diffSeconds = $endTime - $startTime; if ($plan['price_unit'] == 2) { // 按小时计费 $hours = ceil($diffSeconds / 3600); return $plan['price'] * $hours; } if ($plan['price_unit'] == 3) { // 按次计费,不考虑时长 return $plan['price']; } // 按天计费:跨天按自然日数算,不足一天按一天算 $startDay = date('Y-m-d', $startTime); $endDay = date('Y-m-d', $endTime); $days = ceil(($endTime - strtotime($startDay)) / 3600 / 24); // 如果结束日期晚于开始日期,直接算自然日差 $naturalDays = (strtotime($endDay) - strtotime($startDay)) / 86400; $days = $naturalDays > 0 ? $naturalDays : 1; return $plan['price'] * $days; }去重和取整的顺序会影响最终金额。上面这段代码先算自然日差,再把"当天借当天还"兜底为1天。实际项目里,结束时间一旦超过系统设定的最迟归还时间(比如晚上20点),很多租赁商会对当天加收半天或一天的费用,这块需要在后台配置参数而不是写死在代码里。
4.2 押金退还:原路退回与破损扣款
押金退还不要做成手动转账,微信支付原路退回是最省事的方案。ThinkPHP后端调用退款接口时,需要用到商户号和API密钥,请求参数和返回校验都要完整处理。
public function refundDeposit($orderId) { $order = Db::name('rent_order')->where('id', $orderId)->find(); if (!$order || $order['order_status'] != 3) { throw new \Exception('订单状态不允许退款'); } // 实际扣款可能因破损小于押金,这里取出审核后的应退金额 $refundAmount = $order['deposit'] - $order['deduct_amount']; $params = [ 'appid' => 'wx1234567890', 'mch_id' => '商户号', 'out_trade_no' => $order['order_sn'], 'out_refund_no' => $order['order_sn'] . 'R' . time(), 'total_fee' => intval($order['total_amount'] * 100), 'refund_fee' => intval($refundAmount * 100), 'nonce_str' => md5(uniqid()), ]; // 按照微信支付文档生成签名,这里省略具体加密实现 $params['sign'] = $this->makeSign($params); // 以POST方式请求微信退款接口,拿到结果后更新订单退款状态 $response = $this->postXml('https://api.mch.weixin.qq.com/secapi/pay/refund', $params); if ($response['return_code'] == 'SUCCESS') { Db::name('rent_order')->where('id', $orderId)->update([ 'refund_status' => 2, // 已退款 'refund_time' => time() ]); } }total_fee和refund_fee的单位是分,所以要把数据库里的元转成整数分,这是退款金额对不上的头号原因。另外refund_fee不能大于total_fee减去已退款金额,否则微信接口直接报REFUND_FEE_INVALID。破损扣款的处理逻辑是:用户归还后,运营人员在后台标记破损金额,系统把"押金-破损扣款"作为refund_fee提交,而不是先全额退再让用户补钱。
4.3 逾期处理:定时任务与订单状态自动流转
逾期是租赁业务不可避免的运营场景。常见方案有两条路:一是到期前给用户推送提醒,到期后自动按天续费;二是到期后冻结订单并停止计费,等用户主动归还。前者对商家友好,后者对用户友好。这套系统的定时任务通常在ThinkPHP命令行里实现。
// application/command/CheckOverdue.php class CheckOverdue extends Command { protected function configure() { $this->setName('check_overdue'); } protected function execute(Input $input, Output $output) { // 找出所有已过归还时间但状态仍是租赁中的订单 $overdueOrders = Db::name('rent_order') ->where('order_status', 1) ->where('end_time', '<', time()) ->select(); foreach ($overdueOrders as $order) { Db::startTrans(); try { // 延长租期一天,并生成一条逾期费用记录 Db::name('rent_order')->where('id', $order['id'])->update([ 'end_time' => $order['end_time'] + 86400, 'overdue_days' => $order['overdue_days'] + 1, 'overdue_fee' => $order['overdue_fee'] + $order['rent_amount'] ]); Db::commit(); } catch (\Exception $e) { Db::rollback(); } } } }然后在服务器上配置crontab每小时跑一次:
*/30 * * * * /usr/bin/php /www/wwwroot/lease/index.php command check_overdue >> /www/wwwroot/lease/runtime/overdue.log 2>&1这里的schedule命令格式是ThinkPHP 5的写法(php index.php command 命令名),项目如果用的是ThinkPHP 6或8,命令注册和调用方式略有不同,需要看config/console.php里注册的命令名。无论哪个版本,都要保证同一个定时任务在上一轮没跑完时不会并发启动第二轮。加个简单的文件锁或者Redis锁,否则订单量上来后会把同一个订单处理两次。
5. 部署、打包和二次开发时的四个边界
这套源码拿到手之后,从"能看懂"到"能上线"之间还有一段路。最容易卡住人的不是业务代码,而是环境兼容、打包配置和数据库索引这几个外围问题。
5.1 ThinkPHP版本与PHP运行环境的匹配
如果这套源码基于ThinkPHP 3.2开发,直接跑PHP 8大概率会有兼容问题。3.2版本里的mysql_*系列函数、each()、create_function等写法在PHP 7.4里已经废弃,在PHP 8里直接移除。网传的很多补丁方案虽然能让3.2在PHP 8下启动,但动态运行的框架底层兼容性依然没保障。
| PHP版本 | ThinkPHP 3.2 | ThinkPHP 5.x | ThinkPHP 6/8 |
|---|---|---|---|
| PHP 5.6 | 推荐 | 兼容 | 不支持 |
| PHP 7.4 | 基本兼容 | 推荐 | 兼容 |
| PHP 8.0 | 需打补丁 | 需调整 | 推荐 |
跑这套源码前,先确认后端是哪个大版本。常见做法是本地用PHP 7.4的集成环境跑起来,再根据实际报错决定是否升级代码。如果后端是ThinkPHP 6/8,那用的是composer管理依赖,部署时记得先执行composer install拉取vendor目录。
5.2 UniApp打包参数与上架
UniApp在HBuilderX里打包微信小程序时,需要填写小程序AppID,然后在"发行"菜单中选择"小程序-微信",生成的文件在unpackage/dist/dev/mp-weixin或build/mp-weixin目录,用微信开发者工具导入即可预览和上传。
安卓应用市场打包是另一套路径,需要在HBuilderX里配置DCloud的App云打包证书。包名格式反着写,比如com.lease.shop,一旦上架后不能再改。iOS打包必须用Mac上的Xcode或者DCloud的云打包,没有Mac环境就选云打包方案,但需要准备好Apple开发者账号的证书文件。
5.3 租赁订单表的索引与查询压力
订单量到了几万条之后,order_status和end_time的索引缺了就明显变慢。后台"租赁中订单列表"通常同时按状态和时间过滤,所以要建联合索引。
ALTER TABLE `rent_order` ADD INDEX `idx_status_endtime` (`order_status`, `end_time`); ALTER TABLE `rent_order` ADD INDEX `idx_user_status` (`user_id`, `order_status`);定时任务里按end_time < time()扫数据时,如果end_time没有索引,每次都要全表扫描。联合索引里的字段顺序要按查询条件来放,order_status放前面是因为等值查询优先于范围查询。
5.4 微信开发者工具的快捷验证方法
支付链路在开发阶段最重要的调试方式是微信开发者工具里的"模拟支付"。不需要真实扣款,工具会直接调用success回调。但这个模拟支付默认不校验支付签名,所以真机预览时一定要换真实环境测一次,否则容易出现"开发者工具里能支付、真机上支付失败"的情况。
调试接口时常用Chrome开发者工具配合vConsole观察前端请求参数。在UniApp项目里引入vconsole插件,或者在小程序开发者工具里勾选"不校验合法域名",都能减少排查链路的时间。正式上线前记得关闭"不校验合法域名"。最后检查一遍:rent_plan表的status字段是否默认1,rent_order表的refund_status是否默认0,这两个字段一个影响商品能不能租、一个影响后台能不能看到退款单,很容易被忽略。
本文还有配套的精品资源,点击获取