一、引言
如果说 TCP 通信是共享充电宝平台的骨架,那租借和归还就是整个平台的心脏。
用户在共享充电宝前扫码、弹宝、使用、归还、支付——这五个步骤构成了一个完整的交易闭环。每一个环节都可能出问题:设备离线了怎么办?支付分没确认怎么办?归还时网络断了怎么办?
这篇把核心流程的代码实现完整拆解一遍,从订单状态机开始,到计费规则,到支付分对接,一条线讲清楚。
二、订单状态机:7 个状态描述完整生命周期
订单的状态定义在一个字段 Status 上,取值范围从 -1 到 6:
| 状态值 | 含义 | 说明 |
|---|---|---|
| -1 | 未确认 | 用户创建订单但未确认微信支付分 |
| 0 | 已取消 | 用户主动取消或系统自动取消 |
| 1 | 已创建 | 订单已生成,等待支付分确认 |
| 2 | 已确认 | 微信支付分已确认,等待弹宝 |
| 3 | 租借中 | 设备已弹宝,用户正在使用 |
| 4 | 已归还 | 设备已收宝,等待支付分完结 |
| 5 | 已完结 | 支付分订单已完结,等待扣款 |
| 6 | 已支付 | 支付分已扣款成功 |
流转路径是这样的:
创建(1) → 支付分确认(2) → 租借中(3) → 已归还(4) → 已完结(5) → 已支付(6)
↘ 已取消(0) / 退款每个状态之间的转换都有前置条件,不是随便跳的。比如从 1 到 2 必须等微信支付分的用户确认回调,从 3 到 4 必须等设备上报归还指令。
三、租借流程:从扫码到弹宝
整个租借链路涉及三个端:微信小程序 → 后端 API → 充电宝机柜。
第一步:用户扫码,获取设备信息
用户扫描机柜上的二维码后,小程序拿到设备编号,调用接口获取设备详情和可用的充电宝列表:
async getDeviceDetail() {
const res = await api.getDeviceDetail(this.qrcode, false);
if (res.status && res.data != null) {
const { freeMinutes, unitMinutes, unitPrice, dayPrice, maxPrice, sn, aims } = res.data;
this.dsn = sn;
this.setData({ freeMinutes, unitMinutes, unitPrice, dayPrice, maxPrice, aims });
this.getDeviceBatteryList();
}
}
费用信息(免费分钟、单价、日封顶、总封顶)是从店铺配置里读取的,每家商户可以单独设置。
可用的充电宝列表会按电量从高到低排序,优先推荐电量最满的给用户:
async getDeviceBatteryList() {
const res = await api.getDeviceBatteryList({ deviceSN: this.dsn, s: 1 }, true);
this.batteryList = res.data.sort((a, b) => { return b.e - a.e });
}第二步:创建订单
用户选择租借后,小程序调用 addOrder 创建订单:
const params = {
openid, phone, userId, aims, n, batterySN: sn, deviceSN: this.dsn
};
const res = await api.addOrder(params);
后端 OrderService.AddAsync 的核心逻辑:
- 检查设备是否在线——通过
iotService.Connected()查 Socket 连接池 - 检查设备是否已投入使用——设备必须关联到店铺
- 创建订单,写入数据快照——把店铺的计费规则(免费分钟、单价、分成比例)快照到订单中,防止后续配置变更影响已有订单
public async Task<Tuple<bool, string, OrderWxResDto>> AddAsync(RentAddDto model)
{
// 1. 设备是否在线
var online = iotService.Connected(model.DeviceSN);
if (online != 1)
return new Tuple<bool, string, OrderWxResDto>(false, "设备离线", null);
// 2. 设备是否投入使用
if (string.IsNullOrEmpty(device.ShopId))
return new Tuple<bool, string, OrderWxResDto>(false, "设备未投入使用", null);
// 3. 创建订单实体
ZnOrderEntity entity = mapper.Map<ZnOrderEntity>(model);
entity.OrderId = ZnResponse.GuId();
entity.OrderSN = SNHelper.GenerateSN(); // 生成唯一订单号
entity.ST = DateTime.Now;
entity.Status = 1; // 已创建
entity.PayResult = 0;
// 4. 数据快照:计费规则、分成比例
entity.FreeMinutes = shop?.FreeMinutes;
entity.UnitMinutes = shop?.UnitMinutes;
entity.UnitPrice = shop?.UnitPrice;
entity.DayPrice = shop?.DayPrice;
entity.MaxPrice = shop?.MaxPrice;
entity.Radix = shop?.Radix;
entity.MarketRatio = shop?.Ratio; // 商户分成
entity.BrokerRatio = user?.Ratio; // 代理分成
// ...
}
为什么做数据快照?
因为商户可能随时调整计费规则。如果订单创建后规则变了,已产生的订单应该按旧规则计费。把需要的字段全部拷贝到订单表,每个订单都是独立的,不受后续变更影响。
第三步:发起微信支付分
订单创建成功后,小程序拉起微信支付分的确认弹窗:
if (wx.openBusinessView) {
wx.openBusinessView({
businessType: 'wxpayScoreUse',
extraData: item2,
fail() {
that.cancel(); // 自动取消订单
},
});
}
用户确认后,微信支付分回调通知后端,此时订单状态从 1(已创建)变为 2(已确认)。
第四步:下发租借指令
订单确认后,后端通过 IotService.Send() 向机柜下发 rent 指令。指令中包含槽位编号 n,机柜根据编号弹出对应槽位的充电宝。
下发后有一个轮询确认机制——在规定次数内等待设备回复,确认弹宝成功,然后将订单状态更新为 3(租借中)。
整个流程串起来就是:
小程序扫码 → 查设备/电池信息 → 创建订单(Status=1) → 支付分确认(Status=2) →
下发rent指令 → 设备弹宝 → 收到回复 → 更新订单(Status=3) → 开始计费
四、归还流程:从插回到扣款
归还流程比租借简单,但涉及计费和支付,逻辑更重。
第一步:设备上报归还
用户把充电宝插回机柜任意空槽,机柜检测到插回后,通过 TCP 发送 return 指令:
#*{
"msg": 1718691245,
"sn": "DS24060001",
"cmd": "return",
"data": {
"st": 5,
"n": 3,
"sn": "BP24060001",
"e": 7
}
}*#
st 是当前库存,n 是归还的槽位编号,sn 是充电宝编号,e 是电量等级。
第二步:后端处理归还
服务端收到后调用 CmdReturn,核心业务在 OrderService.ReturnAsync 中:
[Logger(Summary = "归还(订单计费)")]
public async Task<ResultModel> ReturnAsync(IotReturnDto model)
{
// 查找租借中的订单(根据电池SN)
var order = await currentRepository.GetQuery(
x => x.BatterySN == model.BatterySN && x.Status == 3)
.OrderByDescending(x => x.CreatedDate)
.FirstOrDefaultAsync();
if (order == null)
{
// 未找到匹配订单,直接收宝(防止异常情况)
return;
}
// === 计费核心逻辑 ===
int? amount = 0;
DateTime st = order.ST.ObjToDate();
DateTime et = DateTime.Now;
// 总使用分钟
double? totalMinutes = (et - st).TotalMinutes;
// 减去免费分钟
totalMinutes = totalMinutes - freeMinutes > 0
? totalMinutes -= freeMinutes : 0;
if (totalMinutes > 0)
{
// 1. 按天计费
int? days = (totalMinutes / (24 * 60)).ObjToInt();
amount += days * dayprice;
// 2. 按分钟计费
double? mm = totalMinutes - days * 24 * 60;
decimal? units = Math.Ceiling((decimal)(mm * 1.0 / um));
int? total = (int)Convert.ToDecimal(units * price);
amount += total >= dayprice ? dayprice : total; // 不超过日封顶
}
// 总封顶
amount = amount > maxprice ? maxprice : amount;
// 更新订单
order.Status = 4; // 已归还
order.Amount = amount; // 计算金额
order.ET = et; // 归还时间
// ...
// 电池使用次数 +1
battery.Times += 1;
}
计费规则示例:
假设店铺配置为:免费 5 分钟、1 元/30 分钟、日封顶 20 元、总封顶 99 元。
- 用户使用了 25 分钟:
(25-5) = 20分钟,不足一个计费单位(30 分钟),按 1 元计 - 用户使用了 3 小时 10 分钟:
(190-5) = 185分钟,ceil(185/30) = 7个单位,共 7 元(不超过日封顶) - 用户使用了 26 小时:先算 1 天 20 元,再算剩余 2 小时
ceil(120/30)*1 = 4元,共 24 元(不超过总封顶 99 元)
第三步:发起支付分完结
计费完成后,自动调用微信支付分完结接口,通知微信按照计算金额扣款:
public async Task<ResultModel> CompleteAsync(CompleteDto model)
{
var postpayment = new List<PostPayment>() {
new (){ name = "收费标准",
description = $"0.01元/30分钟,每24小时封顶20.00元" },
new (){ name = "收费时长",
description = "3小时10分钟" },
};
var wx = await wxServicePayHelper.CompleteOrderAsync(
model.OrderSN, model.Amount, postpayment, model.ET);
if (wx.status)
{
order.Status = 5; // 已完结
}
}
完结后微信支付分会在 T+1 进行实际扣款,扣款成功后回调通知,订单状态变为 6(已支付)。
五、异常处理:几种常见场景
场景一:订单创建后用户未确认支付分
用户在扫码创建订单后,没有在微信支付分弹窗中确认。后端在 ConfirmAsync 中轮询 10 次(每次 800ms),如果超时未确认,自动取消微信支付分订单并将订单状态置为 -1(未确认)。
小程序端在支付分弹窗失败时,也会主动调用取消接口:
wx.openBusinessView({
businessType: 'wxpayScoreUse',
extraData: item2,
fail() {
that.cancel(); // 自动取消订单
},
});
场景二:后台强制终止订单
运营人员可以在后台手动终止进行中的订单(比如用户丢失充电宝、设备故障等):
public async Task<bool> StopAsync(CancleDto model)
{
var order = await currentRepository.FirstOrDefaultAsync(
x => x.OrderSN == model.OrderSN && x.Status <= 4);
var flag = await wxServicePayHelper.CancelOrderAsync(
model.OrderSN, model.Reason);
if (flag.status)
{
order.Status = 0; // 已取消
order.Desc = $"{order.Desc}|{DateTime.Now:yyyy/MM/dd HH:mm:ss} - 终止订单";
}
}
场景三:用户归还了但设备离线没上报
这种情况需要运营人员在后台手动标记归还,并调整订单金额。ReturnAsync 中有一个兜底逻辑:如果没找到匹配的进行中订单(可能是因为后台已终止),直接记录电池日志,不阻塞收宝:
if (order == null)
{
// 未还宝,但后台已经终止该订单,则直接收宝
await batteryLogRepository.AddAsync(new ZnBatteryLogEntity() { ... });
return new ResultModel(result);
}
场景四:退款流程
如果用户对扣款有异议,运营可以在后台发起退款:
public async Task<ResultModel> Refund(RefundDto model)
{
// 创建退款记录
ZnRefundEntity refund = new()
{
RefundSN = SNHelper.GenerateSN(),
OrderId = order.OrderId,
Amount = model.Amount,
// ...
};
// 调用微信支付分退款
var wxSevice = await wxServicePayHelper.Refund(
refund.RefundSN, refund.OrderTransactionId,
"用户申请退款", refund.Amount, refund.OrderAmount);
}
退款金额不能超过实际支付金额,且退款结果通过微信异步回调通知。
六、数据权限:谁可以看到哪些订单
一个容易被忽略但很重要的设计——订单查询的数据权限。
系统中存在四种角色:运营、商户、代理、运维,每种角色能看到的订单范围不同:
SysUserEntity user = await userRepository
.FirstOrDefaultAsNoTrackingAsync(x => x.UserId == aspNetUser.UserId);
if (user?.SecurityLevel != 0 && user?.IsSystem == 0)
{
var shopIds = new List<string>();
// 商户:只能看自己店铺的订单
if (user?.IsBiz == 1)
shopIds.AddRange(shopRepository.GetQuery(
x => x.ZnShopUser.Any(n => n.UserId == aspNetUser.UserId))
.Select(x => x.ShopId).ToList());
// 代理:只能看名下店铺的订单
if (user?.IsBroker == 1)
shopIds.AddRange(shopRepository.GetQuery(
x => x.SysUser.UserId == aspNetUser.UserId)
.Select(x => x.ShopId).ToList());
// 运维:只能看负责区域的订单
if (user?.IsOps == 1)
{
var areaCode = user?.AreaCode;
// 区域编码支持多维数组,如 [["湖南省","长沙市"],["湖南省","株洲市"]]
var codes = JsonConvert.DeserializeObject<List<List<string>>>(areaCode);
shopIds.AddRange(shopRepository.GetQuery(
x => codeList.Contains(x.AreaCode))
.Select(x => x.ShopId).ToList());
}
strWhere = strWhere.And(x => shopIds.Contains(x.ShopId));
}
这个逻辑贯穿所有与订单相关的查询接口,确保数据不会越界。
七、小结
租借和归还的业务流程梳理下来,关键就几点:
- 状态机要清晰——7 个状态定义了订单的完整生命周期,不允许随意跳转
- 计费要严谨——免费分钟、按分钟计费、日封顶、总封顶,四层规则叠加
- 数据快照——计费规则在下单时快照到订单,避免后续变更影响历史订单
- 异常路径全覆盖——用户不确认、设备离线、后台强制终止、退款,每一种都要有对应的处理逻辑
下一篇会讲微信生态的深度集成——小程序登录、支付 V3、支付分免押金、SignalR 实时推送,看看怎么把微信的能力和充电宝业务深度绑定。
有疑问或者想讨论的点,留言区见。
欢迎关注 天草设计 公众号,领取 源代码 福利礼包!
