这两年新能源车和电动自行车的保有量蹭蹭往上涨,充电难的问题越来越突出。我们团队之前接了一个共享充电桩系统的开发需求,技术栈锁定在微信小程序、PHP后端和uniapp跨端框架,也就是标题里的“微信小程序 PHP_uniapp的共享充电桩系统”。这套系统说通俗点,就是用户用微信小程序扫码充电、付费、看状态,运维人员在后台管设备、查订单、设价格,整体打通了“用户-设备-支付-管理”这条完整链路。
我在实际开发中把整个项目趟了一遍,发现这类系统真正难的地方不在增删改查,而在设备状态同步、计费准确性、异常订单处理、以及小程序端和硬件设备之间的时序配合。这篇文章就结合我自己的实践经验,把共享充电桩系统的架构设计、数据库建模、小程序端实现、PHP接口开发、设备通讯协议、以及线上真实踩过的坑都拆开讲清楚。不管是刚入行的开发者,还是准备接类似外包项目的团队,都能从中拿到可以直接落地的方案。
1. 项目整体设计与技术选型思路
1.1 业务需求拆解:三类角色与核心流程
先别急着写代码,需求得捋清楚。共享充电桩系统最少包含三类角色:
- 普通用户:通过微信小程序搜索附近充电桩、扫码开充、查看充电进度、支付费用、申请退款。
- 运维人员:管理充电桩设备信息、远程启停设备、查看实时状态、处理异常工单。
- 平台管理员:配置计费规则、设置分成比例、查看经营报表、管理用户列表。
核心业务闭环是“找桩 → 扫码 → 鉴权 → 开充 → 计费 → 支付 → 结束”,外加“设备心跳上报 → 状态更新 → 异常告警”这条设备侧的闭环。两条链路缺一不可,很多团队做的时候只盯着用户侧的流程,结果设备状态全是假数据,一到上线就露馅。
1.2 为什么选择uniapp + 微信小程序 + PHP这套组合
技术选型当时也纠结过,最终定下uniapp + PHP是综合考虑了开发成本、团队熟悉度和业务周期三方面因素。
- uniapp负责前端:一套代码同时编译到微信小程序、H5、App。虽然项目标题里只提了微信小程序,但后续大概率要扩展到其他平台,用uniapp可以避免二次开发。尤其是H5版本以后可以嵌入公众号、外部合作渠道,这套红利是原生小程序开发给不了的。
- PHP负责后端:PHP在接口开发效率上有天然优势,CI框架或ThinkPHP框架下,一个标准的JSON接口从路由到响应大概十几分钟就能写完。对于业务逻辑不能算特别复杂的充电桩系统,PHP完全扛得住。我们当时用的ThinkPHP 8,用了它的中间件机制做接口鉴权,非常顺手。
- MySQL负责数据落盘:系统涉及订单、设备、用户等核心数据,MySQL的事务能力在处理支付和订单一致性上至关重要。别为了追求炫技引入重数据库,很多场景杀鸡用牛刀。
这套组合对中小型充电运营商来说,性价比极高,维护成本也低,招人容易。就算后续需要拆分微服务,PHP侧也可以先把充电桩设备网关单独抽离出来,其余部分保持单体架构,渐进演进比一步到位稳得多。
1.3 系统架构的层次划分
我把整个系统分成四层:
- 设备接入层:充电桩通过4G/以太网模块连接到服务端的设备网关,走MQTT或自定义TCP长连接协议,定时上报心跳和充电状态。
- 业务服务层:PHP处理用户鉴权、充电桩扫码绑定、订单生成、计费计算、支付回调对接。
- 数据存储层:MySQL存业务数据,Redis做会话缓存、状态缓存、分布式锁。
- 应用展示层:uniapp编译出的微信小程序用户端,加上一个基于Vue的后台管理界面(也用了uniapp的H5能力)。
这里有个关键设计:设备通讯绝不能和用户业务接口混在一个服务里。我当时把设备消息处理和用户API放在同一个Nginx服务下,结果设备高频心跳导致PHP-FPM进程被打满,用户接口响应直接超时。后来拆出一个单独的设备网关服务才解决。血泪教训,架构上一定要给设备通讯单独留地盘。
2. 数据库建模与核心表设计
2.1 用户、充电桩、订单三张核心表
数据库设计决定系统能走多远。我先列一下核心表结构,都是实际跑过线上流量的。
用户表(user)
| 字段 | 类型 | 说明 |
|---|---|---|
| id | int | 自增主键 |
| openid | varchar(64) | 微信小程序唯一标识 |
| mobile | varchar(20) | 手机号,可为空 |
| balance | decimal(10,2) | 账户余额 |
| status | tinyint | 1正常 2冻结 |
| create_time | datetime | 注册时间 |
openid必须有唯一索引,这是用户在小程序端的身份凭证。余额字段我用的是decimal,坚决不用float,避免浮点数精度问题导致金额对不上。
充电桩表(charging_pile)
| 字段 | 类型 | 说明 |
|---|---|---|
| id | int | 自增主键 |
| pile_code | varchar(32) | 设备编号,二维码内容 |
| lat/lng | decimal(10,6) | 经纬度 |
| status | tinyint | 0离线 1空闲 2充电中 3故障 4维护 |
| power | int | 额定功率 |
| electric_meter | decimal(12,2) | 电表总读数 |
| type | tinyint | 1交流 2直流 |
| update_time | datetime | 最后通讯时间 |
注意status字段是设备运行状态,和充电订单状态要分开。很多新手把充电中状态直接写到设备表,结果多个用户同时操作时状态错乱。正确做法是设备只管上报自身状态,业务层用订单关联。
订单表(charge_order)
| 字段 | 类型 | 说明 |
|---|---|---|
| id | int | 自增主键 |
| order_no | varchar(32) | 订单号,全局唯一 |
| user_id | int | 下单用户 |
| pile_id | int | 充电桩ID |
| start_time | datetime | 充电开始时间 |
| end_time | datetime | 充电结束时间 |
| prepay_amount | decimal(10,2) | 预冻结金额 |
| actual_amount | decimal(10,2) | 实际扣费 |
| status | tinyint | 0待支付 1充电中 2完成 3退款中 4已退款 5异常关闭 |
| pay_status | tinyint | 0未支付 1已支付 |
订单表是业务的核心,我单独设计了两个状态字段。业务状态(status)和支付状态(pay_status)必须分离,否则一旦支付回调异常,你分不清到底是“充完了没扣钱”还是“扣钱了没充完”。
2.2 计费规则设计:按时间还是按电量?
充电计费模式有两种主流方案:按时长计费和按电量计费。
- 按时长计费:适合交流慢充,比如“1元/2小时”。实现简单,对用户透明,但运营方可能亏在电费波动上。
- 按电量计费:适合直流快充,比如“1.5元/度”。需要充电桩带计费电表,充电结束后读取电表读数差来计算。
我在项目里做了双模式兼容。计费规则表(charge_rule)设计如下:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | int | 主键 |
| pile_type | tinyint | 交流/直流 |
| calc_type | tinyint | 1按时间 2按电量 |
| unit_price | decimal(10,2) | 基础单价 |
| step_price | text | 阶梯价格JSON |
| start_time/end_time | time | 生效时段 |
阶梯价格用JSON存储,比如谷电时段和峰电时段不同价格,这种灵活设计比硬编码好改得多。实际计算时,后端根据calc_type读取电表差或时间差,乘以单价得到金额,再预扣/退费。
2.3 Redis在系统中的关键地位
Redis在这套系统里不是可选组件,是必需品。
- 防并发扫码:用户A和用户B同时扫同一个空闲桩,必须保证只有一个能开充成功。用
SET lock_pile_{pileId} 1 EX 5 NX做分布式锁,抢不到锁的直接返回“设备正在充电中”。 - 设备状态缓存:设备心跳高频写入MySQL压力大,先写Redis,定时批量同步到数据库。
- 订单幂等处理:支付回调可能重复推送,用订单号作为Redis键做幂等标记,防止重复发货。
3. 微信小程序端功能实现(基于uniapp)
3.1 扫码开充的完整链路
用户点击小程序里的“扫码充电”,调用微信的uni.scanCode接口,拿到充电桩二维码上的pile_code参数,然后后端做三件事:
- 校验桩存在且状态为“空闲”。
- 检测用户账户余额或信用分是否支持启动。
- 创建待充电订单,冻结预扣金额,向设备网关下发充电指令。
前端的关键代码如下(uniapp中):
async function startCharging() { // 1. 扫描二维码 const scanRes = await uni.scanCode({ onlyFromCamera: true }); const pileCode = scanRes.result; // 二维码内容 // 2. 调用后端开充接口 const res = await uni.request({ url: '/api/charge/start', method: 'POST', data: { pile_code: pileCode, latitude: getApp().globalData.latitude, longitude: getApp().globalData.longitude } }); if (res.data.code === 0) { // 3. 跳转到充电状态页,开始轮询 uni.redirectTo({ url: `/pages/charging/status?orderNo=${res.data.data.order_no}` }); } else { uni.showToast({ title: res.data.msg, icon: 'none' }); } }这里的核心细节是:开充指令必须支持异步响应。你调后端接口,后端只是返回“指令已下发”,真正的启动结果要等设备回传。前端不要傻等,应该进入充电状态页后通过轮询获取最终结果。
3.2 充电状态的实时刷新怎么做
微信小程序没有WebSocket的完美原生支持,虽然现在的基础库已经支持了SocketTask,但很多场景下轮询反而更稳。我的方案是前端每5秒轮询一次订单详情,后端返回时直接查Redis缓存,不会打到数据库。
轮询接口返回的关键字段:
progress:充电进度(电量百分比)power:当前实时功率duration:已充电时长amount:当前预估费用status:充电中/已结束/异常
为避免用户切后台后持续轮询消耗资源,我在onHide生命周期里清掉定时器,onShow时重新拉取一次状态。
3.3 支付环节的坑:预扣款与退款策略
我采用的策略是预冻结 + 多退少补:开始充电时先按规则冻结一个预估金额(比如充4小时的电费),充电结束后按实际计费扣款,剩余部分原路退回。
调起微信支付使用uni.requestPayment:
const payRes = await uni.requestPayment({ provider: 'wxpay', timeStamp: res.data.timeStamp, nonceStr: res.data.nonceStr, package: res.data.package, signType: 'MD5', paySign: res.data.paySign });如果用户微信余额不足或中途取消支付,订单状态回到“待支付”,用户可以在“我的订单”里继续支付。这里要注意:创建订单和发起支付之间是有时间窗的,预扣动作在订单创建时就做了,支付动作可能延迟几分钟。为防止用户开了多个待支付订单,我在后端限制同一用户同时只能存在一个“待支付”或“充电中”的订单。
4. PHP后端接口设计与核心业务实现
4.1 接口风格与统一返回格式
前后端分离的项目最怕接口格式不统一。我在项目里定义了统一的返回结构:
{ "code": 0, "msg": "success", "data": {} }code = 0代表成功,非0代表异常。业务上的所有判断结果都通过code表达,而不是用HTTP状态码来表达业务错误。比如“余额不足”返回code = 10001,“设备离线”返回code = 10002,方便前端做switch分支处理。
PHP侧的基类控制器里定义了成功和失败的返回方法:
protected function success($data = [], $msg = 'success') { return json(['code' => 0, 'msg' => $msg, 'data' => $data]); } protected function error($code, $msg) { return json(['code' => $code, 'msg' => $msg, 'data' => new stdClass()]); }4.2 鉴权体系:小程序登录与Token
微信小程序的登录流程是:前端调用uni.login拿到code,传给后端,后端用code换openid和session_key,再签发自己的token返回给前端。
流程细节:
- 小程序端
uni.login()获取临时code。 - 后端调用微信接口换取
openid。 - 查询数据库,
openid存在则直接登录,不存在则自动注册。 - 生成一个随机
token,缓存在Redis中,有效期7天。 - 前端后续请求都带上
Authorization: Bearer token头。
PHP中间件拦截所有请求(除了login和callbac),校验token有效性:
public function handle($request, \Closure $next) { $token = $request->header('Authorization', ''); $token = str_replace('Bearer ', '', $token); $userId = Redis::get('user_token_' . $token); if (!$userId) { return json(['code' => 1000, 'msg' => '登录已过期'], 401); } $request->userId = $userId; return $next($request); }这个方案简单可靠,不需要引入OAuth那套复杂逻辑。对于小程序这种第一方应用,token放Redis完全够用。
4.3 充电启动的核心时序逻辑
启动充电是系统最核心的接口,涉及分布式锁、订单创建、设备指令下发三个环节。
public function start() { $pileCode = $this->request->param('pile_code'); $userId = $this->request->userId; // 1. 获取充电桩信息 $pile = Pile::where('pile_code', $pileCode)->find(); if (!$pile || $pile->status != 1) { return $this->error(10002, '设备不可用'); } // 2. 分布式锁,防并发 $lock = Redis::set('lock:pile:' . $pile->id, 1, ['nx', 'ex' => 8]); if (!$lock) { return $this->error(10003, '设备正在被使用'); } try { // 3. 创建订单 $order = ChargeOrder::create([ 'order_no' => $this->generateOrderNo(), 'user_id' => $userId, 'pile_id' => $pile->id, 'prepay_amount' => $this->calcPrepay($pile), 'status' => 0, 'pay_status' => 0 ]); // 4. 下发充电指令 $result = DeviceGateway::send($pile->device_sn, 'start_charge', ['order_no' => $order->order_no]); if (!$result) { $order->status = 5; $order->save(); return $this->error(10004, '设备启动失败'); } return $this->success(['order_no' => $order->order_no]); } finally { // 5. 释放锁 Redis::del('lock:pile:' . $pile->id); } }要注意顺序:先锁设备,再建订单,最后下发指令。如果先把指令发给设备,再创建订单,一旦订单创建失败,设备已经开始充电了,会造成“孤儿充电”,后续根本没法计费对账。这也是我在实际项目里重点踩过的坑。
4.4 计费结算与订单关单
充电完成的标志是设备上报“充电结束”事件。设备网关收到事件后,推送MQTT消息到PHP消费进程,PHP执行结算:
public function chargeDone($orderNo, $electricMeterEnd) { $order = ChargeOrder::where('order_no', $orderNo)->find(); if (!$order || $order->status != 1) { return; // 幂等处理,已结算直接返回 } $pile = Pile::find($order->pile_id); $calcType = $pile->type == 2 ? 2 : 1; $duration = time() - strtotime($order->start_time); if ($calcType == 2) { // 按电量计算 $usedPower = $electricMeterEnd - $order->electric_meter_start; $amount = $usedPower * $price; } else { // 按时间计算 $amount = ceil($duration / 3600 * 100) / 100 * $price; } // 更新订单,发起退款 $order->actual_amount = $amount; $order->status = 2; $order->save(); // 多退少补 $refundAmount = $order->prepay_amount - $amount; if ($refundAmount > 0) { WechatPay::refund($order->order_no, $order->prepay_amount * 100, $refundAmount * 100); } }5. 充电桩设备接入与网关通讯
5.1 设备通讯协议的选型
充电桩设备端我遇到过两种主流模式:
- TCP长连接 + 自定义协议:设备主动连接服务器,维持长连接。优点是实时性高、节省资源,缺点是协议需要自行设计,调试复杂。
- MQTT协议:设备作为订阅者/发布者接入MQTT Broker,服务端作为另一个订阅者。缺点是设备端SDK要求高,稳定度依赖网络质量;优点是Broker自带QoS机制,消息可靠性更有保障。
我最终选的方案是设备端走TCP长连接,连接到独立的设备网关服务(PHP + Swoole常驻内存),业务PHP与设备网关通过Redis队列解耦。Swoole的Task进程处理长连接,业务侧只需要操作Redis队列,架构清晰,也不会互相阻塞。
5.2 心跳机制与离线判断
设备每30秒上报一次心跳,包含设备编号、状态、电表读数、温度等字段。心跳数据先写Redis,主服务每5分钟做一次批量落库。
设备离线判断逻辑:如果超过90秒(即3个心跳周期)没有收到心跳,设备状态置为离线,同时结束未关闭的充电订单,防止“充电桩已断线,用户还在被计费”的纠纷。
PHP设备网关处理伪代码:
public function onMessage($server, $fd, $data) { $msg = json_decode($data, true); $deviceSn = $msg['sn']; // 更新设备状态到Redis Redis::hMSet('device_status_' . $deviceSn, [ 'status' => $msg['status'], 'meter' => $msg['meter'], 'heartbeat_time' => time() ]); // 如果是充电结束事件 if ($msg['event'] == 'charge_done') { Redis::lPush('queue:charge_done', json_encode($msg)); } }5.3 远控指令下发的实现细节
服务端要向设备下发指令(开充、关充、重启),路径是:业务PHP → Redis队列 → 设备网关订阅消费 → 通过TCP连接发送给设备。
为了确保指令不丢失,指令数据需要带msg_id,设备收到后回ack。设备网关如果发现超过5秒没收到ack,会重发指令,最多重发3次。重试耗尽仍未成功则上报指令失败,业务侧把订单标记为异常状态。
6. 常见问题与排查技巧实录
6.1 支付回调丢失怎么办
微信支付回调是可能因为网络问题延迟甚至丢失的。我的办法是双重保障:一是正常的回调处理;二是做一个主动查单补偿任务。在订单创建5分钟后如果订单仍处于“待支付”状态,PHP定时任务主动调用微信支付查单接口,以订单维度补齐回调缺失的问题。
实际排查中还发现一个典型问题:回调接口处理逻辑里调用了外部API(比如推送通知),外部API超时导致整体响应超时,微信那边判为发送失败,发起重试。解决方法是回调接口里只更新订单状态和释放锁,耗时操作全部丢到队列异步执行。
6.2 用户显示“充电中”但设备实际已断电
这种数据不一致多半是设备上报的结束事件没被正确处理。可能原因:设备网关消费进程崩溃,消息丢失;或MQTT的QoS设置为0,网络抖动丢包。
排查步骤:
- 先看设备心跳的最后时间,确认设备是否在线。
- 查看Redis队列中是否有积压的
charge_done消息。 - 检查设备侧日志,确认设备是否确实上报了结束信息。
- 如果消息确实丢了,利用定时任务扫描“充电中”状态超过24小时的订单,强制拉闸关单。
这个兜底逻辑非常重要,线上运营绝不能只依赖单一事件通道。
6.3 并发扣款导致余额负数
用户账户余额扣费时,如果直接用balance -= amount然后保存,高并发下会出现负数。正确做法是用乐观锁或原子扣减:
$isOk = User::where('id', $userId) ->where('balance', '>=', $amount) ->dec('balance', $amount) ->update();where('balance', '>=', $amount)条件不满足时,update影响行数为0,以此判断余额不足。这是我强烈建议实行的写法,能根除并发超扣问题。
6.4 排查速查表
| 现象 | 可能原因 | 排查思路 |
|---|---|---|
| 扫码后提示设备不可用 | 设备状态不是“空闲” | 查设备心跳、Redis缓存状态 |
| 支付成功但订单仍是待支付 | 回调未到/丢失 | 查支付回调日志、查单补偿任务是否执行 |
| 订单显示充电中但用户说没充上 | 设备上报结束事件丢失 | 查设备在线状态、查队列积压、强制关单任务 |
| 充电结束但金额不对 | 计费规则配置错误 | 核对充电桩类型、rule表配置、电表读数 |
| 接口响应慢 | PHP-FPM被打满 | 检查设备网关是否与业务接口混部,查看慢查询日志 |
最后说点实操体会
这套系统从需求设计到上线,我们团队前后花了大概两个月。再让我重做一遍,我会在设备协议设计上多花一倍时间,把异常场景的模拟测试做透,尤其是“设备上报充电中但实际没通电”“用户先拔枪再调结束”这些边界情况。很多线上问题看着是逻辑错误,归根结底是通讯时序问题。
最后分享一个小技巧:开发阶段把设备模拟器写出来,用PHP脚本模拟100台设备并发上报心跳和结束事件,压出后端系统的处理瓶颈,这比等真机接入后排错效率高太多。这套系统后续还可以扩展的功能也很多,比如多运营商入驻、动态定价、碳积分体系、充电桩地图导航,本质上都是在现有架构上做增量开发,底子打好了,扩展只是时间问题。