本文结合实际项目设计进行拆解,建议先看看设计成果《共享充电宝云平台》,再阅读本文。
一、引言
这是系列的最后一篇。前面七篇把整个充电宝平台的核心模块和技术细节都过了一遍:从架构选型到 TCP 通信,从租借归还到微信支付,从权限体系到设备管理,从 AOP 缓存到 License 授权。
这一篇跳出代码,聊聊过程中踩过的坑、面临的取舍、以及未来的方向。希望能给正在做类似项目的朋友一点参考。
二、有哪些坑踩过
坑 1:Socket 粘包/半包处理
TCP 是流式协议,不像 HTTP 那样天然有「消息边界」。如果设备连续发送多条指令,服务器可能一次读取到多条粘在一起(粘包),或者一条指令被拆成两次读(半包)。
我们的协议用 #* 和 *# 作为消息边界,但在高并发场景下,ReceiveAsync 的回调中可能收到不完整的数据。
解决方式: 在接收缓冲区中累积数据,每次收到数据后先拼接到 StringBuilder,然后按 #*...*# 的完整格式提取报文,剩余部分留在缓冲区等待下次接收。
var sb = new StringBuilder();
var buffer = new byte[1024 * 5];
var header = "#*";
var footer = "*#";
while (!ct.IsCancellationRequested)
{
int bytesRead = await clientSocket.ReceiveAsync(
new ArraySegment<byte>(buffer), SocketFlags.None);
if (bytesRead == 0) break;
sb.Append(Encoding.UTF8.GetString(buffer, 0, bytesRead));
string content = sb.ToString();
int headerIndex = content.IndexOf(header);
int footerIndex = content.IndexOf(footer, headerIndex >= 0 ? headerIndex + 2 : 0);
if (headerIndex >= 0 && footerIndex > headerIndex)
{
string message = content.Substring(
headerIndex + header.Length, footerIndex - headerIndex - header.Length);
// 移除已处理的部分(包括当前报文之前可能残留的字节)
sb.Remove(0, footerIndex + footer.Length);
// 消息解析 + 业务处理
var parseResult = await ParseMessage(clientSocket, message);
await ProcessMessage(clientSocket, parseResult.Item2, message);
}
// 未收到完整报文时,数据留在 sb 中,等下次接收继续拼接
}
这个处理逻辑看着简单,但前提是协议定义足够清晰——包头包尾不能出现在数据中。我们在协议设计时就规定了 #* 和 *# 为保留字符,确保不会出现误匹配。
坑 2:微信支付分回调幂等性
支付分回调通知后端时,如果网络抖动,微信可能会重发同一个回调多次。如果不做幂等处理,就可能重复扣款或重复完结。
解决方式: 在 WechatPayController 的回调处理中,先查 transaction_id 是否已经处理过:
switch (event_type)
{
case "TRANSACTION.SUCCESS":
if (order.PayResult != 1) // 检查是否已支付
{
order.PayResult = 1;
order.PayTime = DateTime.Now;
}
break;
// ...
}
所有回调处理都遵循「先查状态,再更新」的原则。同一个回调通知被多次分发,只有第一次会实际更新数据库,后续的都不会产生副作用。
坑 3:设备离线状态与订单状态的一致性
用户归还后,如果设备恰好在此时断网,return 指令没有发到服务器。服务器以为订单还在进行中,用户以为已经还好了。结果就是计费一直继续,用户产生投诉。
解决方式: 两方面处理:
- 设备端重连后补报——机柜重新连上服务器后,会发送
detailup指令上报所有槽位的当前状态,服务器据此更新已归还的订单 - 运营后台手动处理——运营人员可以在后台强制终止订单、调整金额、发起退款
这个场景告诉我们,物联网应用永远要假设设备随时可能掉线,所有的业务流程都必须考虑「设备离线时的降级方案」。
三、做了哪些取舍
取舍 1:自研 TCP 通信 vs 用 IoT 云平台
当时如果选阿里云 IoT 或者 EMQ X,可以省去 Socket 编程、连接管理、消息路由这些工作。但最终选择了自研:
- 选自研的理由: 设备量不大(几百台),协议定制程度高,不想多维护一个中间件
- 付出的代价: 粘包/半包、心跳检测、连接池管理这些细节都要自己处理
对于百万级设备的场景,自研肯定不划算。但对于我们的体量,自研的灵活性和可控性更有价值。
取舍 2:单体应用 vs 微服务
很多人看到「设备通信 + Web API + 定时任务 + SignalR 全部在同一个进程」可能会皱眉。为什么不拆成微服务?
- 不拆的理由: 项目初期人数有限,单体应用开发效率最高。设备上报数据后直接写数据库、直接更新订单,不需要经过网关转发
- 单体化带来的好处: 部署简单(一个站点搞定)、调试方便(VS 开一个项目就行)、事务一致性好
- 未来怎么拆: 如果设备量增长到几千台,SocketServer 可能会拆分出去作为一个独立服务
取舍 3:内存缓存 vs Redis
目前用的是 .NET 内置的 MemoryCache,没有上 Redis。
- 选 MemoryCache 的理由: 部署简单,不需要额外维护 Redis 服务,对于单实例部署完全够用
- 瓶颈在哪: 如果未来需要多实例水平扩展,MemoryCache 的数据不共享,每个实例各缓各的,缓存一致性需要改造
迁移到 Redis 的成本不高——只需要替换 ICacheService 接口的实现。接口已经抽象好了,具体实现是 MemoryCache 还是 Redis,只改一行注入代码。
四、时间防回退机制
这是 License 模块里最值得单独拿出来讲的一个设计。
常规的 License 只校验过期时间,但有个漏洞:如果把系统时间调回过期之前,再重启服务,验证就绕过了。
我们的做法是记录每次校验的 UTC 时间到文件,每次校验时拿这次的时间跟上次比:
private DateTime? GetLastCheckUtc()
{
// 从文件读取上次校验时间
var text = LicenseUtils.SafeReadFile(lastCheckFilePath);
return DateTime.Parse(text, ...);
}
private void SaveLastCheckUtc(DateTime utcNow)
{
LicenseUtils.SafeWriteFile(lastCheckFilePath, utcNow.ToString("o"));
}
// 验证系统时间
var lastCheck = GetLastCheckUtc();
if (lastCheck.HasValue && DateTime.UtcNow < lastCheck.Value)
{
// 时间被回退了!
return false;
}
SaveLastCheckUtc(DateTime.UtcNow);
再加上每 60 秒的 OfflineLicenseCheckerService 持续校验,即使启动时绕过了,运行中也会被检测到。
五、当前成果
到目前为止,平台已跑通全流程:
- 设备接入: 支持 8 槽 / 16 槽机柜,TCP 长连接 + 4G 模块通信
- 订单系统: 从创建到支付 7 个状态完整流转,日封顶 / 总封顶规则灵活配置
- 支付体系: 微信支付分免押金 + JSAPI 支付 + 退款,全链路跑通
- C 端小程序: 扫码租借、地图找店、订单管理、收益明细
- 运营后台: 设备管理、店铺管理、角色权限、定时任务、审计日志
- 商户后台: 订单查看、收益提现、设备监控
- 安全体系: JWT 认证、API 授权、License 硬件绑定
- 运维能力: 远程操控、心跳监控、全量指令日志
六、接下来往哪走
短期内关注这几个方向:
1. 数据驱动运营
目前报表功能比较基础,接下来想做两个事:
- 设备热力图:按地理位置展示设备使用率、周转率
- 智能调度建议:根据历史数据预测热门点位,辅助运维补宝
2. 智能告警
目前设备离线、指令执行失败等异常没有主动通知机制。接下来考虑接入告警规则引擎:
设备离线超过 30 分钟 → 通知区域运维微信
订单超时未弹宝 → 自动重试 + 通知客服
某设备连续 3 次心跳 csq < 10 → 标记为信号异常
告警渠道可以走微信模板消息 + 站内信。SignalR 已经搭好了,实时推送的底座有了。
3. 多租户能力
部分场景下,不同品牌方需要完全隔离的数据和运营后台。当前的 RBAC 可以一定程度支持,但要实现真正的多租户隔离,需要在数据层增加 TenantId 维度。
4. 大数据看板
运营需要一个实时数据大屏:今日订单数、在线设备数、累计收益、充值转化率等。可以用 SignalR 推送 + ECharts 实现,数据实时刷新。
七、写在最后
八篇文章,从架构设计到业务实现,从通信协议到安全防护,基本把充电宝平台的完整面貌讲清楚了。
这个项目最让我觉得值得分享的地方,不是用了多新潮的技术,而是一个朴素的事实:用成熟的工具,做务实的设计,把每一个细节做到位,就能搭起一个能跑、能赚钱的生产级平台。
没有用微服务、没有用 Redis、没有用 Docker——这个项目只在必要的地方用了必要的技术。每个技术决策都有明确的背景和理由,不是盲目追新,也不是固步自封。
如果你正在做或打算做一个 IoT 相关的项目,希望这个系列能给你一些参考。
有技术问题或者想法,欢迎交流。
(全文完)