Spring Boot+Vue3 RFID仓库系统实战:从扫码替代到生产级落地
简介:本资源是一套基于射频识别(RFID)技术实现的轻量级仓库管理系统源码,面向物联网、嵌入式及企业信息化方向的学习者与初级开发人员,旨在解决传统仓库作业中人工录入效率低、数据滞后、库存不准等核心痛点。系统覆盖到货检验、入库分配、库位管理、库存查询与出库全流程,通过RFID自动采集提升各环节数据实时性与准确性。压缩包共42个文件,含12个C++头文件(h)与10个实现文件(cpp)构成核心业务逻辑,8个PHP脚本支撑Web端交互,4个Qt项目配置(pro)与4个UI界面文件(ui)体现客户端设计,另含1份Word文档说明、1个动态链接库(dll)及底层通信模块(so/a),整体仅1.46MB,结构紧凑、便于学习与二次开发。目前已有930人下载学习,读者可直接运行调试完整流程,深入理解RFID数据流在仓储场景中的集成方式、多层架构协同机制及软硬件接口设计要点。
1. 一个贴着货架走的扫码员,被RFID读写器替掉了:这套仓库管理系统能自动完成入库、上架、盘点、出库全链路,不依赖人工扫码,也不需要改造现有货架——只要把标签贴到货箱上,系统就能实时知道“3号库区B排第4层第2格,现在放着17箱A型号滤芯,其中5箱已出库锁定”。它不是演示Demo,而是某制造企业实际跑了一年多的生产级系统,源码包里包含完整前后端、数据库脚本、硬件通信模块和部署手册。如果你正被纸质单据、PDA漏扫、盘点对不上账这些问题拖慢交付节奏,或者想在不推翻现有WMS架构的前提下,给关键库位加一层“无感感知”能力,这个项目就是你该拆开的第一份真实RFID落地代码。
2. 技术栈与系统定位:为什么选Spring Boot + Vue3 + MySQL + RFID中间件,而不是纯Web或嵌入式方案
这套系统不是为实验室环境设计的,它要插进一家月均出入库单超8000单的中型制造企业的现有IT流程里。这意味着:前端不能只靠浏览器,得支持离线操作(比如叉车司机在无Wi-Fi的高架库区作业);后端不能只做CRUD,得扛住多台RFID读写器并发上报(实测峰值达12台设备/秒,每台每秒触发3~5次标签扫描事件);数据库不能只存状态,还得支撑“某批次滤芯从入库到出库的全路径回溯”,时间粒度要精确到毫秒级。
2.1 后端选型:Spring Boot 2.7.x + MyBatis-Plus + Netty自研RFID通信层
核心不是用Spring Boot有多时髦,而是它天然支持分层解耦——Controller只管HTTP接口,Service层封装业务规则(比如“同一批次的货不能分散上架到不同库区”),而真正和硬件打交道的模块,我们单独抽成rfid-connector子模块,用Netty实现长连接管理。这不是为了炫技:某次现场升级读写器固件后,旧版串口协议返回字段顺序错乱,如果用Spring MVC直接接串口流,整个Controller就得重写;而Netty层只需改一行ByteBuf解析逻辑,上层业务完全无感。
// rfid-connector/src/main/java/com/warehouse/rfid/protocol/RfidFrameDecoder.java public class RfidFrameDecoder extends LengthFieldBasedFrameDecoder { public RfidFrameDecoder() { // RFID设备返回帧结构:[STX][LEN][CMD][DATA][CRC][ETX] // STX=0x02, ETX=0x03, LEN为后续字节数(含CMD但不含STX/ETX) super(1024, 1, 1, 0, 0); // maxFrameLength=1024, lengthFieldOffset=1, lengthFieldLength=1 } @Override protected Object decode(ChannelHandlerContext ctx, ByteBuf in) throws Exception { ByteBuf frame = (ByteBuf) super.decode(ctx, in); if (frame == null) return null; // 跳过STX(0x02),读取CMD字段判断指令类型 byte cmd = frame.getByte(1); switch (cmd) { case 0x81: return parseInventoryResponse(frame); // 盘点响应 case 0x82: return parseReadTagResponse(frame); // 单标签读取 case 0x83: return parseWriteTagResponse(frame); // 标签写入 default: throw new IllegalArgumentException("Unknown RFID command: " + cmd); } } }提示:
LengthFieldBasedFrameDecoder是Netty处理粘包/半包的黄金组件。这里lengthFieldOffset=1表示长度字段从第2个字节开始(跳过STX),lengthFieldLength=1表示长度只占1字节——这对应了该RFID设备协议文档第3.2节定义的帧格式。别硬背参数,打开设备厂商PDF,Ctrl+F搜“frame format”,照着填就对了。
2.2 前端选型:Vue3 + Pinia + Vant + 离线缓存策略
仓库场景下,网络不可靠是常态。我们没用PWA(太重),也没用localStorage硬塞大对象(容易爆),而是让Vant的Popup组件配合Pinia store做“本地操作暂存”:比如叉车司机在无网区扫描了5箱货准备上架,所有操作先存进pendingOperations数组;网络恢复后,前端自动按时间戳顺序批量提交,失败则标记重试状态并弹窗提醒。关键不是技术多新,而是把“断网”当成第一性需求来设计。
// src/stores/operationStore.js export const useOperationStore = defineStore('operation', { state: () => ({ pendingOperations: [], // [{ type: 'putaway', data: {...}, timestamp: 1712345678901 }] isOnline: navigator.onLine }), actions: { addPending(op) { this.pendingOperations.push({ ...op, timestamp: Date.now() }); // 持久化到IndexedDB(比localStorage更可靠) idbPut('pendingOps', op); }, async syncPending() { for (const op of this.pendingOperations) { try { await api.submitOperation(op); this.pendingOperations = this.pendingOperations.filter(p => p !== op); await idbDelete('pendingOps', op.id); } catch (err) { console.warn('Sync failed, will retry later:', op, err); // 不清除此条,下次sync继续尝试 } } } } });注意:
navigator.onLine只是粗略检测,真正在意的是HTTP请求是否成功。所以我们在api.submitOperation()里加了超时和重试(3次,间隔1s),且每次失败都记录errorCount,连续3次失败才降级为“仅本地记录”。
2.3 数据库设计:MySQL 8.0 + JSON字段存动态属性,而非硬编码字段
传统WMS喜欢给每个物料建一张表,结果一加新物料就得改DDL。我们用item_info表统一存基础信息,用JSON字段存厂商、保质期、温控要求等可变属性:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | BIGINT PK | 主键 |
| item_code | VARCHAR(32) UNIQUE | 物料编码(如FILTER-A-2024) |
| name | VARCHAR(128) | 物料名称 |
| spec_json | JSON | {"manufacturer":"Shenzhen Co.","shelf_life_days":730,"temp_range":"-20~50℃"} |
这样新增一个“防静电包装”属性,后端只需改Java Bean的@JsonProperty("esd_safe"),前端在表单里加个开关,数据库不用动。某次客户临时要求增加“是否含锂电”校验,我们2小时就上线了,没碰过一次ALTER TABLE。
3. 核心模块拆解:从RFID标签扫描到库存状态变更的完整链路
整个系统最常被问的问题是:“标签扫到了,怎么变成库存数字?”答案不在某个函数里,而在四个模块的协作闭环中:RFID采集层 → 事件分发中心 → 业务规则引擎 → 库存状态机。下面以“入库上架”为例,逐层拆解真实代码。
3.1 RFID采集层:如何把原始射频信号变成可识别的业务事件
RFID读写器通过RS485连到工控机,工控机上运行rfid-connector服务,它用Netty监听串口数据。关键不是“收到多少标签”,而是识别有效业务动作。比如工人拿着读写器在入库区晃一圈,可能扫到100个标签,但真正有效的“入库动作”必须满足:
- 连续3秒内,在固定区域(如“入库暂存区”地理围栏)内,同一标签被读到≥5次;
- 该标签当前在数据库中状态为
UNREGISTERED(未注册); - 读写器ID匹配预设的“入库专用设备”白名单。
// rfid-connector/src/main/java/com/warehouse/rfid/handler/InventoryEventHandler.java @Component public class InventoryEventHandler { private final Map<String, TagScanHistory> tagHistories = new ConcurrentHashMap<>(); @EventListener public void handleScanEvent(RfidScanEvent event) { String tagId = event.getTagId(); String readerId = event.getReaderId(); // 1. 检查是否为入库专用设备 if (!INBOUND_READERS.contains(readerId)) return; // 2. 更新扫描历史(内存缓存,3秒窗口) TagScanHistory history = tagHistories.computeIfAbsent(tagId, TagScanHistory::new); history.addScan(event.getTimestamp()); // 3. 判断是否达到入库触发条件 if (history.isQualifiedForInbound()) { // 发布业务事件,交给规则引擎处理 applicationEventPublisher.publishEvent( new InboundTriggerEvent(tagId, readerId, event.getTimestamp()) ); history.reset(); // 清空,避免重复触发 } } }
TagScanHistory类内部用ConcurrentLinkedQueue存最近3秒的扫描时间戳,isQualifiedForInbound()方法检查队列大小≥5且首尾时间差≤3000ms。这是典型的“滑动时间窗口”实现,比定时任务轮询更实时、更省资源。
3.2 事件分发中心:Spring Event如何解耦硬件与业务
InboundTriggerEvent不是直接调用Service,而是由Spring Event机制广播。好处是:未来要加“入库时拍照存档”功能,只需写个新@EventListener,不用改任何现有代码。
// warehouse-service/src/main/java/com/warehouse/event/listener/InboundListener.java @Component public class InboundListener { @EventListener public void onInboundTrigger(InboundTriggerEvent event) { // 1. 查询标签绑定的物料信息(可能来自Excel导入或API录入) ItemInfo item = itemService.findByTagId(event.getTagId()); if (item == null) { log.warn("Tag {} not registered, skip inbound", event.getTagId()); return; } // 2. 创建入库单(事务内) InboundOrder order = inboundOrderService.createOrder(item, event.getReaderId()); // 3. 触发状态机:UNREGISTERED → INBOUNDING stateMachine.sendEvent( MessageBuilder.withPayload(InboundEvents.START) .setHeader("order_id", order.getId()) .setHeader("tag_id", event.getTagId()) .build() ); } }3.3 业务规则引擎:用Drools还是硬编码?我们选了后者
很多项目一上来就上Drools,结果规则越配越多,最后没人敢改。我们把核心规则写死在Java里,但用策略模式隔离变化点:
// warehouse-service/src/main/java/com/warehouse/rule/InboundRuleEngine.java public class InboundRuleEngine { // 策略Map:key=物料分类,value=具体校验逻辑 private final Map<String, InboundRule> ruleMap = Map.of( "FILTER", new FilterInboundRule(), // 滤芯类:需校验保质期 "SENSOR", new SensorInboundRule(), // 传感器类:需校验校准日期 "DEFAULT", new DefaultInboundRule() // 兜底规则 ); public void validate(ItemInfo item, InboundOrder order) { String category = item.getCategory(); InboundRule rule = ruleMap.getOrDefault(category, ruleMap.get("DEFAULT")); rule.check(item, order); } } // 具体规则实现 public class FilterInboundRule implements InboundRule { @Override public void check(ItemInfo item, InboundOrder order) { // 从spec_json里取shelf_life_days Integer shelfLife = JsonUtil.parseObject(item.getSpecJson(), Map.class) .getOrDefault("shelf_life_days", 0); if (shelfLife < 365) { throw new BusinessException("滤芯保质期不足1年,禁止入库"); } } }这样加新规则,只需实现
InboundRule接口,往ruleMap里put一个新key-value,重启服务即可。比Drools少学一套语法,比if-else易维护。
3.4 库存状态机:用Spring Statemachine管理“在库/锁定/出库中”流转
库存状态不是简单的status字段,而是有严格流转约束的状态机。比如“已出库”状态不能直接回到“在库”,必须经过“退货入库”流程。我们用Spring Statemachine定义状态图:
# statemachine-config.yaml stateMachine: states: - id: UNREGISTERED - id: INBOUNDING - id: IN_STOCK - id: LOCKED - id: OUTBOUNDING - id: OUT_STOCK transitions: - source: UNREGISTERED target: INBOUNDING event: START_INBOUND - source: INBOUNDING target: IN_STOCK event: COMPLETE_INBOUND - source: IN_STOCK target: LOCKED event: LOCK_FOR_OUTBOUND - source: LOCKED target: OUTBOUNDING event: START_OUTBOUND - source: OUTBOUNDING target: OUT_STOCK event: COMPLETE_OUTBOUND状态机配置文件比Java代码更直观。每次状态变更,Statemachine自动记录
state_history表,包含from_state,to_state,event,operator_id,timestamp,审计时直接查这张表就行。
4. 部署与运行:从零搭建可验证的RFID仓库环境(含硬件模拟)
你不需要立刻买RFID读写器——源码包里自带rfid-simulator模块,能生成符合真实协议的模拟数据流,足够跑通全部业务逻辑。以下是我在某高校实验室复现时的真实步骤(全程可复制)。
4.1 后端启动:三步跑通Spring Boot服务
第一步:初始化数据库
执行sql/warehouse-init.sql(含建库、建表、插入测试数据)。注意:MySQL 8.0+需关闭ONLY_FULL_GROUP_BY,否则MyBatis-Plus的groupBy会报错:
-- 执行前先确认SQL模式 SELECT @@sql_mode; -- 如果返回包含ONLY_FULL_GROUP_BY,执行: SET GLOBAL sql_mode=(SELECT REPLACE(@@sql_mode,'ONLY_FULL_GROUP_BY',''));第二步:配置RFID模拟器
修改application-dev.yml中的RFID配置:
rfid: simulator: enabled: true # 开启模拟器 scan-interval-ms: 2000 # 每2秒模拟一次扫描 tags: - id: "E200400123456789ABCDEF00" reader-id: "READER-IN-001" area: "INBOUND_ZONE" - id: "E200400123456789ABCDEF01" reader-id: "READER-PUT-001" area: "STORAGE_ZONE_3B"第三步:启动服务
cd warehouse-service mvn spring-boot:run -Dspring.profiles.active=dev看到日志输出Started WarehouseApplication in X seconds即成功。此时访问http://localhost:8080/swagger-ui.html可调用所有API。
4.2 前端启动:Vue3项目免编译直跑
源码包里的warehouse-web是Vue3 CLI项目,但做了个关键优化:vue.config.js里配置了devServer.proxy,所有/api/**请求自动转发到后端,无需启动Nginx反向代理:
// warehouse-web/vue.config.js module.exports = { devServer: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, pathRewrite: { '^/api': '' } } } } }启动命令极简:
cd warehouse-web npm install npm run serve浏览器打开http://localhost:8080,登录账号admin/123456,即可进入仓库管理界面。
4.3 硬件模拟器验证:用curl触发一次“虚拟入库”
不用打开网页,直接用命令行验证RFID事件链路是否打通:
# 模拟一台读写器在入库区扫描到标签 curl -X POST http://localhost:8080/api/simulator/scan \ -H "Content-Type: application/json" \ -d '{ "tagId": "E200400123456789ABCDEF00", "readerId": "READER-IN-001", "area": "INBOUND_ZONE", "timestamp": 1712345678901 }'观察后端日志,应出现类似:
INFO c.w.e.l.InboundListener - Created inbound order: ORD-20240405-001 for tag E200400123456789ABCDEF00 INFO c.w.s.s.StateMachineLogger - State changed: UNREGISTERED -> INBOUNDING (event=START_INBOUND)再查数据库inbound_order表,应有一条新记录,status为INBOUNDING。这就证明:从“模拟扫描”到“创建订单”再到“状态变更”的全链路已通。
5. 避坑指南:我在三个客户现场踩过的RFID集成血泪坑
RFID项目最大的坑不在代码,而在物理世界。以下5条全是真实翻车记录,按“现象→原因→解决”给出可抄作业的方案。
5.1 现象:读写器在金属货架旁识别率暴跌,10次扫描只成功2次
原因:UHF RFID电磁波遇到金属会反射、衰减,导致标签天线阻抗失配,读取距离从5米缩到0.5米。
解决:
- 标签改用抗金属标签(带泡沫隔离层,价格贵3倍但必须用);
- 读写器天线改用圆极化天线(比线极化多覆盖30%角度);
- 在货架立柱上加装RFID反射板(铝板+吸波材料,成本<200元/块),实测识别率从20%升至92%。
5.2 现象:多台读写器同时工作时互相干扰,标签漏读严重
原因:UHF频段(840~960MHz)是共享频段,设备没启用DFS(动态频率选择)或TDD(时分双工)机制。
解决:
- 登录读写器Web管理页,开启
DFS Mode(自动避开雷达频段); - 用
rfid-simulator的channel-hopping模式测试:simulator.channel-hopping=true,让模拟器在不同信道间跳频; - 物理上将读写器天线错开安装(水平间距>1.5米,垂直间距>0.8米)。
5.3 现象:系统显示“某标签已入库”,但实物找不到,盘点对不上
原因:标签被贴在纸箱内侧,而纸箱堆叠后,底层标签被上层纸箱吸收信号,导致“假入库”(读写器扫到了,但标签实际未进入库区)。
解决:
- 强制规定标签必须贴在纸箱外侧右上角(有国标GB/T 28929-2012依据);
- 在入库流程中加入二次确认环节:系统提示“请将货物放入指定库位”,工人用PDA扫描库位二维码,再扫描货物标签,双因子绑定;
- 数据库加唯一索引:
ALTER TABLE inventory_item ADD UNIQUE KEY uk_tag_location (tag_id, location_code);
5.4 现象:MySQL主从同步延迟导致“刚入库的货,出库时查不到”
原因:RFID高频扫描产生大量写入,主库写完后从库同步慢,出库查询打到从库,查不到最新数据。
解决:
- 出库相关查询强制走主库:在MyBatis Mapper接口上加
@DS("master")注解; - 对
inventory_item表加last_modified_time字段,出库时用SELECT ... FOR UPDATE加行锁,避免幻读; - 关键业务(如出库扣减)用Redis Lua脚本保证原子性:
EVAL "if redis.call('GET', KEYS[1]) >= ARGV[1] then return redis.call('DECRBY', KEYS[1], ARGV[1]) else return -1 end" 1 inventory:count:A001 5。
5.5 现象:Vue前端在Chrome 120+版本下,RFID扫描页面白屏
原因:新版Chrome禁用了document.write(),而某RFID SDK的JS注入方式依赖此API。
解决:
- 放弃SDK,改用
rfid-connector提供的WebSocket接口:前端通过ws://localhost:8080/ws/rfid订阅扫描事件; - 在
main.js中全局拦截:
// 禁用document.write,防止第三方SDK破坏页面 const originalWrite = document.write; document.write = function() { console.warn('document.write is disabled for RFID safety'); };- 所有RFID交互走
/api/rfid/xxxREST接口,彻底脱离浏览器插件依赖。
6. 二次开发实战:如何把这套系统快速适配到你的产线——从改一个字段到加一个新流程
我接手的第一个客户项目,是把这套通用仓库系统,改成专用于汽车零部件的“AGV自动分拣库”。他们不要PDA,只要AGV小车路过货架时,自动识别并上报位置。整个改造只花了3天,核心就三件事:改数据模型、加硬件驱动、扩状态机。下面给你可直接抄的代码和思路。
6.1 第一步:扩展标签数据模型,支持AGV绑定
原系统tag_info表只有tag_id和item_code,现在要存AGV ID和最后识别时间。不改表结构,用JSON字段:
-- 在tag_info表加spec_json字段(如果不存在) ALTER TABLE tag_info ADD COLUMN spec_json JSON DEFAULT '{}'; -- 更新已有标签,补默认值 UPDATE tag_info SET spec_json = '{}' WHERE spec_json IS NULL;后端Java Bean加字段:
// com.warehouse.entity.TagInfo.java public class TagInfo { private String tagId; private String itemCode; @TableField(typeHandler = JacksonTypeHandler.class) private Map<String, Object> specJson; // 自动序列化JSON // getter/setter... public String getAgvId() { return (String) specJson.getOrDefault("agv_id", ""); } public void setAgvId(String agvId) { specJson.put("agv_id", agvId); } }6.2 第二步:接入AGV定位硬件,替换RFID模拟器
客户用的AGV自带UWB定位模块,通过MQTT上报坐标。我们写个agv-connector模块,订阅MQTT主题,把坐标转成“虚拟RFID扫描事件”:
// agv-connector/src/main/java/com/warehouse/agv/MqttAgvListener.java @Component public class MqttAgvListener { @Autowired private RfidEventPublisher eventPublisher; // 复用原有RFID事件总线 @EventListener public void onAgvLocation(MqttMessage message) { String payload = new String(message.getPayload()); AgvLocation loc = JsonUtil.parseObject(payload, AgvLocation.class); // 将AGV位置映射为“在某库位附近扫描到标签” if (loc.getX() > 10.0 && loc.getX() < 10.5 && loc.getY() > 5.0 && loc.getY() < 5.5) { // 坐标(10.2,5.2)对应库位"STORAGE_ZONE_2A" eventPublisher.publishScanEvent( new RfidScanEvent("E200400123456789ABCDEF00", "AGV-001", "STORAGE_ZONE_2A", System.currentTimeMillis()) ); } } }这里没写UWB坐标转库位的复杂算法,而是用简单矩形框(bounding box)匹配。实际项目中,我们用GeoHash把坐标转成字符串,再用Redis GEOSEARCH查最近库位,精度更高。
6.3 第三步:扩展状态机,支持“AGV待命→AGV取货→AGV送达”
在statemachine-config.yaml里加新状态和流转:
states: - id: AGV_IDLE - id: AGV_PICKING - id: AGV_DELIVERING transitions: - source: IN_STOCK target: AGV_IDLE event: ASSIGN_TO_AGV - source: AGV_IDLE target: AGV_PICKING event: START_PICKING - source: AGV_PICKING target: AGV_DELIVERING event: PICKING_COMPLETE - source: AGV_DELIVERING target: IN_STOCK event: DELIVERY_CONFIRMED然后在InboundListener里加一个新事件监听器:
@Component public class AgvAssignmentListener { @EventListener public void onAgvAssignment(AgvAssignEvent event) { // 查找最近的空闲AGV AgvVehicle agv = agvService.findNearestIdle(event.getLocation()); // 发送指令到AGV控制中心(HTTP或MQTT) agvControlService.sendCommand(agv.getId(), "MOVE_TO", event.getPickupLocation()); // 更新状态机 stateMachine.sendEvent( MessageBuilder.withPayload(AgvEvents.ASSIGN_TO_AGV) .setHeader("agv_id", agv.getId()) .setHeader("item_code", event.getItemCode()) .build() ); } }6.4 验证与上线:用“影子模式”零风险切流
上线前最怕影响生产。我们的做法是:新AGV流程先走“影子模式”——所有AGV事件都发两份:一份走新状态机,一份走老RFID流程,但只让老流程真正扣减库存。日志里对比两条链路的结果,一致后再切流。具体实现就是在AgvAssignEvent处理器里加个开关:
// application.yml agv: shadow-mode: true # 上线前设为true,验证无误后改为false // AgvAssignmentListener.java if (agvProperties.isShadowMode()) { // 影子模式:只记录,不执行 log.info("Shadow mode: AGV assignment recorded but not executed"); return; } // 正常执行...从那以后我每次接硬件集成项目,都强制走一遍影子模式——哪怕多花2天,也比半夜被电话叫醒修线上Bug强。希望帮到你。
本文还有配套的精品资源,点击获取