
作为一名在智慧停车领域摸爬滚打13 年的技术老兵,我最深的体会是:城市级 “路内 + 路外 + 充电一体化” 不是 “拼积木”,而是 “用技术把分散的资源串成网”—— 从路边的地磁、视频桩,到商场的道闸、充电桩,再到用户手机里的 APP,每一个节点都需要 “会说话”。
今天我就把我们团队基于SpringCloud 微服务架构搭建的YunParking 停充一体平台的技术细节扒开来讲 —— 从底层设备接入的协议适配,到中层业务联动的规则引擎,再到上层支付分账的算法实现,全是一线踩坑后的干货。
一、底层破局:YunIOT 设备统一接入,搞定 “协议碎片化” 难题
传统停充系统的第一大痛点是设备协议不兼容:地磁用 NB-IoT 发十六进制指令,视频桩用 SDK 传图片流,充电桩用云快充协议 —— 每加一类设备就要写一套适配代码,维护成本高到离谱。
我们的解决方案是 \\“协议适配器模式 + 消息中间件”\\,把所有设备的 “语言” 翻译成统一的 “系统语”。
1. 协议适配器:让设备 “讲同一种话”
我们定义了一个DeviceAdapter接口,每类设备对应一个实现类,核心是 \\“解析原始数据”+“构建控制指令”\\:
/**
* 设备适配器接口:统一设备数据解析与指令构建
*/
public interface DeviceAdapter {
/**
* 解析设备原始数据(如NB-IoT的十六进制、视频桩的SDK输出)
* @param rawData 设备原始数据
* @return 统一格式的设备数据DTO
*/
DeviceDataDTO parseRawData(String rawData);
/**
* 构建设备控制指令(如控制道闸开/关、查询充电桩状态)
* @param command 系统指令DTO
* @return 设备能懂的指令字符串(如十六进制、SDK指令)
*/
String buildCommand(DeviceCommandDTO command);
}以路内地磁(NB-IoT 协议)和路外道闸(百胜协议)为例,看具体实现:
(1)地磁适配器:解析 NB-IoT 十六进制数据
地磁的原始数据是"01 03 00 00 00 01"(表示车位占用),我们需要转成系统能理解的DeviceDataDTO:
/**
* NB-IoT地磁适配器
*/
public class MagneticAdapter implements DeviceAdapter {
@Override
public DeviceDataDTO parseRawData(String rawData) {
// 1. 去除空格,转成十六进制字节数组
String hexStr = rawData.replace(" ", "");
byte[] bytes = Hex.decodeHex(hexStr.toCharArray());
// 2. 解析状态:第4位(索引3)为1表示占用,0表示空闲
int status = bytes[3] & 0xFF;
DeviceDataDTO data = new DeviceDataDTO();
data.setDeviceId(new String(bytes, 0, 2)); // 设备ID取前2字节
data.setDeviceType("magnetic");
data.setStatus(status == 1 ? "occupied" : "free");
data.setTimestamp(System.currentTimeMillis());
return data;
}
@Override
public String buildCommand(DeviceCommandDTO command) {
// 构建查询指令:"01 03 00 00 00 01"(读取地磁状态)
return "01 03 00 00 00 01";
}
}(2)道闸适配器:对接百胜道闸协议
百胜道闸的控制指令是"JF:OPEN"(开闸)、"JF:CLOSE"(关闸),我们需要将系统的DeviceCommandDTO转成对应字符串:
/**
* 百胜道闸适配器
*/
public class BaShengGateAdapter implements DeviceAdapter {
@Override
public DeviceDataDTO parseRawData(String rawData) {
// 道闸的原始数据是"STATUS:OPEN"(开闸状态)或"STATUS:CLOSED"(关闸)
String[] parts = rawData.split(":");
DeviceDataDTO data = new DeviceDataDTO();
data.setDeviceType("gate");
data.setStatus(parts[1].toLowerCase());
return data;
}
@Override
public String buildCommand(DeviceCommandDTO command) {
// 系统指令"open"转成百胜的"JF:OPEN"
return command.getCommand().equals("open") ? "JF:OPEN" : "JF:CLOSE";
}
}(3)适配器工厂:一键获取设备适配器
为了避免硬编码,我们用工厂模式管理所有适配器:
/**
* 设备适配器工厂:根据设备类型获取对应适配器
*/
public class DeviceAdapterFactory {
private static final Map<String, DeviceAdapter> ADAPTERS = new ConcurrentHashMap<>();
// 静态初始化:注册所有设备适配器
static {
ADAPTERS.put("magnetic", new MagneticAdapter());
ADAPTERS.put("video_pile", new VideoPileAdapter()); // 视频桩适配器
ADAPTERS.put("gate_basheng", new BaShengGateAdapter()); // 百胜道闸
ADAPTERS.put("charger_yunchong", new YunChongChargerAdapter()); // 云快充充电桩
}
/**
* 根据设备类型获取适配器
* @param deviceType 设备类型(如"magnetic"、"gate_basheng")
* @return 对应的设备适配器
*/
public static DeviceAdapter getAdapter(String deviceType) {
return ADAPTERS.getOrDefault(deviceType, new DefaultAdapter()); // 默认适配器处理未知设备
}
}2. 消息中间件:用 RabbitMQ 解决 “高并发延迟”
早期我们用阿里云 MQTT接入设备,结果 1000 台设备同时上报时,消息延迟高达8 秒(MQTT 的 QoS1 模式需要确认消息)。后来换成私有化 RabbitMQ,通过 3 个优化把延迟压到了500ms 以内:
(1)队列配置:持久化 + 手动 ACK
/**
* RabbitMQ队列配置:确保消息不丢失
*/
@Configuration
public class RabbitConfig {
@Bean
public Queue deviceDataQueue() {
// durable: 持久化队列;autoDelete: 不自动删除
return new Queue("device_data_queue", true, false, false);
}
@Bean
public SimpleRabbitListenerContainerFactory rabbitListenerContainerFactory(ConnectionFactory connectionFactory) {
SimpleRabbitListenerContainerFactory factory = new SimpleRabbitListenerContainerFactory();
factory.setConnectionFactory(connectionFactory);
factory.setAcknowledgeMode(AcknowledgeMode.MANUAL); // 手动ACK,避免消息丢失
factory.setPrefetchCount(10); // 每次拉取10条消息,提升并发
return factory;
}
}(2)消费者代码:异步处理设备数据
/**
* 设备数据消费者:处理所有设备的上报数据
*/
@Component
@RabbitListener(queues = "device_data_queue")
public class DeviceDataConsumer {
@Autowired
private DeviceService deviceService;
@RabbitHandler
public void process(String rawData, @Header(AmqpHeaders.DELIVERY_TAG) long deliveryTag, Channel channel) throws IOException {
try {
// 1. 解析设备类型(假设原始数据是JSON格式:{"deviceType":"magnetic","rawData":"01 03..."})
JSONObject json = JSON.parseObject(rawData);
String deviceType = json.getString("deviceType");
String raw = json.getString("rawData");
// 2. 获取适配器,解析数据
DeviceAdapter adapter = DeviceAdapterFactory.getAdapter(deviceType);
DeviceDataDTO data = adapter.parseRawData(raw);
// 3. 保存数据到数据库(或触发业务逻辑)
deviceService.saveData(data);
// 4. 手动ACK:确认消息处理完成
channel.basicAck(deliveryTag, false);
} catch (Exception e) {
// 异常处理:重新入队或死信队列
channel.basicNack(deliveryTag, false, true);
log.error("处理设备数据失败:{}", e.getMessage(), e);
}
}
}二、中层联动:YunParking+YunCharge,让 “停车” 与 “充电” 对话
停充一体的核心是 \\“业务联动”:比如用户停入路内车位,系统自动推荐 50 米内的空闲充电桩;用户发起充电,系统锁定对应车位 —— 这些场景需要规则引擎 \\ 把 “设备数据” 转换成 “用户服务”。
1. 路内停车:视频桩的 “车牌识别 + 计时”
路内停车的关键是 \\“精准识别车牌 + 自动计时”,我们选用臻识 200 万像素 AI 视频桩 \\(支持宽动态、低照度),通过 SDK 对接实现车牌识别:
(1)视频桩 SDK 接入
/**
* 视频桩车牌识别服务:对接臻识SDK
*/
@Service
public class VideoPileService {
// 臻识SDK初始化(需要申请APP_KEY)
private ZSClient zsClient = new ZSClient("YOUR_APP_KEY");
/**
* 识别视频流中的车牌
* @param videoStream 视频流字节数组
* @return 车牌信息(如"粤A12345")
*/
public String recognizeLicensePlate(byte[] videoStream) {
try {
// 调用SDK识别车牌
ZSResult result = zsClient.recognize(videoStream);
if (result.isSuccess()) {
return result.getLicensePlate();
}
return null;
} catch (Exception e) {
log.error("视频桩识别失败:{}", e.getMessage());
return null;
}
}
}(2)自动计时逻辑
当视频桩识别到车牌时,系统会记录 “入场时间”;当车辆离开时,再次识别车牌,计算 “停车时长”—— 整个过程无需人工干预。
2. 路外停车:道闸与充电桩的 “双向锁定”
路外停车场的核心是 \\“道闸与充电桩联动”\\:用户停入带充电桩的车位,发起充电后,系统自动锁定车位(避免被其他车辆占用)。
我们用Drools 规则引擎实现这个逻辑,规则文件charge_lock_parking.drl:
/**
* 规则:充电开始时锁定对应车位
*/
rule "Lock Parking Space When Charging Starts"
when
// 1. 捕获充电开始事件(ChargeEvent)
$chargeEvent : ChargeEvent(status == "STARTED")
// 2. 找到对应车位,且状态为空闲
$parkingSpace : ParkingSpace(id == $chargeEvent.getParkingSpaceId(), status == "FREE")
then
// 3. 锁定车位
parkingSpaceService.lock($parkingSpace.getId());
// 4. 发送通知给用户
notificationService.send($chargeEvent.getUserId(), "您的车位已锁定,充电期间请勿离开");
endJava 代码中调用规则引擎:
/**
* 规则引擎服务:执行停充联动规则
*/
@Service
public class RuleEngineService {
@Autowired
private KieContainer kieContainer;
public void executeChargeRules(ChargeEvent chargeEvent) {
KieSession kieSession = kieContainer.newKieSession();
try {
// 插入充电事件到规则引擎
kieSession.insert(chargeEvent);
// 执行所有匹配的规则
kieSession.fireAllRules();
} finally {
kieSession.dispose();
}
}
}三、上层闭环:YunPay 支付分账,解决 “多角色分润” 痛点
城市级项目的终极问题是 \\“分钱”\\:政府要抽成、停车场要收益、充电桩运营商要利润 —— 传统支付系统的 “固定比例分账” 根本扛不住复杂场景(比如充电时长超过 1 小时,运营商分润降 10%)。
1. 分账规则:JSON 配置 + 动态调整
我们用JSON 定义分账规则,支持 “固定金额”“比例分润”“条件调整”:
{
"ruleId": "city_park_charge",
"scenario": "parking+charging", // 场景:停车+充电
"parties": [
{"partyId": "government", "name": "政府", "type": "fixed", "value": 10}, // 政府固定抽10元
{"partyId": "parking_lot", "name": "停车场", "type": "proportion", "value": 0.8}, // 停车费80%
{"partyId": "charger_op", "name": "充电桩运营商", "type": "proportion", "value": 0.7} // 充电费70%
],
"conditions": [
// 条件:充电时长超过60分钟,运营商分润降10%
{"field": "chargingDuration", "operator": ">", "value": "60", "adjust": {"partyId": "charger_op", "value": -0.1}}
]
}2. 分账算法:从 “固定” 到 “动态” 的实现
/**
* 分账计算服务:根据规则计算各角色的分润金额
*/
@Service
public class ProfitShareService {
@Autowired
private ShareRuleDao shareRuleDao;
/**
* 计算分账金额
* @param order 订单(包含停车费、充电费、充电时长等信息)
* @return 分润结果列表
*/
public List<ProfitShareDTO> calculateShare(Order order) {
List<ProfitShareDTO> result = new ArrayList<>();
// 1. 获取订单对应的分账规则
ShareRule rule = shareRuleDao.getByScenario(order.getScenario());
if (rule == null) {
throw new IllegalArgumentException("未找到分账规则");
}
// 2. 处理固定金额分账(如政府抽10元)
handleFixedShare(rule, result);
// 3. 处理比例分账(如停车费80%给停车场)
handleProportionShare(rule, order, result);
// 4. 处理条件调整(如充电超过60分钟,运营商分润降10%)
handleConditionAdjust(rule, order, result);
return result;
}
/**
* 处理固定金额分账
*/
private void handleFixedShare(ShareRule rule, List<ProfitShareDTO> result) {
for (ShareParty party : rule.getParties()) {
if ("fixed".equals(party.getType())) {
BigDecimal amount = new BigDecimal(party.getValue());
result.add(new ProfitShareDTO(party.getPartyId(), amount));
}
}
}
/**
* 处理比例分账
*/
private void handleProportionShare(ShareRule rule, Order order, List<ProfitShareDTO> result) {
BigDecimal parkingFee = order.getParkingFee();
BigDecimal chargingFee = order.getChargingFee();
for (ShareParty party : rule.getParties()) {
if (!"proportion".equals(party.getType())) continue;
BigDecimal amount = BigDecimal.ZERO;
if ("parking_lot".equals(party.getPartyId())) {
amount = parkingFee.multiply(new BigDecimal(party.getValue()));
} else if ("charger_op".equals(party.getPartyId())) {
amount = chargingFee.multiply(new BigDecimal(party.getValue()));
}
result.add(new ProfitShareDTO(party.getPartyId(), amount));
}
}
/**
* 处理条件调整
*/
private void handleConditionAdjust(ShareRule rule, Order order, List<ProfitShareDTO> result) {
for (Condition condition : rule.getConditions()) {
// 检查条件是否满足(如充电时长>60分钟)
boolean meet = checkCondition(condition, order);
if (!meet) continue;
// 调整对应角色的分润
for (ProfitShareDTO share : result) {
if (share.getPartyId().equals(condition.getAdjust().getPartyId())) {
BigDecimal adjust = new BigDecimal(condition.getAdjust().getValue());
share.setAmount(share.getAmount().add(adjust));
}
}
}
}
/**
* 检查条件是否满足
*/
private boolean checkCondition(Condition condition, Order order) {
String field = condition.getField();
String operator = condition.getOperator();
String value = condition.getValue();
if ("chargingDuration".equals(field)) {
int duration = order.getChargingDuration();
int val = Integer.parseInt(value);
return ">".equals(operator) && duration > val;
}
return false;
}
}
