这几年租赁类的线上业务明显多了起来,从无人机、单反相机、演出服,到儿童玩具、工具箱、新能源汽车,线下租赁门店都在往小程序上搬。但租赁和普通电商完全是两回事:商品不是卖出去就结束,而是要持续跟踪“借出、在租、归还、退回”的状态;订单里要有起租止租日期,要算租金、押金、逾期费;资金流里还牵扯押金冻结与退还。传统商城系统改造成租赁系统,动不动就要动订单核心逻辑,改到最后往往四不像。我之前基于ThinkPHP和UniApp完整做过一套租赁小程序系统,把租赁商城这条业务链路从头到尾梳理了一遍,从后端数据结构到前端跨端适配,再到押金、计费、库存这些核心难点,都形成了一套可落地的方案。这篇就把整个项目的设计思路、核心实现和踩坑过程写出来,给准备做租赁小程序或者正在改造电商系统的朋友一个参考。
这套系统适合这几类人看:一是打算从零做租赁小程序、但不确定技术栈怎么选的产品和技术负责人;二是已经有了电商系统、想扩展租赁业务模块的开发者;三是接了租赁小程序外包项目、需要快速梳理业务模型和数据库设计的同学。内容以ThinkPHP 6/8和UniApp为基础,主要讲设计思路和核心代码逻辑,不依赖特定版本,你用自己的框架版本也能对准思路。
1. 项目定位与技术选型——为什么是ThinkPHP+UniApp
1.1 租赁商城和传统电商的本质差异
很多团队做租赁系统时犯的第一个错误,是拿现成的电商系统来改。改完之后发现,购物车、订单、售后这套电商核心流程根本撑不起租赁业务的需求。租赁业务的本质不是“商品所有权转移”,而是“使用权的阶段性出让”,这带来三个电商系统没有的强需求:
第一个是时间维度。电商订单结算完就结束了,租赁订单则必须贯穿借出、使用中、即将到期、已归还、已逾期、已结算这整个生命周期。每一个状态都和时间强相关,系统里必须有一个可靠的时间引擎来驱动状态流转。
第二个是押金和资金流。电商的钱是一次性的“货款”,租赁的钱是“押金+租金”的双层结构。押金要可冻结、可退还、可部分扣除(比如物品损坏、逾期扣款),这比单纯收款复杂得多。
第三个是库存和商品的可复用性。同一件租赁商品被归还后还能再次出租,这和电商卖出一件就少一件库存完全不同。租赁库存关心的是“某一时间区间内有没有空闲”,还会涉及同一个SKU多件库存的并发预订。
这三个差异决定了租赁系统必须单独设计数据模型,单独开发核心接口,不能靠电商插件蒙混过关。我最终选择的方案是ThinkPHP做后端API、UniApp做小程序前端,原因很简单:这两个技术栈都能很好覆盖租赁业务的高定制需求,而且开发效率高、生态成熟。
1.2 后端框架选型:ThinkPHP在业务中台的定位
选ThinkPHP而不是更“重”的框架,是基于实际业务体量做的判断。租赁小程序通常的并发量不会像电商大促那么极端,而业务逻辑却非常复杂:计费规则、押金流转、商品状态流转、多角色权限(用户、商家、平台管理员)。ThinkPHP的核心优势恰恰在于:
- 上手快、路由清晰:ThinkPHP的控制器、模型、验证器分层明确,写业务逻辑时能快速定位代码位置,多人协作时也容易统一规范。
- ORM配合事务方便:租赁系统的库存扣减、订单创建、资金流水写入必须在一个事务里完成,ThinkPHP的模型关联和事务机制配合得很好。
- 内置功能务实:软删除、自动时间戳、数据填充、中间件、队列等常用能力都有现成的,不用重复造轮子。
我在项目中用的是ThinkPHP 8,启用了多应用模式,把接口层、管理后台、定时任务拆成不同的应用模块。前端只认API,后端严格按照控制器→服务层→模型层三层结构来组织代码——控制器只做参数接收和响应输出,所有业务规则都放在服务层,模型层只负责数据表和关联关系定义。这样做的最大好处是,当计费规则调整、押金流程改动时,只需要动服务层里的一个类,不会牵连到控制器和模型。
提醒一点:ThinkPHP在低并发场景下开发效率很高,但如果后续业务量起来,仍然可以通过加Redis缓存、队列异步化、接口网关横向扩容来升级,架构上留好扩展空间就可以,不需要一开始就上高并发那套重型中间件。
1.3 前端跨端方案:UniApp的租赁业务适配
前端我选了UniApp。租赁类项目有一个典型特点:业务通常从微信小程序起量,但紧接着又要做H5落地页、甚至打包成App。如果每个端都单独开发,成本和维护量直接翻倍。UniApp一套代码可以编译到微信小程序、支付宝小程序、H5、App,语法基于Vue,写起来很顺手。
它在租赁场景里的几个关键能力非常实用。条件编译可以让我在不同端做差异化处理,比如微信小程序的登录逻辑和H5的登录逻辑不同,我用条件编译在同一个文件里分端写,不需要拆出多套工程。uni.request封装统一了请求入口,方便统一加token、统一处理错误码。本地存储API处理登录态和缓存也足够方便。
不过UniApp有个要注意的地方:复杂日期选择器、横滑日历等组件在跨端环境下有时会出现样式不一致。不要一上来就引入重型UI库,优先用内置组件加少量自定义样式实现,等项目核心功能跑通后,再逐步优化交互细节。
2. 租赁业务模型设计——核心难点拆解
2.1 商品与SKU:出租品和售卖品并存的数据设计
租赁商城里经常出现“既可租赁、也可直接购买”的商品,比如相机既可以按天租,也可以直接买走新品。这种模式下,商品表不能只设计成单一类型。我在项目里的做法是:
- 商品表(goods):保存商品基本属性,如名称、图片、描述、分类ID。
- SKU表(goods_sku):保存具体的规格和价格信息,一个商品可以挂多个SKU。
- 租赁规则表(goods_rental_rule):单独保存该SKU的租赁相关配置,比如最低起租天数、租金单价、押金金额、是否允许续租、逾期日费率等。
为什么把租赁规则独立成一张表,而不是直接塞在SKU表里?因为同一款商品在不同时期、不同用户群体(普通用户/会员/企业客户)可能对应不同的租赁规则。比如某型号无人机默认押金3000元,但在会员体系下押金降到1500元;默认租金80元一天,但租30天以上自动按6折算。规则独立成表后,可以在同一张表里保存多条规则,然后按优先级匹配,扩展性极强。
还要注意“租赁库存”不能和“销售库存”混用。租赁库存需要按时间段维护,我就单独设计了sku_rental_inventory的概念,结合订单表来判断每个SKU在某个时间区间内是否还有空闲库存,而不是简单依赖一个库存数字。具体并发控制后面专门讲。
2.2 计费规则:起租、按天/按月、续租与逾期处理
租赁计费是整个系统的核心争议点,前端要能实时算钱,后端在创建订单和归还结算时要能精确计算。我的计费设计采用了“统一价格字段+按单位换算”的模型,规则如下:
| 参数 | 说明 | 存储位置 |
|---|---|---|
| 计价单位 | day/week/month/hour | goods_rental_rule |
| 单位价格 | 每单位时间的租金金额 | goods_rental_rule |
| 最少起租 | 最短租期,比如至少3天 | goods_rental_rule |
| 阶梯折扣 | 按租期长度递减的折扣区间 | rental_price_rule |
| 续租单位 | 每次续租的最小时间单元 | goods_rental_rule |
| 逾期费率 | 超过应还日期后的每日费用(通常为日租金的1.5倍) | goods_rental_rule |
订单租金计算的逻辑大致是这样的:先根据起租日期+预计归还日期得出租期天数,再匹配阶梯折扣计算出基础租金;如果用户选择续租,把新的归还日期更新进订单,续租天数的租金按续租时的实时单价重新计算;如果到应还日期还没有归还,系统进入逾期状态,逾期费用按“逾期单价×逾期天数”逐日累计。
这里的核心难点是计费精度。比如按小时租的商品,起止时间精确到分钟;按天租的商品,要约定好“自然日”和“24小时制”的差异,比如租期从5月1日14:00到5月3日14:00是两个自然日还是按小时算?我在前端选了“按自然日+首日不计整天”的简化方案,租期默认按天为单位、最短1天,起租当日不算完整租金但占用库存,归还日如果超时则按小时补收。这个规则事先要和业务方确认清楚,不然后期对账会出很多麻烦。
2.3 押金与资金流转:冻结、扣款、退还的链路设计
押金是租赁业务里最需要严谨对待的部分。我在系统里把押金单独抽象成一张deposit_order表,不把它混进普通订单表。每一笔租赁订单可以对应一条押金单,押金单有自己的状态机:待支付、已支付(冻结中)、退还中、已退还、已扣除(部分或全部)。
押金的处理链路是这样的:
- 用户下单时,同时发起“租金支付”和“押金支付”两个支付动作。微信小程序里常见的做法是押金走“微信支付分免押”,平台可以省去资金冻结环节;如果走现金押金,则用普通微信支付把押金打进来。
- 订单归还时,系统先检查商品是否有损坏、是否逾期,若正常,则发起押金原路退回,在微信支付回调后把押金单状态改成“已退还”。
- 若出现损坏或逾期,平台管理员在后台填写扣款金额和原因,系统自动生成一条扣款记录,剩余押金原路退回。
资金流设计上有一个经验:每一笔资金变动都要保留一条流水记录。无论是押金收入、押金退款、租金收入、逾期扣款还是赔偿扣款,全部落进fund_flow表。这样做有两个直接好处:一是在对账时可以直接用流水的累计数和支付平台账单核对;二是用户有争议时,后台能立刻查看整条资金路径的完整时间线,不用翻Excel。
在和小程序对接时要注意微信支付的“押金”和“租金”最好分两个支付单创建,不要合并成一笔。因为退款时押金往往需要单独退,合并支付在退款环节会变得非常难拆分。
3. 数据库设计与核心接口实现
3.1 核心数据表结构与字段设计
数据库设计是整个租赁系统最见功底的地方。我把表分成三组:商品组(goods、goods_sku、goods_rental_rule、sku_rental_inventory)、交易组(order、order_item、deposit_order、fund_flow、order_rental_period)、用户与营销组(user、user_address、coupon、user_coupon)。
下面挑几张关键表列出核心字段,方便你直接参考:
| 表名 | 关键字段 | 设计说明 |
|---|---|---|
| order | id、order_no、user_id、total_rent_amount、deposit_amount、deduct_amount、refund_amount、status、start_time、end_time、actual_return_time | status建议拆成多个子状态:10待支付、20待发货、30租赁中、40已归还待结算、50已完成、60已取消、70已逾期 |
| order_item | order_id、sku_id、sku_name、price_unit、unit_type、rent_days、rent_amount | 订单快照,防止商品SKU后续改价影响历史订单 |
| order_rental_period | order_id、start_time、end_time、period_index、amount | 记录每一次租期变更(原始租期/续租/改期),用于审计和核算 |
| deposit_order | id、order_id、user_id、deposit_amount、refund_amount、status、payment_sn | 独立押金单,状态机管理 |
| fund_flow | id、user_id、order_id、type、amount、balance_before、balance_after、trade_no | 每一笔钱的进出记录,type区分租金/押金/扣款/退款 |
索引设计上有几个容易忽略的点。订单表的user_id + status必须建联合索引,因为用户订单列表查询基本走这两个条件;order_rental_period的end_time必须建索引,因为定时任务需要高频扫描“即将到期/已逾期”的订单;fund_flow的trade_no必须建唯一索引,因为要通过它做幂等处理,避免支付回调重复入账。
租金和押金关乎真金白银,所有金额字段我都用的decimal(10,2),禁止使用float。浮点数的精度问题在涉及扣款、分摊的场景一定会出事,不要赌这个。
3.2 计费与建单核心接口实现
租金的计算逻辑我在服务层写了一个独立的RentalCalculator类,和控制器完全解耦。创建订单的流程是:
- 前端提交参数(sku_id、起租时间、预计归还时间、收货方式等)。
- 后端先做商品状态校验:SKU是否上架、租赁规则是否有效。
- 调用优惠计算和租金计算,得到应付金额。
- 校验库存:锁定sku_rental_inventory。
- 用事务创建订单、订单明细、租赁周期记录、押金单。
- 生成微信支付参数返回给前端。
库存锁定我用的是一张sku_rental_inventory表,里面直接记录“某SKU在某时间区间的可租数量”。每次用户发起订单时,需要查询该SKU在order_rental_period中有没有重叠时间的有效订单,并判断剩余可租数量。为了减少并发冲突,我在库存表上加了乐观锁字段version,更新时带上版本号,更新失败说明有冲突,重新查一遍即可。
建单接口的关键代码如下(ThinkPHP服务层逻辑,已做简化):
public function createOrder(array $params): array { Db::startTrans(); try { $sku = GoodsSku::find($params['sku_id']); $rule = GoodsRentalRule::where('sku_id', $sku->id) ->order('priority desc')->find(); // 1. 校验租赁规则是否启用 if (!$rule || $rule['status'] != 1) { throw new \Exception('该商品暂不支持租赁'); } // 2. 计算租期与租金 $calcResult = (new RentalCalculator())->calculate( $rule, $params['start_time'], $params['end_time'] ); // 3. 校验库存是否足够(按时间区间) $inventoryService = new RentalInventoryService(); $count = $inventoryService->getAvailableCount( $sku->id, $params['start_time'], $params['end_time'] ); if ($count <= 0) { throw new \Exception('所选时间段库存不足'); } // 4. 创建订单主记录 $order = Order::create([ 'order_no' => $this->generateOrderNo(), 'user_id' => $params['user_id'], 'total_rent_amount' => $calcResult['rent_amount'], 'deposit_amount' => $rule['deposit_amount'], 'status' => Order::STATUS_PENDING_PAY, 'start_time' => $params['start_time'], 'end_time' => $params['end_time'], ]); // 5. 创建租期记录 OrderRentalPeriod::create([ 'order_id' => $order->id, 'start_time' => $params['start_time'], 'end_time' => $params['end_time'], 'period_index' => 1, 'amount' => $calcResult['rent_amount'], ]); // 6. 创建押金单 DepositOrder::create([ 'order_id' => $order->id, 'user_id' => $params['user_id'], 'deposit_amount' => $rule['deposit_amount'], 'status' => DepositOrder::STATUS_UNPAID, ]); Db::commit(); return $this->buildPayParams($order); } catch (\Exception $e) { Db::rollback(); throw $e; } }归还结算接口则是对整个租期做一次总清算:判断实际归还时间是否逾期,如果逾期,调用计算器计算逾期费用;检查是否有客服标记的损坏赔偿金额;把“租金+逾期费+赔偿金”汇总,然后从押金中扣除,剩余部分发起退款。这个接口建议只在后台管理器人触发,或者由扫码归还设备的动作触发,不要开放成普通用户可重复调用的接口,防止重复退款。
3.3 小程序端页面与交互实现
UniApp端的页面结构按用户路径来组织:首页→分类页→商品详情→立即租赁→订单确认→支付→订单中心→归还/续租。我把关键页面提取出来说下实现要点。
首页和分类页相对常规,用uni.request请求后端接口,展示轮播图、分类入口、推荐商品列表。需要注意的是一级页面要做好分页加载,小程序端下拉刷新用enablePullDownRefresh,触底加载用onReachBottom,这两个事件直接控制页码自增并追加载入列表。
商品详情页的租赁版块比电商版多了一个“租期选择器”。我实现了一个简单的日期选择组件:起始日期用picker的mode="date",归还日期同样用日期选择器,选择后自动发送请求给后端试算租金,并在页面上展示“租金总额+押金+是否免押”的金额明细。这里要避免把租金计算做在前端——所有金额试算以后端接口返回为准,前端展示后端给的数据即可。试算接口和服务层计算逻辑复用一个类,确保试算和最终下单的金额完全一致。
订单确认页的关键是收件信息与费用展示。租赁订单通常有两种交付方式:快递寄送和到店自提。我在订单表里加了delivery_type字段,快递时调起收货地址选择,自提时显示门店位置和营业时间。运费单独算一笔字段freight_amount,不混入租金,这样退款时运费可以独立处理。
订单列表页需要展示四种高频操作:待支付的“去支付”、租赁中的“申请续租”“查看归还指引”、待归还的“申请归还”、已完成的“查看结算明细”。这里我建议不要把所有按钮都堆在卡片上,而是根据订单状态动态渲染操作按钮列表,每次操作后重新拉取订单详情,避免用户连续点击导致状态冲突。
续租功能是租赁小程序的高频操作。用户点击续租时,前端弹出续租时长选择器(按天/按周/按月),选择新归还日期后,后端根据原订单剩余未结租金、新周期的租金、以及续租产生的应付金额,生成一个“续租支付单”,用户在确认金额后补差价。我在设计续租时特意和“改期”做了区分:续租只是延长租期,不改变原订单的计费周期;改期则会重新调整整个租期和租金计算。这两个操作对应后端不同的服务方法,不要混用。
整个前端的请求层我统一封装了一个request.js插件,把baseURL、token注入、响应码处理、401跳登录都集中在一起。这样在页面里只用关心业务数据,不用每次手动调uni.request。
import { getToken } from '@/utils/auth' export function request(options) { return new Promise((resolve, reject) => { uni.request({ url: baseURL + options.url, method: options.method || 'GET', data: options.data || {}, header: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + getToken() }, success: (response) => { const res = response.data if (res.code === 0) { resolve(res.data) } else if (res.code === 401) { uni.navigateTo({ url: '/pages/login/login' }) reject(res) } else { uni.showToast({ title: res.msg, icon: 'none' }) reject(res) } }, fail: (err) => { uni.showToast({ title: '网络异常', icon: 'none' }) reject(err) } }) }) }这段代码虽然简单,但在多端环境下KPI很稳。核心是统一了鉴权头、错误提示、登录拦截,避免每个页面各写一套逻辑,减少很多重复工作量。
4. 常见问题与排查技巧实录
4.1 并发扣库存:同一个SKU同一时间段被抢订
租赁系统最典型的并发问题:同一款热门相机,两个用户同时提交“同一时间段”的订单,系统必须保证只有一个成功。我踩过的坑是:刚开始用“查询数量再判断”的方式,结果在并发下库存超卖。后来改成两条路并行:
- 数据库层:在库存表使用
where条件原子扣减,比如UPDATE sku_rental_inventory SET available_num = available_num - 1 WHERE sku_id = ? AND available_num > 0,更新影响行数为0则说明库存已满,直接拒绝。 - 业务层:引入Redis锁,以
lock:order:{sku_id}:{start_time-{end_time}为key加分布式锁,抢不到锁的直接返回“手慢了”,避免大量请求同时打到数据库。
这里有一个容易忽视的细节:租赁库存的数量必须按“时间区间重叠”判定,而不是笼统按总量扣。比如同一件商品,A租5月1日到5月3日、B租5月4日到5月6日,这两个订单没有重叠时间段,那就可以同时占用同一件实物。所以库存服务要写一个“区间重叠查询”,扫描已有订单记录中有没有和当前区间重叠且状态有效的订单,再算出剩余可用数。时间复杂度会略高,但租赁场景的SKU规模不会特别大,配合索引和Redis缓存完全够用。
4.2 小程序登录态与Token过期问题
小程序的登录流程是:wx.login()拿临时code,发送给后端,后端用code去微信接口换算openid和session_key,再返回自定义token给前端。Token保存在UniApp的本地存储里,后续请求在请求头带上。常见的问题有两个:
一是token过期后,用户操作时接口报401,但页面没有跳转登录。我的解决方案是在前端的request封装里统一处理401状态码,跳转至登录页,并带上当前页面路径,登录成功后uni.redirectTo回原页面,保证用户操作不丢失。
二是支付回调与登录态的耦合。有些页面调用微信支付时,后端回调使用的是微信服务器,不会带用户的token,完全依赖订单号定位用户。所以后端接口设计时要注意:凡是支付回调相关的接口,一律通过订单号和签名验证身份,不要依赖Authorization头。这是我踩过很深的坑,第一次上线时回调接口带了token校验,结果微信服务器根本不会带token,导致回调全部失败。
4.3 押金退还与对账异常
押金退还的异常,几乎都出在异步回调和幂等上。微信支付退款接口是异步通知的,系统收到“退款成功”回包后,才能把押金单状态改成“已退还”。如果程序在处理回调时挂了,或者重复收到回调,状态就会错乱。
我的做法是:在deposit_order表里加refund_notify_status字段,并保证退款通知的处理接口是幂等的——每次收到回调先检查该字段,如果是“已成功”,直接忽略;如果当前是“处理中”,则继续执行状态更新和资金流水落库。资金流水写入时,trade_no + type加唯一索引,从数据库层面保证不会重复入账。
对账方面,我写了一个定时任务,每个小时拉取一次微信支付平台的退款记录,和本地fund_flow表中标记为“退款中”的记录做比对。如果本地显示退款中但微信端已经是退款成功,就把本地状态补上;如果本地显示退款成功但微信端没有记录,则触发人工告警。这个任务在前三个月帮我们避免了好几笔资损。
4.4 小程序审核与合规注意事项
小程序平台对租赁类目审核比较严格,提审时需要选择对应类目并上传资质证明。做租赁小程序要注意几件事:一是平台要求二手商品或租赁商品必须有明确的售后和退款说明,我们在用户协议里单独用一个大章节写租赁规则、押金规则、逾期规则,并用粗体标注关键条款;二是在线支付功能不能只接“付款”,如果涉及平台担保或资金归集,需要选择合适的支付产品,不能随随便便用个人收款码;三是用户协议不要写“所有解释权归平台”这类无效条款,平台审核时会对这些内容打回。
另外,UniApp编译到小程序后,分包大小、图片体积都要控制。租赁商品详情页往往有很多高清图,要统一走图片压缩和CDN加速,不然小程序包很容易超限。我的经验是详情页图片用懒加载组件,列表页图片用缩略图,商品详情大图点击后再加载原图,能做到体验和性能的平衡。
4.5 数据库连接数与慢查询优化
租赁系统上线后,实际遇到的最大性能瓶颈不在代码逻辑,而在数据库。订单查询列表、库存区间扫描、对账任务,三波压力叠加时MySQL连接数经常报警。我做了三件事缓解这个情况:
- 给订单表和租期表拆了“热数据”和“归档数据”。订单完成超过3个月后,由定时任务把明细搬到归档表,热表数据量降到几十万级,查询速度明显提高。
- 任务型的对账和逾期扫描,改成跑在独立数据库账号上,并限制Sleep连接数,避免一次性查询把连接池占满。
- 所有前端列表页分页必须强制传
page和pageSize,后端做最大条数限制,防止有人写个脚本拉全量数据把数据库拖垮。
5. 这套系统的实际落地效果与经验沉淀
整套系统从第一个版本到稳定运行,前后迭代了几轮。最开始我用的是“电商系统加几个租赁字段”的思路,结果被现实狠狠教训了一顿——计费规则改一次,订单逻辑就跟着乱一次;押金和租金混在一张表里,退款时怎么算都头大。后来推倒重来,把押金、租期、资金流水都拆成独立模型,系统才真正稳定下来。
我自己在实操中最深的体会是:租赁系统的核心就两个词,库存和资金。库存是物理世界的映射,必须在设计上尊重时间段和并发;资金是真金白银,必须在每一步都保留流水和幂等保护。业务逻辑再花哨,如果这两条主线不稳,后期一定会被对账和客诉折磨到崩溃。
另一个经验是:不要在第一个版本就把所有功能做全。租赁业务的需求面很宽——预约、免押、以租代购、保险、门店自提、物流轨迹,每一样都值得做,但一次性全上,开发和测试风险都会指数上升。我当时的做法是先跑通“展示商品→下单支付→发货/自提→到期归还→押金结算”这条最小闭环,确认资金流完全正确后,再逐步叠加信用免押、会员折扣、扫码归还等增强功能。你如果正在规划类似的系统,建议也按这个节奏来。
后续系统可以扩展的方向也很多:接入信用分做免押金租赁、租转售的以租代购、企业内部资产租赁管理、多门店库存共享等。基础数据结构只要没走偏,往上加功能就像搭积木一样顺。如果你已经开始做租赁项目,希望这篇里的设计思路和实施细节能帮你避开几个最容易翻车的坑。遇到具体的问题,欢迎在评论区把场景和状态机贴出来一起聊。