0

by tcdos 2026-06-19

03 | 租借与归还 - 充电宝平台核心业务流程的代码实现

一、引言

如果说 TCP 通信是共享充电宝平台的骨架,那租借和归还就是整个平台的心脏。

用户在共享充电宝前扫码、弹宝、使用、归还、支付——这五个步骤构成了一个完整的交易闭环。每一个环节都可能出问题:设备离线了怎么办?支付分没确认怎么办?归还时网络断了怎么办?

这篇把核心流程的代码实现完整拆解一遍,从订单状态机开始,到计费规则,到支付分对接,一条线讲清楚。


二、订单状态机:7 个状态描述完整生命周期

订单的状态定义在一个字段 Status 上,取值范围从 -1 到 6:

状态值含义说明
-1未确认用户创建订单但未确认微信支付分
0已取消用户主动取消或系统自动取消
1已创建订单已生成,等待支付分确认
2已确认微信支付分已确认,等待弹宝
3租借中设备已弹宝,用户正在使用
4已归还设备已收宝,等待支付分完结
5已完结支付分订单已完结,等待扣款
6已支付支付分已扣款成功

流转路径是这样的:

创建(1) → 支付分确认(2) → 租借中(3) → 已归还(4) → 已完结(5) → 已支付(6)
                                            ↘ 已取消(0) / 退款

每个状态之间的转换都有前置条件,不是随便跳的。比如从 12 必须等微信支付分的用户确认回调,从 34 必须等设备上报归还指令。


三、租借流程:从扫码到弹宝

整个租借链路涉及三个端:微信小程序 → 后端 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 的核心逻辑:

  1. 检查设备是否在线——通过 iotService.Connected() 查 Socket 连接池
  2. 检查设备是否已投入使用——设备必须关联到店铺
  3. 创建订单,写入数据快照——把店铺的计费规则(免费分钟、单价、分成比例)快照到订单中,防止后续配置变更影响已有订单
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));
}

这个逻辑贯穿所有与订单相关的查询接口,确保数据不会越界。


七、小结

租借和归还的业务流程梳理下来,关键就几点:

  1. 状态机要清晰——7 个状态定义了订单的完整生命周期,不允许随意跳转
  2. 计费要严谨——免费分钟、按分钟计费、日封顶、总封顶,四层规则叠加
  3. 数据快照——计费规则在下单时快照到订单,避免后续变更影响历史订单
  4. 异常路径全覆盖——用户不确认、设备离线、后台强制终止、退款,每一种都要有对应的处理逻辑

下一篇会讲微信生态的深度集成——小程序登录、支付 V3、支付分免押金、SignalR 实时推送,看看怎么把微信的能力和充电宝业务深度绑定。

有疑问或者想讨论的点,留言区见。

欢迎关注 天草设计 公众号,领取 源代码 福利礼包!

系列文章:共享充电宝

围观:73| 收获点赞:0

评论 0

暂无评论,来说点什么吧

?