微信小程序 + PHP + uniapp 共享充电桩系统实战复盘做共享充电桩系统这个项目,前后折腾了大概两个月。从最开始的产品原型,到小程序前端、PHP后端、管理后台,再到真机调试和上线,整个链路走下来踩了不少坑,也沉淀了一些值得记录的经验。这篇文章就把这个项目的完整实现过程做一个复盘,从需求拆解到数据库设计,从接口开发到小程序端实现,把核心环节和关键代码都梳理出来,希望对正在做同类项目的开发者有所帮助。先交代一下项目背景。这套系统面向的场景是小区、园区、商超停车场的新能源汽车充电服务,用户通过微信小程序扫码启动充电、查看充电状态、在线支付,运营方通过管理后台管理充电桩设备、设置计费策略、查看收益报表。技术栈是微信小程序 + uniapp + PHP + MySQL,小程序端用 uniapp 开发以便后续可以扩展 App 端,后端用 PHP 提供接口服务,管理后台用 Web 页面实现。1. 项目整体设计与技术选型1.1 核心需求拆解共享充电桩系统表面上看是一个“扫码充电”的工具,但实际拆解下来,核心链路比想象中复杂不少。我从用户端和管理端两个维度做了需求拆解。用户端(微信小程序)需要实现的功能有:地图找桩、充电桩详情展示、扫码充电、充电状态实时监控、充电结束结算支付、历史订单查询、余额充值、优惠券管理等。这里面的关键链路是“扫码 -> 启动充电 -> 实时计费 -> 结束充电 -> 支付结算”,每个环节都有状态流转和数据一致性问题。管理端(Web 后台)需要实现的功能有:充电桩设备管理(添加、编辑、启停、故障标记)、计费策略配置(按时段、按功率、按度数等多维度计费)、订单管理(订单查询、退款处理、异常订单人工干预)、用户管理(用户信息、余额变动记录)、收益统计报表、操作员账号管理等。除了这两个核心端,还要考虑设备端的通信问题。充电桩本身是一个 IoT 设备,需要联网上报状态。但因为是共享充电桩场景,设备端和云端的交互策略决定了整个系统的实时性表现。1.2 技术选型背后的考虑这个项目为什么选 PHP?主要原因是团队现有技术储备和服务器部署成本。PHP 在中小型项目中开发效率高,生态成熟,部署门槛低,适合这种需要快速上线验证的业务场景。框架选了 thinkphp 6,主要是因为它的目录结构清晰、ORM 好用、有现成的多应用模式,开发效率比较有保障。小程序端为什么用 uniapp?核心目标是一个代码库同时支持微信小程序和将来的 App 端。如果用原生微信小程序开发,后续要做 App 端就得另起炉灶,维护成本就翻倍了。uniapp 的编译能力可以把同一套 Vue 语法代码编译到多个平台,而且它内置了 uni.request、uni.login 等跨端 API,对这类项目来说性价比很高。数据库选型 MySQL 5.7,原因也很朴素:云服务器自带环境里最成熟的搭配,配合 thinkphp 的 ORM,开发体验和运行稳定性都有保证。考虑到充电桩系统的订单量级(一个小区几十台桩,一天几百单),MySQL 完全能扛得住。1.3 系统架构的层次划分整个系统按三层来组织:设备接入层、业务服务层、应用展示层。设备接入层负责和充电桩硬件通信。这里有一个关键设计决策:充电桩和服务器之间的通信协议,是走 HTTP 轮询还是走 WebSocket 长连接?我从实际落地角度考虑,选了 HTTP 轮询为主、WebSocket 辅助的方案。充电桩每隔一定时间主动上报状态,服务器下发指令时通过接口返回待执行任务。原因很简单:HTTP 实现简单、调试方便、部署后不容易因为长连接断开导致掉线问题。具体轮询间隔可以根据设备数量调整,我从实际项目经验看,单桩状态下 5 秒轮询一次比较合理,状态实时性可接受,服务器压力也可控。业务服务层就是 PHP 后端提供的接口集合,涵盖用户认证、充电桩管理、订单交易、支付回调、余额变动等模块。这里我按业务域拆分了多个控制器,每个控制器负责一类功能,接口统一返回 JSON 格式,字段规范为 code/message/data。应用展示层是用户通过微信小程序看到的界面和运营方通过管理后台看到的界面。小程序端以地图找桩为核心入口,接入了微信地图组件;管理后台用 iframe 方式嵌入了简单的 HTML 表格页面,没有引入太重的前端框架。2. 数据库设计:一张表对应一个业务动作2.1 数据表结构总览数据库设计是整个系统的基础,我花了比较多的时间来梳理表结构和字段设计。整个系统一共设计了 12 张核心表,每张表对应一类业务实体。列一下主要的数据表:表名用途核心字段user用户表openid、手机号、余额、积分charge_pile充电桩表桩编号、状态、功率、地址、经纬度charge_order充电订单表订单号、用户ID、桩ID、开始时间、结束时间、电量、金额、状态charge_record充电过程记录表订单ID、实时电压、电流、功率、时间戳balance_log余额变动日志表用户ID、变动金额、变动类型、订单关联coupon优惠券表面额、使用门槛、有效期user_coupon用户优惠券表用户ID、优惠券ID、状态charge_strategy计费策略表策略名称、计价方式、电价、服务费recharge_order充值订单表用户ID、充值金额、赠送金额、支付状态device_status_log设备状态日志表桩ID、状态码、描述、上报时间feedback用户反馈表用户ID、反馈内容、图片、处理状态admin_user管理端账号表用户名、密码、角色2.2 数据一致性的几个关键设计订单金额计算一致性。充电订单涉及两个关键金额:预估金额和实际扣费金额。我的处理方式是:充电启动时不做预付冻结,充电过程中实时计算当前费用并在用户端展示,充电结束生成最终账单,再调用支付接口扣款。这种方式的好处是用户体验好(不用先充钱再退款),代价是必须保证结束充电时扣款成功,如果用户余额不足则需要进入欠费处理流程。充电桩状态与订单状态的联动。充电桩有“空闲、充电中、离线、故障、预约中”五种状态,订单有“充电中、已完成、已取消、异常终止”四种状态。我专门做了一个状态机表记录允许的状态流转路径,在更新状态时先做合法性校验。比如“空闲”状态桩不能直接生成“充电中”状态的订单(必须先启动充电指令),已完成的订单不能再次发起取消操作。余额变动必须走流水表。用户余额涉及充值、消费、退款、管理员调整四种变动来源,每一笔变动都必须写入 balance_log 表。这样做账目清晰,也方便排查问题。实际开发中我还遇到过并发扣款的问题,后面的章节会详细说。2.3 计费策略与动态价格计算计费策略单独设计了一张表,这是充电桩系统比较核心的业务逻辑。每个充电桩可以绑定一个计费策略,也可以整站设置默认策略。计费方式有三种:按电量计费(元/度)、按时间计费(元/分钟)、按功率分段计费。实际项目里最常用的是“基础电价 + 服务费”的组合模式。我在充电桩表里冗余了一个“当前策略ID”字段,而不是每次下单都去关联查找。这样做的原因很实际:策略可能会调整,但已经开始的订单要按启动时的策略计费,所以订单表里还需要保存一份启动时的单价快照。订单表里的 price_snapshot 字段存储 JSON 格式的计费参数,这样即使后续修改了策略,历史订单的结算也不受影响。充电费用计算公式大致是:// 基础费用 = 电量 + 服务费 // 时段费用 = 基础电价 * 电量 + 服务费单价 * 充电时长 $energy_cost = $energy * $strategy['energy_price']; $service_cost = $duration_minutes * $strategy['service_price_per_min']; $total = $energy_cost + $service_cost;实际业务中还经常有“峰谷电价”“闲时折扣”“满减优惠”等需求,这些可以基于这个基础公式做策略扩展。3. PHP 后端接口设计与核心逻辑实现3.1 接口规划与返回格式约定整个后端接口按模块划分,一共规划了 9 个控制器:用户模块:login(微信登录授权)、getUserInfo(获取用户信息)、recharge(充值)、useCoupon(使用优惠券)充电桩模块:getPileList(获取桩列表)、getPileDetail(获取桩详情)、scanStartCharge(扫码启动充电)、stopCharge(结束充电)订单模块:getOrderList(获取订单列表)、getOrderDetail(订单详情)、cancelOrder(取消订单)、refund(退款申请)支付模块:paymentCallback(支付回调)、rechargeCallback(充值回调)、balancePay(余额支付)管理后台模块:pileAdd(添加充电桩)、pileEdit(编辑充电桩)、pileDelete(删除充电桩)、getStatistics(收益统计)。接口统一返回 JSON 格式:{ "code": 0, "message": "success", "data": {} }code 为 0 表示成功,非 0 表示业务异常。消息码按模块分段:1xxx 是用户模块,2xxx 是充电桩模块,3xxx 是订单模块。这样做的好处是前端可以根据 code 直接判断业务类型,后端排查问题时通过错误码也能迅速定位模块。3.2 微信登录与用户身份绑定微信小程序登录流程和传统 Web 登录不同,需要走微信官方的 code2session 接口获取用户的 openid。在实际开发中我踩过一个坑:一开始在服务端用 code 换取 openid 时只保存了 openid,导致用户换设备重新登录后无法关联之前的订单记录。后来才明白 openid 是用户在小程序维度下的唯一标识,同一个用户在不同的公众号/小程序下的 openid 是不同的,所以前端在 wx.login 拿到 code 后,后端用 code 调微信接口换取 openid 和 session_key,用 openid 作为用户的业务唯一标识。服务端核心逻辑:public function login() { $code = $this->request->param('code'); $appid = config('wechat.mini_appid'); $secret = config('wechat.mini_secret'); $url = "https://api.weixin.qq.com/sns/jscode2session?appid={$appid}&secret={$secret}&js_code={$code}&grant_type=authorization_code"; $result = file_get_contents($url); $data = json_decode($result, true); // 用 openid 查用户表,不存在则创建 $user = UserModel::where('openid', $data['openid'])->find(); if (!$user) { $user = UserModel::create([ 'openid' => $data['openid'], 'nickname' => '微信用户' . mt_rand(1000, 9999), 'balance' => 0, 'create_time' => time() ]); } // 生成登录态 token $token = md5($data['openid'] . time() . mt_rand(100000, 999999)); cache('user_token_' . $token, $user['id'], 86400); return json(['code' => 0, 'message' => 'ok', 'data' => ['token' => $token, 'user_info' => $user]]); }这里生成 token 后我用 thinkphp 的 cache 存了 24 小时有效期的用户 ID,后续接口通过请求头里的 token 获取当前用户。用 cache 存登录态的好处是有效期好控制,比直接往客户端写用户 ID 安全得多。3.3 扫码启动充电的状态流转扫码启动充电是整个系统的主链路,也是最核心的接口。用户扫充电桩上的二维码,小程序会带着桩编号跳转到充电详情页。这时候前端会调用 scanStartCharge 接口,接口内部做以下处理:校验桩是否存在且状态为“空闲”。如果桩在充电中,直接返回“设备占用中”的提示。如果桩处于离线或故障状态,返回对应提示。校验用户是否还有未完成的充电订单。如果用户有充电中的订单,不允许启动新的充电任务。校验用户账户是否可用。比如用户被拉黑或账号异常,不允许充电。生成订单记录。订单状态设为“充电中”,启动时间取服务器当前时间。下发启动指令。这一步我用了 mqtt 方案。项目里假设后端通过 MQTT 给设备下发指令,设备收到指令后闭合继电器,充电开始。实际项目中如果桩的联网方案是 MQTT 协议,这一步就在代码里调用 MQTT 的发布接口。如果桩是 HTTP 轮询方案,则把“待启动”指令写入指令表,设备下次上报时获取。下面是这台桩的状态机迁移逻辑:public function scanStartCharge() { $pile_id = $this->request->param('pile_id'); $user_id = $this->getUserId(); // 查看充电桩状态 $pile = ChargePileModel::where('id', $pile_id)->find(); if (!$pile) { return json(['code' => 2001, 'message' => '充电桩不存在']); } if ($pile['status'] != 'idle') { return json(['code' => 2002, 'message' => '当前设备不可用']); } // 查看用户是否有充电中的订单 $exist = ChargeOrderModel::where('user_id', $user_id) ->whereIn('status', ['charging', 'pending']) ->find(); if ($exist) { return json(['code' => 1001, 'message' => '您有充电中的订单,请先结束']); } // 生成订单 $order_no = $this->generateOrderNo(); $strategy = ChargeStrategyModel::where('id', $pile['strategy_id'])->find(); $order = ChargeOrderModel::create([ 'order_no' => $order_no, 'user_id' => $user_id, 'pile_id' => $pile_id, 'start_time' => time(), 'status' => 'charging', 'price_snapshot' => json_encode($strategy), ]); // 更新桩状态 $pile->save(['status' => 'charging']); // 通过 MQTT 下发启动指令(或者写入待执行指令表) // $this->mqttPublish('pile/' . $pile_id, ['cmd' => 'start']); return json(['code' => 0, 'message' => 'ok', 'data' => ['order_id' => $order['id'], 'order_no' => $order_no]]); }这里有几个细节要注意。第一,状态更新操作必须是有条件的原子更新,不能用“先查再改”这种两步操作,否则并发请求可能导致同一台桩被多次启动,这一步可以用 thinkphp 的 update 方法配合条件判断来实现。第二,生成订单和修改桩状态要放在一个事务里,如果有一半成功一半失败,整体回滚。第三,如果桩的控制是通过第三方云平台(如阿里云 IoT 或者某充电桩厂商的云接口),那这里就是调用第三方接口,要考虑第三方接口超时或者失败的兜底策略。3.4 实时计费逻辑与充电结束扣费充电过程中,设备会定期上报功率和电量累计值。我在 charge_record 表里记录每次上报的数据,同时用钩子函数更新订单表中的实时电量。这个方案的好处是:即使主业务接口挂了,设备上报数据仍然持续落库,对账时有据可查。充电结束有两种触发方式:用户在小程序端主动点击停止,或者设备检测到车辆充满自动停止。我分别实现了两个接口,但核心逻辑是同一个:根据订单 id 查询充电记录,计算出最终电量和时长,然后结算费用。费用计算有一个容易踩坑的点:充电开始时桩上显示的是一个预估金额,实际结束时按真实电量和时长计算,两者可能有差异。所以订单支付时直接按最终金额扣款,用户看到的应付金额以最终账单为准。如果用户余额不足,订单状态变为“待补缴”,这种订单可以继续获取充电记录,但不能发起新充电任务。附上结算逻辑的代码框架:public function settleOrder($order_id) { $order = ChargeOrderModel::where('id', $order_id)->find(); if ($order['status'] != 'charging') { return false; } $strategy = json_decode($order['price_snapshot'], true); // 查询累计充电记录 $record = ChargeRecordModel::where('order_id', $order_id)->order('id desc')->find(); $total_energy = $record['total_energy']; // 设备累计电量 $duration = ($record['create_time'] - $order['start_time']) / 60; // 分钟 // 计费 $energy_cost = round($total_energy * $strategy['energy_price'], 2); $service_cost = round($duration * $strategy['service_price_per_min'], 2); $total_fee = round($energy_cost + $service_cost, 2); // 检查余额扣款 $user = UserModel::where('id', $order['user_id'])->find(); if ($user['balance'] >= $total_fee) { UserModel::where('id', $user['id']) ->where('balance', $user['balance']) ->dec('balance', $total_fee) ->update(); $order->save([ 'end_time' => time(), 'total_energy' => $total_energy, 'total_fee' => $total_fee, 'status' => 'completed', 'pay_status' => 'paid' ]); BalanceLogModel::create([ 'user_id' => $user['id'], 'change_amount' => -$total_fee, 'type' => 'consume', 'order_id' => $order['id'], 'create_time' => time() ]); } else { $order->save([ 'end_time' => time(), 'total_energy' => $total_energy, 'total_fee' => $total_fee, 'status' => 'debt', 'pay_status' => 'unpaid' ]); } // 设备置为空闲 ChargePileModel::where('id', $order['pile_id'])->save(['status' => 'idle']); return true; }3.5 微信支付接入与回调处理支付模块接入的是微信支付的小程序支付(JSAPI 支付)。流程不复杂,但有几个细节容易踩坑。统一下单接口要在后端生成预支付订单,返回 prepay_id,小程序端再调 wx.requestPayment 拉起支付。签名算法是 MD5 或 HMAC-SHA256,我这里用了 HMAC-SHA256。回调处理的逻辑我单独拿出来说一下。微信支付回调会以 POST 方式推送到我们配置的通知 URL,携带加密的支付结果数据。开发时一定要验证签名和金额,防止伪造回调。而且回调必须返回 success/fail,否则微信会反复重试通知。public function paymentCallback() { $input = file_get_contents('php://input'); $result = $this->decryptCallback($input); if ($result['return_code'] == 'SUCCESS') { $out_trade_no = $result['out_trade_no']; $order = RechargeOrderModel::where('order_no', $out_trade_no)->find(); if ($order['status'] == 'unpaid') { // 更新充值订单状态 $order->save(['status' => 'paid', 'pay_time' => time()]); // 给用户加余额 UserModel::where('id', $order['user_id'])->inc('balance', $order['amount'])->update(); BalanceLogModel::create([ 'user_id' => $order['user_id'], 'change_amount' => $order['amount'], 'type' => 'recharge', 'order_id' => $order['id'], 'create_time' => time() ]); } return 'success'; } return 'fail'; }一开始做回调时我踩了一个坑:用户在充值页面点击支付后,充值订单已经生成了,但回调进来后我直接给用户加余额,结果用户点击多次支付按钮导致生成了多个充值订单,回调处理时没有对同一订单做幂等判断,导致余额加了两遍。后来加了“回调处理前检查订单状态是否已 paid”的判断,这个问题就解决了。4. uniapp 小程序端实现要点4.1 地图找桩:把经纬度玩明白小程序端的地图找桩功能,用的是微信小程序的 map 组件。后端返回充电桩列表时,每个桩都带有经纬度坐标和距离信息,前端把坐标转换成 map 上的 marker 标记,用户点击 marker 可以查看桩的详情。这里有一个精度问题要处理:微信小程序的 getLocation 接口获取的是当前用户的位置坐标,但如果没有做坐标转换,直接用 GPS 坐标和腾讯地图坐标会有偏差。正确做法是:前端拿到 GPS 坐标后,调腾讯地图的坐标转换接口,转换成腾讯地图的坐标体系,再传给后端做距离计算和地图渲染。否则用户在地图上看到充电桩的位置和实际位置会偏移几十米,在停车场里找桩时就会很困惑。后端返回桩列表时,需要根据用户坐标计算距离并排序。距离计算直接用球面距离公式(Haversine)就可以,不需要引入地理位置搜索引擎,量级几十公里内精度足够:public function getNearbyPiles() { $lat = $this->request->param('lat'); $lng = $this->request->param('lng'); $radius = $this->request->param('radius', 5); // 默认5km $rows = Db::query( "SELECT *, ROUND(6371 * 2 * ASIN(SQRT(POWER(SIN(({$lat} - lat) * PI() / 180 / 2), 2) + COS({$lat} * PI() / 180) * COS(lat * PI() / 180) * POWER(SIN(({$lng} - lng) * PI() / 180 / 2), 2))), 2) AS distance FROM charge_pile WHERE status IN ('idle', 'charging') HAVING distance <= {$radius} ORDER BY distance ASC" ); return json(['code' => 0, 'message' => 'ok', 'data' => $rows]); }用原生 SQL 计算距离的写法在数据量大时性能会下降,但几百个桩的场景完全没问题。如果后续扩展到大范围运营,可以换成 geohash 编码方案或者使用位置检索服务。4.2 扫码页面与启动充电的交互小程序里的扫码主要用 uni.scanCode 实现,扫出来的结果是充电桩上的二维码内容。二维码内容我约定为纯数字桩编号,例如 https://domain/pile/1003 这种 URL 也可以,但小程序端解析时统一取最后一段作为桩编号。这样即使二维码需要重新制作,只需要保证桩编号唯一即可,不需要改后端代码。扫码后的流程是:解析桩编号 -> 调后端接口查询桩状态 -> 跳转充电详情页。这里有一个体验细节:查询桩状态是异步的,如果桩状态是“离线”或“故障”,要给出明确的提示,同时把“启动充电”按钮置灰,避免用户反复点击产生无意义的请求。启动充电后,页面要进入实时监控状态。我用 setInterval 每 5 秒调一次“查询实时充电状态”的接口,刷新展示当前的电量、功率、充电时长和预计费用。同时为了避免用户切到后台导致定时器失效,在 onShow 生命周期里重新开启轮询,在 onHide 生命周期里清理定时器。这个细节一开始没处理好,用户切出去再回来,页面上还是几秒前的数据,后来检查才发现是定时器被清掉了没恢复。实时充电状态接口:public function getChargingStatus() { $order_id = $this->request->param('order_id'); $order = ChargeOrderModel::where('id', $order_id)->find(); if (!$order || $order['status'] != 'charging') { return json(['code' => 0, 'message' => 'ok', 'data' => ['status' => 'finished']]); } $record = ChargeRecordModel::where('order_id', $order_id)->order('id desc')->find(); $strategy = json_decode($order['price_snapshot'], true); $duration = ($record['create_time'] - $order['start_time']) / 60; $energy_cost = round($record['total_energy'] * $strategy['energy_price'], 2); $service_cost = round($duration * $strategy['service_price_per_min'], 2); $total_fee = round($energy_cost + $service_cost, 2); return json(['code' => 0, 'message' => 'ok', 'data' => [ 'status' => 'charging', 'voltage' => $record['voltage'], 'current' => $record['current'], 'power' => $record['power'], 'total_energy' => $record['total_energy'], 'duration' => round($duration, 1), 'total_fee' => $total_fee ]]); }4.3 订单列表与状态展示的映射关系订单列表是用户最常看的功能之一。前端需要根据订单不同状态展示不同按钮:充电中订单显示“结束充电”和“查看详情”,已完成订单显示“查看详情”和“开发票”,待补缴订单显示“去支付”。这里有一个容易忽视的小细节:订单状态字段在后端是英文枚举(charging/completed/cancelled/debt),前端展示时一定要做映射转换,不能让用户看到英文状态。uniapp 端的映射逻辑:statusMap() { const map = { pending: '待启动', charging: '充电中', completed: '已完成', cancelled: '已取消', debt: '待补缴', abnormal: '异常终止' }; return map[this.orderInfo.status] || '未知状态'; }4.4 微信登录与用户授权的处理策略小程序的用户授权逻辑是个老生常谈的问题。一方面微信官方收紧了 getUserProfile 接口的使用范围,另一方面用户点击拒绝授权的情况很常见。我这里采用的最小授权策略是:用户进入小程序后先调 wx.login 静默获取 code,后端换取 openid 完成注册登录,不要求用户主动授权手机号。只有当用户要使用充值、开发票等功能时才去请求手机号授权。为什么这样设计?因为充电桩的核心场景是“扫码充电”,用户到充电桩前是带着明确目的的,此时最应该做的是让用户快速完成充电,而不是在入口处被授权弹窗拦住。把手机号授权放在后续环节,对转化率的影响会小很多。这个策略实测下来用户整个充电流程的跳出率明显比一开始就强制授权低。5. 管理后台与设备关联功能5.1 后台功能清单与页面设计管理后台我用了 iframe 内嵌的方式,简单但真正管用。后台功能模块按操作频率和重要度排序:订单管理是日常使用最多的,充电桩管理次之,收益统计和用户管理是每周看的,计费策略配置是低频操作。订单管理页面我做了几个实用的筛选条件:按订单号搜索、按用户 ID 搜索、按桩编号搜索、按状态筛选、按时间范围筛选。表格展示列包含订单号、用户 ID、桩编号、充电时长、电量、金额、状态、支付状态、操作按钮。这个页面日均处理量不大,但要求快进快出,所以我把筛选和翻页做到了同一个表格工具栏里。5.2 设备状态上报与故障告警充电桩设备的接入方式在第一节说过:HTTP 轮询为主。设备每次上报状态时带上桩编号、当前状态码、电压、电流、功率、累计电量、故障码等字段。后端收到上报后返回一个 JSON,里面含待执行指令列表(如启动、停止、重启等)。设备上报接口:public function deviceReport() { $device_id = $this->request->param('device_id'); $data = $this->request->param(); // 记录上报数据 DeviceStatusLogModel::create([ 'pile_id' => $device_id, 'status_code' => $data['status'], 'voltage' => $data['voltage'], 'current' => $data['current'], 'power' => $data['power'], 'total_energy' => $data['energy'], 'fault_code' => $data['fault_code'] ?? '', 'report_time' => time() ]); // 更新桩状态 $pile = ChargePileModel::where('device_id', $device_id)->find(); if ($pile) { $pile->save([ 'status' => $data['status'], 'last_report_time' => time(), 'voltage' => $data['voltage'], 'current' => $data['current'], 'power' => $data['power'] ]); } // 查询待办指令 $commands = DeviceCommandModel::where('device_id', $device_id) ->where('status', 'pending') ->select(); $cmd_list = []; foreach ($commands as $cmd) { $cmd_list[] = $cmd['command']; $cmd->save(['status' => 'sent']); } return json(['code' => 0, 'message' => 'ok', 'data' => ['commands' => $cmd_list]]); }故障告警我用了一个简单的方案:在后台的充电桩管理页面里特殊标红显示“长时间未上报”的设备(超过 60 秒未上报)。管理后台首页头部显示设备在线率、告警数量、今日订单数、今日营收等概览数据,运营人员每天开电脑扫一眼就能知道当前整体运行状况。5.3 收益统计与报表查询收益统计报表的核心是订单表的数据聚合。我按天、按月两个维度做了统计接口,统计指标包括订单总数、充电量(度)、订单金额、实收金额、退款金额、优惠金额。运营方最关注的是“实收金额”和“充电量”这两个指标,因为对应的就是实际营收和闲置率评估。按月统计的 SQL:SELECT DATE_FORMAT(FROM_UNIXTIME(start_time), '%Y-%m') AS month, COUNT(*) AS order_count, SUM(total_energy) AS total_energy, SUM(total_fee) AS total_fee, SUM(refund_fee) AS refund_fee FROM charge_order WHERE status IN ('completed') GROUP BY DATE_FORMAT(FROM_UNIXTIME(start_time), '%Y-%m') ORDER BY month DESC报表页面用简单的柱状图呈现,图表库我用的是 ECharts 的 CDN,直接引入不需要复杂配置。后端接口返回聚合数据,前端画柱状图,开发成本很低但效果够用。6. 常见问题与排查技巧实录6.1 并发冲突:同一台桩被多人同时扫码上线后遇到最严重的一次故障是:同一台空闲桩被两个用户同时扫码,两个启动请求几乎同时到达后端,都通过了查询桩状态的校验,然后各自生成了充电订单,导致同一台桩出现了两笔订单,实际通电的只有一笔。这个问题的根因就是前面提到的“查询后操作”没有加原子性保护。解决方案有两种:一种是在 SQL 层做更新,比如执行UPDATE charge_pile SET status='charging' WHERE id=? AND status='idle',更新影响行数为 0 说明状态已经被其他请求改掉了,这单就不能下;另一种是用 Redis 的 SETNX 命令做分布式锁,取到锁的请求才能继续走下单流程。介意维护 Redis 的话就在 MySQL 层面解决,加一个条件更新就行。我当时选的方案是条件更新,简单可靠:$updated = Db::name('charge_pile') ->where('id', $pile_id) ->where('status', 'idle') ->update(['status' => 'charging']); if (!$updated) { return json(['code' => 2002, 'message' => '设备已被使用,请选择其他充电桩']); }把“查状态”和“改状态”合并成了一个原子操作,问题自然就解决了。6.2 MQTT 掉线导致设备指令下发失败设备是通过 MQTT 接入的,实际运行中遇到过设备网络波动导致 MQTT 连接断开,后端下发的启动指令没有被设备收到,用户扫码后一直卡在“充电中”状态但设备根本没有输出电流。排查时发现,因为 MQTT 连接断开后,broker 里的消息会被保留到重连后重新推送,但业务代码里没有对“指令是否被设备回复确认”做判断,所以没能及时发现这次启动是失败的。解决思路是加了一层指令确认机制:设备收到指令并执行成功后,会上报一条“执行确认”状态,后端收到确认后才把指令状态更新为已完成;如果超过一定时间没有收到确认,指令状态变为超时,订单自动进入“异常”状态,同时客服可以在后台手动重试或取消订单。时间关系,这个机制我用定时器轮询的方式实现,每 10 秒扫描一次超时指令,目前运行下来效果还可以。6.3 回调重复处理导致用户余额错误微信支付的回调有重试机制,有时同一个支付结果会被推送多次。如果回调处理逻辑没有做幂等校验,用户充值 100 元可能被入账两次,变成余额增加 200 元。排查这类问题的方法很简单:检查 balance_log 表里是否有同一订单号出现多笔入账记录。修复方式是在回调处理入口加校验:查询充值订单的状态,如果已经是 paid 了,直接返回 success 不再处理。除了支付回调,其他所有涉及“状态变更+金额变动”的接口都应该遵守这个原则:先锁状态,再改数据,最后记流水。6.4 坐标偏移导致找桩定位不准地图找桩功能刚上线时,用户反馈充电桩位置在地图上显示不准,有小几十米的偏移。排查后确定是坐标系不一致的问题:设备上报的经纬度是 GPS 坐标(WGS-84),微信小程序的地图组件用的是腾讯坐标系(GCJ-02),两者之间存在非线性偏移。解决方案是在后端转换坐标系,用一个标准的 WGS-84 转 GCJ-02 函数处理后再返回给前端。这个转换算法是公开的,我直接把转换函数集成到了接口层,用户端拿到的坐标直接就是腾讯坐标系,小程序端不再需要做额外转换。如果是接入百度地图,则需要用 BD-09 坐标体系,转换函数对应不同,开发时要确认清楚地图组件用的是什么坐标系。6.5 充电记录上报延迟导致的计费偏差设备在充电过程中是每 10 秒上报一次充电数据,但如果设备网络波动导致数据上报延迟,后端统计充电电量时可能拿不到最新的累计电量,导致结算金额比实际偏低。处理方式是增加补报机制:设备重新连接后,主动上报最后一次的累计电量,后端把订单的 total_energy 更新为当前的最大值。同时结算订单时,不依赖最后一次上报的实时数据,而是取该订单所有充电记录中累计电量的最大值。这样即使单次上报有丢失,累计值也不会少算。6.6 小程序端权限申请的合规适配小程序上线前要注意隐私接口的权限申请。地图功能涉及地理位置接口,充电启动涉及扫码接口,这些都要在微信公众平台的“接口设置”里完成权限配置。提审时如果隐私政策里没有明确说明位置信息和充电记录的采集用途,可能会被驳回。这方面的坑我补充一句:不要等提审被驳回才去补隐私政策,开发阶段就应该在小程序内写好用户隐私保护指引,并把实际用到的接口列全。后面每次新增一个需要用户授权的接口,都要同步更新隐私说明文档,否则版本审核很容易被卡。7. 项目上线后的稳定性优化与数据复盘7.1 服务器架构与性能监控这个项目上线初期用的是单台云服务器,配置是 2 核 4G,部署了 PHP 环境、MySQL、Redis、MQTT 服务。日均几千个请求的量级下,这个配置完全能扛住。但考虑到设备上报频率(每 5 秒一次,一台桩一天上报 17280 条),加上小程序端的轮询请求,MySQL 的并发写还是有一定压力。后期我在项目里做了一处关键优化:设备上报数据不再直接写订单关联表,而是先进 Redis 队列,再由一个消费脚本异步批量写入数据库。这样削峰填谷之后,MySQL 的压力明显下降。但要注意 Redis 队列消费脚本要处理单点故障风险。我用了简单的计划任务每分钟拉取一次数据批量入库,即使脚本挂了重启后能靠时间戳增量补拉,数据不丢。7.2 数据复盘中发现的优化点项目运行一个月后,我从订单数据里发现了几类值得优化的现象:夜间闲时订单占比很低。小区场景下大部分用户是下班回家后充电,夜间 0 点到 6 点的充电量非常少,但桩依然占着位置没有充电。后来我在计费策略里加了“闲时优惠时段”,通过价格杠杆把部分订单引导到夜间,提高了桩的利用率。异常订单占比不高但影响体验。主要是启动失败和提前拔枪两类。启动失败大多是因为设备离线或指令下发超时,我加了下发指令前先检查设备心跳时间的逻辑,超过 2 分钟没有上报的设备直接提示用户不可用,减少无效的启动尝试。用户充值后再充电的比例在提升。刚开始很多用户是订单完成后余额不足变成待补缴状态,后来我在充电完成页面增加了“余额不足时可一键充值”的引导,待补缴订单数量降了将近一半。7.3 关于设备端联动的一些经验补充分享有些充电桩设备厂商提供的是云平台接入接口,这种情况下设备上报的数据会先到厂商云,我们通过云接口去拉取订单相关数据。这种模式的优点是省去了自己对接硬件的成本,但缺点是实时性和数据完整性依赖厂商接口的稳定性。如果设备支持本地局域网协议,可以考虑做边缘网关,在本地完成充电管理逻辑后,再把结果同步到云端。这样断网时充电功能不受影响,恢复联网后再补报数据。不过这个方案对开发能力和硬件能力都有要求,适合团队有嵌入式基础的情况,我这次的项目里还用不上这层方案。8. 一些务实建议和避坑清单复盘整个项目,有几个问题如果一开始就注意,能少走弯路。接口层要做好参数校验。小程序端传过来的参数不一定合法,比如订单 ID、桩编号如果出现非法字符,不应该直接进 SQL 查询,必须做类型转换和格式校验,防止产生不必要的脏数据。事务边界要清晰。涉及金额变动、订单状态变更、设备状态变更的操作,一定要包在事务里,并且按照“先查后锁再改”的顺序执行。如果项目里已经引入了 Redis,可以用分布式锁处理高并发场景,但大多数情况下 MySQL 的条件更新就足够。日志记录要全面。设备上报日志、支付回调日志、指令下发日志、异常订单日志,这四类日志我建议全部保留,而且日志里要有足够的上下文信息(订单号、设备编号、状态码、耗时等),方便线上问题快速定位。我在项目里专门封装了一个简单的日志方法,每个关键节点都做了记录,翻日志排查问题时确实省了很多时间。前后端联调时要有统一的调试环境。接口文档用 swagger 或 yapi 维护,接口变更及时同步。uniapp 本地开发时要注意跨域问题,微信开发者工具里要在“详情 -> 本地设置”勾选“不校验合法域名”,否则正式环境接口请求不到,开发时也会被卡住。提审小程序前记得把域名校验做好。微信小程序要求所有请求域名都是 HTTPS,并且在公众平台配置服务器域名白名单。开发环境用 HTTP 可以,但审核版本里如果有 HTTP 请求,会被直接打回。这块建议项目初期就把正式域名配好并申请 SSL 证书,避免后面统一替换域名的大工程。项目上线后要建立每周巡检机制。检查项包括:服务器资源使用率、数据库空间增长趋势、异常订单量、设备掉线率、支付回调成功率、用户反馈处理进度。巡检不一定要做得多复杂,一张 Excel 表格记录每周各项指标即可,长期积累下来能提前发现很多隐患。最后关于团队协作说一句。共享充电桩系统涉及到小程序端、后端、设备端、运营端四个维度,团队里最好有一个人能从整体角度把控业务流程和数据流。哪怕分工明确,也要定期做整体的代码走查,避免各端各改各的,最后接口联调时对不上。这段时间的实际体会是,共享充电桩这类系统,真正的难点不在于某个单一技术点的突破,而在于把用户端、设备端、管理端这几个环节的组合逻辑理清楚。状态机设计、异常处理、边界条件这些基本功做得越扎实,系统运行就越省心。希望这篇文章能帮到正在做或准备做同类项目的开发者。
微信小程序 + PHP + uniapp 共享充电桩系统实战复盘做共享充电桩系统这个项目,前后折腾了大概两个月。从最开始的产品原型,到小程序前端、PHP后端、管理后台,再到真机调试和上线,整个链路走下来踩了不少坑,也沉淀了一些值得记录的经验。这篇文章就把这个项目的完整实现过程做一个复盘,从需求拆解到数据库设计,从接口开发到小程序端实现,把核心环节和关键代码都梳理出来,希望对正在做同类项目的开发者有所帮助。先交代一下项目背景。这套系统面向的场景是小区、园区、商超停车场的新能源汽车充电服务,用户通过微信小程序扫码启动充电、查看充电状态、在线支付,运营方通过管理后台管理充电桩设备、设置计费策略、查看收益报表。技术栈是微信小程序 + uniapp + PHP + MySQL,小程序端用 uniapp 开发以便后续可以扩展 App 端,后端用 PHP 提供接口服务,管理后台用 Web 页面实现。1. 项目整体设计与技术选型1.1 核心需求拆解共享充电桩系统表面上看是一个“扫码充电”的工具,但实际拆解下来,核心链路比想象中复杂不少。我从用户端和管理端两个维度做了需求拆解。用户端(微信小程序)需要实现的功能有:地图找桩、充电桩详情展示、扫码充电、充电状态实时监控、充电结束结算支付、历史订单查询、余额充值、优惠券管理等。这里面的关键链路是“扫码 -> 启动充电 -> 实时计费 -> 结束充电 -> 支付结算”,每个环节都有状态流转和数据一致性问题。管理端(Web 后台)需要实现的功能有:充电桩设备管理(添加、编辑、启停、故障标记)、计费策略配置(按时段、按功率、按度数等多维度计费)、订单管理(订单查询、退款处理、异常订单人工干预)、用户管理(用户信息、余额变动记录)、收益统计报表、操作员账号管理等。除了这两个核心端,还要考虑设备端的通信问题。充电桩本身是一个 IoT 设备,需要联网上报状态。但因为是共享充电桩场景,设备端和云端的交互策略决定了整个系统的实时性表现。1.2 技术选型背后的考虑这个项目为什么选 PHP?主要原因是团队现有技术储备和服务器部署成本。PHP 在中小型项目中开发效率高,生态成熟,部署门槛低,适合这种需要快速上线验证的业务场景。框架选了 thinkphp 6,主要是因为它的目录结构清晰、ORM 好用、有现成的多应用模式,开发效率比较有保障。小程序端为什么用 uniapp?核心目标是一个代码库同时支持微信小程序和将来的 App 端。如果用原生微信小程序开发,后续要做 App 端就得另起炉灶,维护成本就翻倍了。uniapp 的编译能力可以把同一套 Vue 语法代码编译到多个平台,而且它内置了 uni.request、uni.login 等跨端 API,对这类项目来说性价比很高。数据库选型 MySQL 5.7,原因也很朴素:云服务器自带环境里最成熟的搭配,配合 thinkphp 的 ORM,开发体验和运行稳定性都有保证。考虑到充电桩系统的订单量级(一个小区几十台桩,一天几百单),MySQL 完全能扛得住。1.3 系统架构的层次划分整个系统按三层来组织:设备接入层、业务服务层、应用展示层。设备接入层负责和充电桩硬件通信。这里有一个关键设计决策:充电桩和服务器之间的通信协议,是走 HTTP 轮询还是走 WebSocket 长连接?我从实际落地角度考虑,选了 HTTP 轮询为主、WebSocket 辅助的方案。充电桩每隔一定时间主动上报状态,服务器下发指令时通过接口返回待执行任务。原因很简单:HTTP 实现简单、调试方便、部署后不容易因为长连接断开导致掉线问题。具体轮询间隔可以根据设备数量调整,我从实际项目经验看,单桩状态下 5 秒轮询一次比较合理,状态实时性可接受,服务器压力也可控。业务服务层就是 PHP 后端提供的接口集合,涵盖用户认证、充电桩管理、订单交易、支付回调、余额变动等模块。这里我按业务域拆分了多个控制器,每个控制器负责一类功能,接口统一返回 JSON 格式,字段规范为 code/message/data。应用展示层是用户通过微信小程序看到的界面和运营方通过管理后台看到的界面。小程序端以地图找桩为核心入口,接入了微信地图组件;管理后台用 iframe 方式嵌入了简单的 HTML 表格页面,没有引入太重的前端框架。2. 数据库设计:一张表对应一个业务动作2.1 数据表结构总览数据库设计是整个系统的基础,我花了比较多的时间来梳理表结构和字段设计。整个系统一共设计了 12 张核心表,每张表对应一类业务实体。列一下主要的数据表:表名用途核心字段user用户表openid、手机号、余额、积分charge_pile充电桩表桩编号、状态、功率、地址、经纬度charge_order充电订单表订单号、用户ID、桩ID、开始时间、结束时间、电量、金额、状态charge_record充电过程记录表订单ID、实时电压、电流、功率、时间戳balance_log余额变动日志表用户ID、变动金额、变动类型、订单关联coupon优惠券表面额、使用门槛、有效期user_coupon用户优惠券表用户ID、优惠券ID、状态charge_strategy计费策略表策略名称、计价方式、电价、服务费recharge_order充值订单表用户ID、充值金额、赠送金额、支付状态device_status_log设备状态日志表桩ID、状态码、描述、上报时间feedback用户反馈表用户ID、反馈内容、图片、处理状态admin_user管理端账号表用户名、密码、角色2.2 数据一致性的几个关键设计订单金额计算一致性。充电订单涉及两个关键金额:预估金额和实际扣费金额。我的处理方式是:充电启动时不做预付冻结,充电过程中实时计算当前费用并在用户端展示,充电结束生成最终账单,再调用支付接口扣款。这种方式的好处是用户体验好(不用先充钱再退款),代价是必须保证结束充电时扣款成功,如果用户余额不足则需要进入欠费处理流程。充电桩状态与订单状态的联动。充电桩有“空闲、充电中、离线、故障、预约中”五种状态,订单有“充电中、已完成、已取消、异常终止”四种状态。我专门做了一个状态机表记录允许的状态流转路径,在更新状态时先做合法性校验。比如“空闲”状态桩不能直接生成“充电中”状态的订单(必须先启动充电指令),已完成的订单不能再次发起取消操作。余额变动必须走流水表。用户余额涉及充值、消费、退款、管理员调整四种变动来源,每一笔变动都必须写入 balance_log 表。这样做账目清晰,也方便排查问题。实际开发中我还遇到过并发扣款的问题,后面的章节会详细说。2.3 计费策略与动态价格计算计费策略单独设计了一张表,这是充电桩系统比较核心的业务逻辑。每个充电桩可以绑定一个计费策略,也可以整站设置默认策略。计费方式有三种:按电量计费(元/度)、按时间计费(元/分钟)、按功率分段计费。实际项目里最常用的是“基础电价 + 服务费”的组合模式。我在充电桩表里冗余了一个“当前策略ID”字段,而不是每次下单都去关联查找。这样做的原因很实际:策略可能会调整,但已经开始的订单要按启动时的策略计费,所以订单表里还需要保存一份启动时的单价快照。订单表里的 price_snapshot 字段存储 JSON 格式的计费参数,这样即使后续修改了策略,历史订单的结算也不受影响。充电费用计算公式大致是:// 基础费用 = 电量 + 服务费 // 时段费用 = 基础电价 * 电量 + 服务费单价 * 充电时长 $energy_cost = $energy * $strategy['energy_price']; $service_cost = $duration_minutes * $strategy['service_price_per_min']; $total = $energy_cost + $service_cost;实际业务中还经常有“峰谷电价”“闲时折扣”“满减优惠”等需求,这些可以基于这个基础公式做策略扩展。3. PHP 后端接口设计与核心逻辑实现3.1 接口规划与返回格式约定整个后端接口按模块划分,一共规划了 9 个控制器:用户模块:login(微信登录授权)、getUserInfo(获取用户信息)、recharge(充值)、useCoupon(使用优惠券)充电桩模块:getPileList(获取桩列表)、getPileDetail(获取桩详情)、scanStartCharge(扫码启动充电)、stopCharge(结束充电)订单模块:getOrderList(获取订单列表)、getOrderDetail(订单详情)、cancelOrder(取消订单)、refund(退款申请)支付模块:paymentCallback(支付回调)、rechargeCallback(充值回调)、balancePay(余额支付)管理后台模块:pileAdd(添加充电桩)、pileEdit(编辑充电桩)、pileDelete(删除充电桩)、getStatistics(收益统计)。接口统一返回 JSON 格式:{ "code": 0, "message": "success", "data": {} }code 为 0 表示成功,非 0 表示业务异常。消息码按模块分段:1xxx 是用户模块,2xxx 是充电桩模块,3xxx 是订单模块。这样做的好处是前端可以根据 code 直接判断业务类型,后端排查问题时通过错误码也能迅速定位模块。3.2 微信登录与用户身份绑定微信小程序登录流程和传统 Web 登录不同,需要走微信官方的 code2session 接口获取用户的 openid。在实际开发中我踩过一个坑:一开始在服务端用 code 换取 openid 时只保存了 openid,导致用户换设备重新登录后无法关联之前的订单记录。后来才明白 openid 是用户在小程序维度下的唯一标识,同一个用户在不同的公众号/小程序下的 openid 是不同的,所以前端在 wx.login 拿到 code 后,后端用 code 调微信接口换取 openid 和 session_key,用 openid 作为用户的业务唯一标识。服务端核心逻辑:public function login() { $code = $this->request->param('code'); $appid = config('wechat.mini_appid'); $secret = config('wechat.mini_secret'); $url = "https://api.weixin.qq.com/sns/jscode2session?appid={$appid}&secret={$secret}&js_code={$code}&grant_type=authorization_code"; $result = file_get_contents($url); $data = json_decode($result, true); // 用 openid 查用户表,不存在则创建 $user = UserModel::where('openid', $data['openid'])->find(); if (!$user) { $user = UserModel::create([ 'openid' => $data['openid'], 'nickname' => '微信用户' . mt_rand(1000, 9999), 'balance' => 0, 'create_time' => time() ]); } // 生成登录态 token $token = md5($data['openid'] . time() . mt_rand(100000, 999999)); cache('user_token_' . $token, $user['id'], 86400); return json(['code' => 0, 'message' => 'ok', 'data' => ['token' => $token, 'user_info' => $user]]); }这里生成 token 后我用 thinkphp 的 cache 存了 24 小时有效期的用户 ID,后续接口通过请求头里的 token 获取当前用户。用 cache 存登录态的好处是有效期好控制,比直接往客户端写用户 ID 安全得多。3.3 扫码启动充电的状态流转扫码启动充电是整个系统的主链路,也是最核心的接口。用户扫充电桩上的二维码,小程序会带着桩编号跳转到充电详情页。这时候前端会调用 scanStartCharge 接口,接口内部做以下处理:校验桩是否存在且状态为“空闲”。如果桩在充电中,直接返回“设备占用中”的提示。如果桩处于离线或故障状态,返回对应提示。校验用户是否还有未完成的充电订单。如果用户有充电中的订单,不允许启动新的充电任务。校验用户账户是否可用。比如用户被拉黑或账号异常,不允许充电。生成订单记录。订单状态设为“充电中”,启动时间取服务器当前时间。下发启动指令。这一步我用了 mqtt 方案。项目里假设后端通过 MQTT 给设备下发指令,设备收到指令后闭合继电器,充电开始。实际项目中如果桩的联网方案是 MQTT 协议,这一步就在代码里调用 MQTT 的发布接口。如果桩是 HTTP 轮询方案,则把“待启动”指令写入指令表,设备下次上报时获取。下面是这台桩的状态机迁移逻辑:public function scanStartCharge() { $pile_id = $this->request->param('pile_id'); $user_id = $this->getUserId(); // 查看充电桩状态 $pile = ChargePileModel::where('id', $pile_id)->find(); if (!$pile) { return json(['code' => 2001, 'message' => '充电桩不存在']); } if ($pile['status'] != 'idle') { return json(['code' => 2002, 'message' => '当前设备不可用']); } // 查看用户是否有充电中的订单 $exist = ChargeOrderModel::where('user_id', $user_id) ->whereIn('status', ['charging', 'pending']) ->find(); if ($exist) { return json(['code' => 1001, 'message' => '您有充电中的订单,请先结束']); } // 生成订单 $order_no = $this->generateOrderNo(); $strategy = ChargeStrategyModel::where('id', $pile['strategy_id'])->find(); $order = ChargeOrderModel::create([ 'order_no' => $order_no, 'user_id' => $user_id, 'pile_id' => $pile_id, 'start_time' => time(), 'status' => 'charging', 'price_snapshot' => json_encode($strategy), ]); // 更新桩状态 $pile->save(['status' => 'charging']); // 通过 MQTT 下发启动指令(或者写入待执行指令表) // $this->mqttPublish('pile/' . $pile_id, ['cmd' => 'start']); return json(['code' => 0, 'message' => 'ok', 'data' => ['order_id' => $order['id'], 'order_no' => $order_no]]); }这里有几个细节要注意。第一,状态更新操作必须是有条件的原子更新,不能用“先查再改”这种两步操作,否则并发请求可能导致同一台桩被多次启动,这一步可以用 thinkphp 的 update 方法配合条件判断来实现。第二,生成订单和修改桩状态要放在一个事务里,如果有一半成功一半失败,整体回滚。第三,如果桩的控制是通过第三方云平台(如阿里云 IoT 或者某充电桩厂商的云接口),那这里就是调用第三方接口,要考虑第三方接口超时或者失败的兜底策略。3.4 实时计费逻辑与充电结束扣费充电过程中,设备会定期上报功率和电量累计值。我在 charge_record 表里记录每次上报的数据,同时用钩子函数更新订单表中的实时电量。这个方案的好处是:即使主业务接口挂了,设备上报数据仍然持续落库,对账时有据可查。充电结束有两种触发方式:用户在小程序端主动点击停止,或者设备检测到车辆充满自动停止。我分别实现了两个接口,但核心逻辑是同一个:根据订单 id 查询充电记录,计算出最终电量和时长,然后结算费用。费用计算有一个容易踩坑的点:充电开始时桩上显示的是一个预估金额,实际结束时按真实电量和时长计算,两者可能有差异。所以订单支付时直接按最终金额扣款,用户看到的应付金额以最终账单为准。如果用户余额不足,订单状态变为“待补缴”,这种订单可以继续获取充电记录,但不能发起新充电任务。附上结算逻辑的代码框架:public function settleOrder($order_id) { $order = ChargeOrderModel::where('id', $order_id)->find(); if ($order['status'] != 'charging') { return false; } $strategy = json_decode($order['price_snapshot'], true); // 查询累计充电记录 $record = ChargeRecordModel::where('order_id', $order_id)->order('id desc')->find(); $total_energy = $record['total_energy']; // 设备累计电量 $duration = ($record['create_time'] - $order['start_time']) / 60; // 分钟 // 计费 $energy_cost = round($total_energy * $strategy['energy_price'], 2); $service_cost = round($duration * $strategy['service_price_per_min'], 2); $total_fee = round($energy_cost + $service_cost, 2); // 检查余额扣款 $user = UserModel::where('id', $order['user_id'])->find(); if ($user['balance'] >= $total_fee) { UserModel::where('id', $user['id']) ->where('balance', $user['balance']) ->dec('balance', $total_fee) ->update(); $order->save([ 'end_time' => time(), 'total_energy' => $total_energy, 'total_fee' => $total_fee, 'status' => 'completed', 'pay_status' => 'paid' ]); BalanceLogModel::create([ 'user_id' => $user['id'], 'change_amount' => -$total_fee, 'type' => 'consume', 'order_id' => $order['id'], 'create_time' => time() ]); } else { $order->save([ 'end_time' => time(), 'total_energy' => $total_energy, 'total_fee' => $total_fee, 'status' => 'debt', 'pay_status' => 'unpaid' ]); } // 设备置为空闲 ChargePileModel::where('id', $order['pile_id'])->save(['status' => 'idle']); return true; }3.5 微信支付接入与回调处理支付模块接入的是微信支付的小程序支付(JSAPI 支付)。流程不复杂,但有几个细节容易踩坑。统一下单接口要在后端生成预支付订单,返回 prepay_id,小程序端再调 wx.requestPayment 拉起支付。签名算法是 MD5 或 HMAC-SHA256,我这里用了 HMAC-SHA256。回调处理的逻辑我单独拿出来说一下。微信支付回调会以 POST 方式推送到我们配置的通知 URL,携带加密的支付结果数据。开发时一定要验证签名和金额,防止伪造回调。而且回调必须返回 success/fail,否则微信会反复重试通知。public function paymentCallback() { $input = file_get_contents('php://input'); $result = $this->decryptCallback($input); if ($result['return_code'] == 'SUCCESS') { $out_trade_no = $result['out_trade_no']; $order = RechargeOrderModel::where('order_no', $out_trade_no)->find(); if ($order['status'] == 'unpaid') { // 更新充值订单状态 $order->save(['status' => 'paid', 'pay_time' => time()]); // 给用户加余额 UserModel::where('id', $order['user_id'])->inc('balance', $order['amount'])->update(); BalanceLogModel::create([ 'user_id' => $order['user_id'], 'change_amount' => $order['amount'], 'type' => 'recharge', 'order_id' => $order['id'], 'create_time' => time() ]); } return 'success'; } return 'fail'; }一开始做回调时我踩了一个坑:用户在充值页面点击支付后,充值订单已经生成了,但回调进来后我直接给用户加余额,结果用户点击多次支付按钮导致生成了多个充值订单,回调处理时没有对同一订单做幂等判断,导致余额加了两遍。后来加了“回调处理前检查订单状态是否已 paid”的判断,这个问题就解决了。4. uniapp 小程序端实现要点4.1 地图找桩:把经纬度玩明白小程序端的地图找桩功能,用的是微信小程序的 map 组件。后端返回充电桩列表时,每个桩都带有经纬度坐标和距离信息,前端把坐标转换成 map 上的 marker 标记,用户点击 marker 可以查看桩的详情。这里有一个精度问题要处理:微信小程序的 getLocation 接口获取的是当前用户的位置坐标,但如果没有做坐标转换,直接用 GPS 坐标和腾讯地图坐标会有偏差。正确做法是:前端拿到 GPS 坐标后,调腾讯地图的坐标转换接口,转换成腾讯地图的坐标体系,再传给后端做距离计算和地图渲染。否则用户在地图上看到充电桩的位置和实际位置会偏移几十米,在停车场里找桩时就会很困惑。后端返回桩列表时,需要根据用户坐标计算距离并排序。距离计算直接用球面距离公式(Haversine)就可以,不需要引入地理位置搜索引擎,量级几十公里内精度足够:public function getNearbyPiles() { $lat = $this->request->param('lat'); $lng = $this->request->param('lng'); $radius = $this->request->param('radius', 5); // 默认5km $rows = Db::query( "SELECT *, ROUND(6371 * 2 * ASIN(SQRT(POWER(SIN(({$lat} - lat) * PI() / 180 / 2), 2) + COS({$lat} * PI() / 180) * COS(lat * PI() / 180) * POWER(SIN(({$lng} - lng) * PI() / 180 / 2), 2))), 2) AS distance FROM charge_pile WHERE status IN ('idle', 'charging') HAVING distance <= {$radius} ORDER BY distance ASC" ); return json(['code' => 0, 'message' => 'ok', 'data' => $rows]); }用原生 SQL 计算距离的写法在数据量大时性能会下降,但几百个桩的场景完全没问题。如果后续扩展到大范围运营,可以换成 geohash 编码方案或者使用位置检索服务。4.2 扫码页面与启动充电的交互小程序里的扫码主要用 uni.scanCode 实现,扫出来的结果是充电桩上的二维码内容。二维码内容我约定为纯数字桩编号,例如 https://domain/pile/1003 这种 URL 也可以,但小程序端解析时统一取最后一段作为桩编号。这样即使二维码需要重新制作,只需要保证桩编号唯一即可,不需要改后端代码。扫码后的流程是:解析桩编号 -> 调后端接口查询桩状态 -> 跳转充电详情页。这里有一个体验细节:查询桩状态是异步的,如果桩状态是“离线”或“故障”,要给出明确的提示,同时把“启动充电”按钮置灰,避免用户反复点击产生无意义的请求。启动充电后,页面要进入实时监控状态。我用 setInterval 每 5 秒调一次“查询实时充电状态”的接口,刷新展示当前的电量、功率、充电时长和预计费用。同时为了避免用户切到后台导致定时器失效,在 onShow 生命周期里重新开启轮询,在 onHide 生命周期里清理定时器。这个细节一开始没处理好,用户切出去再回来,页面上还是几秒前的数据,后来检查才发现是定时器被清掉了没恢复。实时充电状态接口:public function getChargingStatus() { $order_id = $this->request->param('order_id'); $order = ChargeOrderModel::where('id', $order_id)->find(); if (!$order || $order['status'] != 'charging') { return json(['code' => 0, 'message' => 'ok', 'data' => ['status' => 'finished']]); } $record = ChargeRecordModel::where('order_id', $order_id)->order('id desc')->find(); $strategy = json_decode($order['price_snapshot'], true); $duration = ($record['create_time'] - $order['start_time']) / 60; $energy_cost = round($record['total_energy'] * $strategy['energy_price'], 2); $service_cost = round($duration * $strategy['service_price_per_min'], 2); $total_fee = round($energy_cost + $service_cost, 2); return json(['code' => 0, 'message' => 'ok', 'data' => [ 'status' => 'charging', 'voltage' => $record['voltage'], 'current' => $record['current'], 'power' => $record['power'], 'total_energy' => $record['total_energy'], 'duration' => round($duration, 1), 'total_fee' => $total_fee ]]); }4.3 订单列表与状态展示的映射关系订单列表是用户最常看的功能之一。前端需要根据订单不同状态展示不同按钮:充电中订单显示“结束充电”和“查看详情”,已完成订单显示“查看详情”和“开发票”,待补缴订单显示“去支付”。这里有一个容易忽视的小细节:订单状态字段在后端是英文枚举(charging/completed/cancelled/debt),前端展示时一定要做映射转换,不能让用户看到英文状态。uniapp 端的映射逻辑:statusMap() { const map = { pending: '待启动', charging: '充电中', completed: '已完成', cancelled: '已取消', debt: '待补缴', abnormal: '异常终止' }; return map[this.orderInfo.status] || '未知状态'; }4.4 微信登录与用户授权的处理策略小程序的用户授权逻辑是个老生常谈的问题。一方面微信官方收紧了 getUserProfile 接口的使用范围,另一方面用户点击拒绝授权的情况很常见。我这里采用的最小授权策略是:用户进入小程序后先调 wx.login 静默获取 code,后端换取 openid 完成注册登录,不要求用户主动授权手机号。只有当用户要使用充值、开发票等功能时才去请求手机号授权。为什么这样设计?因为充电桩的核心场景是“扫码充电”,用户到充电桩前是带着明确目的的,此时最应该做的是让用户快速完成充电,而不是在入口处被授权弹窗拦住。把手机号授权放在后续环节,对转化率的影响会小很多。这个策略实测下来用户整个充电流程的跳出率明显比一开始就强制授权低。5. 管理后台与设备关联功能5.1 后台功能清单与页面设计管理后台我用了 iframe 内嵌的方式,简单但真正管用。后台功能模块按操作频率和重要度排序:订单管理是日常使用最多的,充电桩管理次之,收益统计和用户管理是每周看的,计费策略配置是低频操作。订单管理页面我做了几个实用的筛选条件:按订单号搜索、按用户 ID 搜索、按桩编号搜索、按状态筛选、按时间范围筛选。表格展示列包含订单号、用户 ID、桩编号、充电时长、电量、金额、状态、支付状态、操作按钮。这个页面日均处理量不大,但要求快进快出,所以我把筛选和翻页做到了同一个表格工具栏里。5.2 设备状态上报与故障告警充电桩设备的接入方式在第一节说过:HTTP 轮询为主。设备每次上报状态时带上桩编号、当前状态码、电压、电流、功率、累计电量、故障码等字段。后端收到上报后返回一个 JSON,里面含待执行指令列表(如启动、停止、重启等)。设备上报接口:public function deviceReport() { $device_id = $this->request->param('device_id'); $data = $this->request->param(); // 记录上报数据 DeviceStatusLogModel::create([ 'pile_id' => $device_id, 'status_code' => $data['status'], 'voltage' => $data['voltage'], 'current' => $data['current'], 'power' => $data['power'], 'total_energy' => $data['energy'], 'fault_code' => $data['fault_code'] ?? '', 'report_time' => time() ]); // 更新桩状态 $pile = ChargePileModel::where('device_id', $device_id)->find(); if ($pile) { $pile->save([ 'status' => $data['status'], 'last_report_time' => time(), 'voltage' => $data['voltage'], 'current' => $data['current'], 'power' => $data['power'] ]); } // 查询待办指令 $commands = DeviceCommandModel::where('device_id', $device_id) ->where('status', 'pending') ->select(); $cmd_list = []; foreach ($commands as $cmd) { $cmd_list[] = $cmd['command']; $cmd->save(['status' => 'sent']); } return json(['code' => 0, 'message' => 'ok', 'data' => ['commands' => $cmd_list]]); }故障告警我用了一个简单的方案:在后台的充电桩管理页面里特殊标红显示“长时间未上报”的设备(超过 60 秒未上报)。管理后台首页头部显示设备在线率、告警数量、今日订单数、今日营收等概览数据,运营人员每天开电脑扫一眼就能知道当前整体运行状况。5.3 收益统计与报表查询收益统计报表的核心是订单表的数据聚合。我按天、按月两个维度做了统计接口,统计指标包括订单总数、充电量(度)、订单金额、实收金额、退款金额、优惠金额。运营方最关注的是“实收金额”和“充电量”这两个指标,因为对应的就是实际营收和闲置率评估。按月统计的 SQL:SELECT DATE_FORMAT(FROM_UNIXTIME(start_time), '%Y-%m') AS month, COUNT(*) AS order_count, SUM(total_energy) AS total_energy, SUM(total_fee) AS total_fee, SUM(refund_fee) AS refund_fee FROM charge_order WHERE status IN ('completed') GROUP BY DATE_FORMAT(FROM_UNIXTIME(start_time), '%Y-%m') ORDER BY month DESC报表页面用简单的柱状图呈现,图表库我用的是 ECharts 的 CDN,直接引入不需要复杂配置。后端接口返回聚合数据,前端画柱状图,开发成本很低但效果够用。6. 常见问题与排查技巧实录6.1 并发冲突:同一台桩被多人同时扫码上线后遇到最严重的一次故障是:同一台空闲桩被两个用户同时扫码,两个启动请求几乎同时到达后端,都通过了查询桩状态的校验,然后各自生成了充电订单,导致同一台桩出现了两笔订单,实际通电的只有一笔。这个问题的根因就是前面提到的“查询后操作”没有加原子性保护。解决方案有两种:一种是在 SQL 层做更新,比如执行UPDATE charge_pile SET status='charging' WHERE id=? AND status='idle',更新影响行数为 0 说明状态已经被其他请求改掉了,这单就不能下;另一种是用 Redis 的 SETNX 命令做分布式锁,取到锁的请求才能继续走下单流程。介意维护 Redis 的话就在 MySQL 层面解决,加一个条件更新就行。我当时选的方案是条件更新,简单可靠:$updated = Db::name('charge_pile') ->where('id', $pile_id) ->where('status', 'idle') ->update(['status' => 'charging']); if (!$updated) { return json(['code' => 2002, 'message' => '设备已被使用,请选择其他充电桩']); }把“查状态”和“改状态”合并成了一个原子操作,问题自然就解决了。6.2 MQTT 掉线导致设备指令下发失败设备是通过 MQTT 接入的,实际运行中遇到过设备网络波动导致 MQTT 连接断开,后端下发的启动指令没有被设备收到,用户扫码后一直卡在“充电中”状态但设备根本没有输出电流。排查时发现,因为 MQTT 连接断开后,broker 里的消息会被保留到重连后重新推送,但业务代码里没有对“指令是否被设备回复确认”做判断,所以没能及时发现这次启动是失败的。解决思路是加了一层指令确认机制:设备收到指令并执行成功后,会上报一条“执行确认”状态,后端收到确认后才把指令状态更新为已完成;如果超过一定时间没有收到确认,指令状态变为超时,订单自动进入“异常”状态,同时客服可以在后台手动重试或取消订单。时间关系,这个机制我用定时器轮询的方式实现,每 10 秒扫描一次超时指令,目前运行下来效果还可以。6.3 回调重复处理导致用户余额错误微信支付的回调有重试机制,有时同一个支付结果会被推送多次。如果回调处理逻辑没有做幂等校验,用户充值 100 元可能被入账两次,变成余额增加 200 元。排查这类问题的方法很简单:检查 balance_log 表里是否有同一订单号出现多笔入账记录。修复方式是在回调处理入口加校验:查询充值订单的状态,如果已经是 paid 了,直接返回 success 不再处理。除了支付回调,其他所有涉及“状态变更+金额变动”的接口都应该遵守这个原则:先锁状态,再改数据,最后记流水。6.4 坐标偏移导致找桩定位不准地图找桩功能刚上线时,用户反馈充电桩位置在地图上显示不准,有小几十米的偏移。排查后确定是坐标系不一致的问题:设备上报的经纬度是 GPS 坐标(WGS-84),微信小程序的地图组件用的是腾讯坐标系(GCJ-02),两者之间存在非线性偏移。解决方案是在后端转换坐标系,用一个标准的 WGS-84 转 GCJ-02 函数处理后再返回给前端。这个转换算法是公开的,我直接把转换函数集成到了接口层,用户端拿到的坐标直接就是腾讯坐标系,小程序端不再需要做额外转换。如果是接入百度地图,则需要用 BD-09 坐标体系,转换函数对应不同,开发时要确认清楚地图组件用的是什么坐标系。6.5 充电记录上报延迟导致的计费偏差设备在充电过程中是每 10 秒上报一次充电数据,但如果设备网络波动导致数据上报延迟,后端统计充电电量时可能拿不到最新的累计电量,导致结算金额比实际偏低。处理方式是增加补报机制:设备重新连接后,主动上报最后一次的累计电量,后端把订单的 total_energy 更新为当前的最大值。同时结算订单时,不依赖最后一次上报的实时数据,而是取该订单所有充电记录中累计电量的最大值。这样即使单次上报有丢失,累计值也不会少算。6.6 小程序端权限申请的合规适配小程序上线前要注意隐私接口的权限申请。地图功能涉及地理位置接口,充电启动涉及扫码接口,这些都要在微信公众平台的“接口设置”里完成权限配置。提审时如果隐私政策里没有明确说明位置信息和充电记录的采集用途,可能会被驳回。这方面的坑我补充一句:不要等提审被驳回才去补隐私政策,开发阶段就应该在小程序内写好用户隐私保护指引,并把实际用到的接口列全。后面每次新增一个需要用户授权的接口,都要同步更新隐私说明文档,否则版本审核很容易被卡。7. 项目上线后的稳定性优化与数据复盘7.1 服务器架构与性能监控这个项目上线初期用的是单台云服务器,配置是 2 核 4G,部署了 PHP 环境、MySQL、Redis、MQTT 服务。日均几千个请求的量级下,这个配置完全能扛住。但考虑到设备上报频率(每 5 秒一次,一台桩一天上报 17280 条),加上小程序端的轮询请求,MySQL 的并发写还是有一定压力。后期我在项目里做了一处关键优化:设备上报数据不再直接写订单关联表,而是先进 Redis 队列,再由一个消费脚本异步批量写入数据库。这样削峰填谷之后,MySQL 的压力明显下降。但要注意 Redis 队列消费脚本要处理单点故障风险。我用了简单的计划任务每分钟拉取一次数据批量入库,即使脚本挂了重启后能靠时间戳增量补拉,数据不丢。7.2 数据复盘中发现的优化点项目运行一个月后,我从订单数据里发现了几类值得优化的现象:夜间闲时订单占比很低。小区场景下大部分用户是下班回家后充电,夜间 0 点到 6 点的充电量非常少,但桩依然占着位置没有充电。后来我在计费策略里加了“闲时优惠时段”,通过价格杠杆把部分订单引导到夜间,提高了桩的利用率。异常订单占比不高但影响体验。主要是启动失败和提前拔枪两类。启动失败大多是因为设备离线或指令下发超时,我加了下发指令前先检查设备心跳时间的逻辑,超过 2 分钟没有上报的设备直接提示用户不可用,减少无效的启动尝试。用户充值后再充电的比例在提升。刚开始很多用户是订单完成后余额不足变成待补缴状态,后来我在充电完成页面增加了“余额不足时可一键充值”的引导,待补缴订单数量降了将近一半。7.3 关于设备端联动的一些经验补充分享有些充电桩设备厂商提供的是云平台接入接口,这种情况下设备上报的数据会先到厂商云,我们通过云接口去拉取订单相关数据。这种模式的优点是省去了自己对接硬件的成本,但缺点是实时性和数据完整性依赖厂商接口的稳定性。如果设备支持本地局域网协议,可以考虑做边缘网关,在本地完成充电管理逻辑后,再把结果同步到云端。这样断网时充电功能不受影响,恢复联网后再补报数据。不过这个方案对开发能力和硬件能力都有要求,适合团队有嵌入式基础的情况,我这次的项目里还用不上这层方案。8. 一些务实建议和避坑清单复盘整个项目,有几个问题如果一开始就注意,能少走弯路。接口层要做好参数校验。小程序端传过来的参数不一定合法,比如订单 ID、桩编号如果出现非法字符,不应该直接进 SQL 查询,必须做类型转换和格式校验,防止产生不必要的脏数据。事务边界要清晰。涉及金额变动、订单状态变更、设备状态变更的操作,一定要包在事务里,并且按照“先查后锁再改”的顺序执行。如果项目里已经引入了 Redis,可以用分布式锁处理高并发场景,但大多数情况下 MySQL 的条件更新就足够。日志记录要全面。设备上报日志、支付回调日志、指令下发日志、异常订单日志,这四类日志我建议全部保留,而且日志里要有足够的上下文信息(订单号、设备编号、状态码、耗时等),方便线上问题快速定位。我在项目里专门封装了一个简单的日志方法,每个关键节点都做了记录,翻日志排查问题时确实省了很多时间。前后端联调时要有统一的调试环境。接口文档用 swagger 或 yapi 维护,接口变更及时同步。uniapp 本地开发时要注意跨域问题,微信开发者工具里要在“详情 -> 本地设置”勾选“不校验合法域名”,否则正式环境接口请求不到,开发时也会被卡住。提审小程序前记得把域名校验做好。微信小程序要求所有请求域名都是 HTTPS,并且在公众平台配置服务器域名白名单。开发环境用 HTTP 可以,但审核版本里如果有 HTTP 请求,会被直接打回。这块建议项目初期就把正式域名配好并申请 SSL 证书,避免后面统一替换域名的大工程。项目上线后要建立每周巡检机制。检查项包括:服务器资源使用率、数据库空间增长趋势、异常订单量、设备掉线率、支付回调成功率、用户反馈处理进度。巡检不一定要做得多复杂,一张 Excel 表格记录每周各项指标即可,长期积累下来能提前发现很多隐患。最后关于团队协作说一句。共享充电桩系统涉及到小程序端、后端、设备端、运营端四个维度,团队里最好有一个人能从整体角度把控业务流程和数据流。哪怕分工明确,也要定期做整体的代码走查,避免各端各改各的,最后接口联调时对不上。这段时间的实际体会是,共享充电桩这类系统,真正的难点不在于某个单一技术点的突破,而在于把用户端、设备端、管理端这几个环节的组合逻辑理清楚。状态机设计、异常处理、边界条件这些基本功做得越扎实,系统运行就越省心。希望这篇文章能帮到正在做或准备做同类项目的开发者。