签到API集成
签到API集成
本节将 RFID 标签读取、HTTP 请求和基于文件的状态管理组合成一个完整的 Node-RED 签到/签退流程。学习完本节后,您将能够:
- 构建用于基于 RFID 的签到/签退的完整 Node-RED 流程
- 实现基于文件的状态追踪以区分签到和签退
- 处理完整的生命周期:标签扫描 → 文件检查 → 创建/更新 → 删除
- 端到端调试和测试完整流程
开始本节前,请确保您已完成:
- ESP32 读取 RFID 标签并发布到 MQTT(05-04)
- TimeTagger 已安装且 API 令牌已就绪(05-05、05-06)
- HTTP POST 请求已测试(05-07)
- Node-RED 已安装且 MQTT 已配置
- Node-RED FS(文件系统)节点已安装
签到/签退状态机
Section titled “签到/签退状态机”系统使用基于 Node-RED 服务器上文件存在性的简单状态追踪机制:
┌─────────────────────┐ │ 检测到 RFID 标签 │ │ (MQTT 消息) │ └──────────┬──────────┘ │ ┌───────▼────────┐ │ 检查文件: │ │ timerecord.txt │ └───────┬────────┘ │ ┌────────────┴────────────┐ │ │ ┌────▼────┐ ┌────▼────┐ │ 文件不存在│ │ 文件存在 │ └────┬────┘ └────┬────┘ │ │ ┌────▼────┐ ┌────▼──────────┐ │ 签到 │ │ 签退 │ │ │ │ │ │ 创建 │ │ 读取文件 │ │ JSON │ │ 更新 t2 │ │ 文件 │ │ POST 到 API │ │ 含 t1 │ │ 删除文件 │ └─────────┘ └────────────────┘关键逻辑:
- 签到(文件不存在):创建包含开始时间戳的 JSON 文件
- 签退(文件存在):读取文件,添加结束时间戳,POST 到 TimeTagger,删除文件
基于文件的状态存储
Section titled “基于文件的状态存储”为什么使用平面文件而不是内存变量?
| 原因 | 说明 |
|---|---|
| 持久性 | Node-RED 重启后仍然保持 |
| 简单性 | 无需数据库 |
| 可调试 | 轻松检查文件内容 |
| 单用户 | 适合演示/概念验证场景 |
文件位置:Node-RED 容器内的 /data/timerecord.txt
文件格式(JSON):
[ { "key": "1715942400a1b2c3d4e5f6", "t1": 1715942400, "t2": 0, "ds": "ESP32_TEAM - 签到", "mt": 1715942400, "st": 0 }]步骤 1:安装所需的 Node-RED 节点
Section titled “步骤 1:安装所需的 Node-RED 节点”# 在 Node-RED 容器或终端中npm install node-red-contrib-fs-ops
# 或通过 Node-RED 面板管理器:# 搜索 "node-red-contrib-fs-ops" 并安装或者,使用内置的文件系统节点(如果可用)或安装:
管理面板 → 安装 → 搜索 "node-red-contrib-fs"步骤 2:创建完整流程
Section titled “步骤 2:创建完整流程”以下是签到/签退系统的完整 Node-RED 流程:
流程结构:[MQTT In:RFID 标签] │ ▼[函数:构建记录对象] │ ▼[FS Access:检查文件是否存在] │ ├──[输出 1:文件存在 → 签退]──→[函数:处理签退] │ │ │ ▼ │ [FS Read:读取文件] │ │ │ ▼ │ [函数:添加结束时间] │ │ │ ▼ │ [HTTP 请求:PUT 到 API] │ │ │ ▼ │ [FS Remove:删除文件] │ └──[输出 2:文件不存在 → 签到]──→[函数:处理签到] │ ▼ [FS Write:创建文件]步骤 3:MQTT In 节点配置
Section titled “步骤 3:MQTT In 节点配置”节点:MQTT In(RFID 标签)┌──────────────────────────────────┐│ 服务器:localhost:1883 ││ 主题:asset/tracking/tag ││ QoS:1 ││ 输出:解析后的 JSON 对象 │└──────────────────────────────────┘ESP32 发布:
{ "uid": "04A3B2C1", "name": "ESP32_TEAM"}步骤 4:构建记录对象函数节点
Section titled “步骤 4:构建记录对象函数节点”// 函数节点:"构建记录对象"// 从 RFID 标签数据创建初始记录对象
const authToken = global.get("timetaggerAuth")?.token || "您的_TOKEN";const baseUrl = global.get("timetaggerAuth")?.baseUrl || "http://timetagger:80/api/v2";
// 从 MQTT 消息中提取标签信息const uid = msg.payload.uid || "未知";const tagName = msg.payload.name || uid;
// 生成唯一键const now = Math.floor(Date.now() / 1000);const key = now.toString(16) + Math.random().toString(36).substring(2, 10);
// 存储记录数据供后续使用msg.record = { key: key, t1: now, t2: 0, // 将在签退时设置 ds: tagName + " - " + uid, mt: now, st: 0};
// 存储认证信息msg.authToken = authToken;msg.baseUrl = baseUrl;
// 存储文件路径msg.filePath = "/data/timerecord.txt";msg.fileName = "timerecord.txt";
return msg;步骤 5:FS Access 节点(检查文件存在性)
Section titled “步骤 5:FS Access 节点(检查文件存在性)”节点:FS Access(检查文件)┌──────────────────────────────────┐│ 操作:Access ││ 文件名:msg.filePath ││ 类型:msg.fileName ││ 输出 1:文件可访问 ││ 输出 2:文件不可访问 │└──────────────────────────────────┘工作原理:
- 输出 1(可访问):文件存在 → 这是签退
- 输出 2(不可访问):文件不存在 → 这是签到
步骤 6:处理签到流程
Section titled “步骤 6:处理签到流程”// 函数节点:"处理签到"// 创建一个新的 JSON 文件,包含初始记录
// 获取记录数据const record = msg.record;const filePath = msg.filePath;
// 文件内容应为 JSON 数组const fileContent = JSON.stringify([record]);
// 设置写操作msg.filename = "timerecord.txt"; // 简单文件名(相对于 Node-RED 数据目录)msg.filedata = fileContent;
// 同时发布确认消息到 MQTT 用于 LED 反馈const confirmMsg = { topic: "asset/tracking/feedback", payload: JSON.stringify({ action: "CHECK_IN", name: record.ds, key: record.key, t1: record.t1 })};
// 发送到两个输出return [[msg], [confirmMsg]];FS Write 节点:
节点:FS Write(写入文件)┌──────────────────────────────────┐│ 操作:写入文件 ││ 文件名:msg.filename ││ 数据:msg.filedata ││ 编码:utf8 ││ 操作:覆盖文件 ││ 如果缺失则创建:是 │└──────────────────────────────────┘步骤 7:处理签退流程
Section titled “步骤 7:处理签退流程”// 函数节点:"处理签退"// 读取现有文件,使用结束时间更新,发送到 API
const record = msg.record;const authToken = msg.authToken;const baseUrl = msg.baseUrl;const now = Math.floor(Date.now() / 1000);
// FS Read 节点会将文件内容放入 msg.payload// 我们需要解析它并更新结束时间
// 为后续节点存储认证信息msg.authToken = authToken;msg.baseUrl = baseUrl;
// 为下一步存储msg.currentTime = now;
return msg;FS Read 节点:
节点:FS Read(读取文件)┌──────────────────────────────────┐│ 操作:读取文件 ││ 文件名:msg.filename ││ 编码:utf8 ││ 输出:msg.payload │└──────────────────────────────────┘步骤 8:添加结束时间并 POST 到 API
Section titled “步骤 8:添加结束时间并 POST 到 API”// 函数节点:"添加结束时间并发送"// 使用结束时间更新记录并准备 API 请求
const authToken = msg.authToken;const baseUrl = msg.baseUrl;const now = msg.currentTime;
// 解析存储的记录let records;try { records = JSON.parse(msg.payload);} catch (e) { node.error("解析存储的记录失败:" + e.toString()); msg.status = { fill: "red", shape: "dot", text: "解析错误" }; return null;}
if (!Array.isArray(records) || records.length === 0) { node.error("文件中记录数组无效"); return null;}
// 使用结束时间更新记录const record = records[0];record.t2 = now; // 设置结束时间为现在record.mt = now; // 更新修改时间
// 准备 HTTP 请求msg.headers = { "Authorization": "Bearer " + authToken, "Content-Type": "application/json"};msg.method = "PUT";msg.url = baseUrl + "/records";msg.payload = [record]; // 数组包装
// 同时准备 MQTT 反馈用于 LEDmsg.feedback = { topic: "asset/tracking/feedback", payload: JSON.stringify({ action: "CHECK_OUT", name: record.ds, key: record.key, t1: record.t1, t2: record.t2, duration: record.t2 - record.t1 })};
return msg;步骤 9:签退后删除文件
Section titled “步骤 9:签退后删除文件”成功 POST 到 API 后,删除文件:
节点:FS Remove(删除文件)┌──────────────────────────────────┐│ 操作:删除文件 ││ 文件名:msg.filename ││ 类型:字符串 │└──────────────────────────────────┘流程完成:文件删除后,发送确认 MQTT 消息到 ESP32 用于 LED 反馈。
步骤 10:完整流程 JSON
Section titled “步骤 10:完整流程 JSON”将此完整流程导入 Node-RED:
[ { "id": "mqtt-in-tag", "type": "mqtt in", "name": "RFID 标签", "topic": "asset/tracking/tag", "qos": "1", "broker": "localhost", "wires": [["build-record"]] }, { "id": "build-record", "type": "function", "name": "构建记录", "func": "const authToken = global.get(\"timetaggerAuth\")?.token || \"您的_TOKEN\";\nconst baseUrl = global.get(\"timetaggerAuth\")?.baseUrl || \"http://timetagger:80/api/v2\";\nconst uid = msg.payload.uid || \"未知\";\nconst tagName = msg.payload.name || uid;\nconst now = Math.floor(Date.now() / 1000);\nconst key = now.toString(16) + Math.random().toString(36).substring(2, 10);\nmsg.record = { key: key, t1: now, t2: 0, ds: tagName, mt: now, st: 0 };\nmsg.authToken = authToken;\nmsg.baseUrl = baseUrl;\nmsg.filename = \"timerecord.txt\";\nreturn msg;", "wires": [["fs-access-check"]] }, { "id": "fs-access-check", "type": "fs-access", "name": "检查文件", "path": "/data/timerecord.txt", "wire": false, "wires": [["check-out-flow"], ["check-in-flow"]] }, { "id": "check-out-flow", "type": "function", "name": "签退", "func": "msg.authToken = msg.authToken;\nmsg.baseUrl = msg.baseUrl;\nmsg.currentTime = Math.floor(Date.now() / 1000);\nmsg.filename = \"timerecord.txt\";\nreturn msg;", "wires": [["fs-read-file"]] }, { "id": "fs-read-file", "type": "fs-read", "name": "读取文件", "filename": "timerecord.txt", "format": "utf8", "wires": [["update-and-send"]] }, { "id": "update-and-send", "type": "function", "name": "更新并发送", "func": "const authToken = msg.authToken;\nconst baseUrl = msg.baseUrl;\nconst now = msg.currentTime;\nlet records;\ntry { records = JSON.parse(msg.payload); } catch(e) { return null; }\nif (!Array.isArray(records) || records.length === 0) return null;\nrecords[0].t2 = now;\nrecords[0].mt = now;\nmsg.headers = { \"Authorization\": \"Bearer \" + authToken, \"Content-Type\": \"application/json\" };\nmsg.method = \"PUT\";\nmsg.url = baseUrl + \"/records\";\nmsg.payload = [records[0]];\nreturn msg;", "wires": [["http-put-api", "mqtt-feedback-out"]] }, { "id": "http-put-api", "type": "http request", "name": "PUT 到 TimeTagger", "method": "PUT", "ret": "txt", "url": "", "tls": "", "wires": [["fs-remove-file"]] }, { "id": "fs-remove-file", "type": "fs-remove", "name": "删除文件", "path": "timerecord.txt", "wires": [["debug-result"]] }, { "id": "check-in-flow", "type": "function", "name": "签到", "func": "const record = msg.record;\nconst fileContent = JSON.stringify([record]);\nmsg.filedata = fileContent;\nmsg.filename = \"timerecord.txt\";\nreturn msg;", "wires": [["fs-write-file", "mqtt-feedback-in"]] }, { "id": "fs-write-file", "type": "fs-write", "name": "写入文件", "filename": "timerecord.txt", "format": "utf8", "wires": [] }, { "id": "mqtt-feedback-in", "type": "mqtt out", "name": "签到反馈", "topic": "asset/tracking/feedback", "qos": "1", "broker": "localhost", "wires": [] }, { "id": "mqtt-feedback-out", "type": "mqtt out", "name": "签退反馈", "topic": "asset/tracking/feedback", "qos": "1", "broker": "localhost", "wires": [] }, { "id": "debug-result", "type": "debug", "name": "结果", "active": true, "wires": [] }]测试完整流程:
# 1. 模拟签到:mosquitto_pub -t "asset/tracking/tag" \ -m '{"uid":"04A3B2C1","name":"ESP32_TEAM"}'
# 预期:# - 文件已创建:/data/timerecord.txt# - MQTT 反馈:{"action":"CHECK_IN","name":"ESP32_TEAM",...}
# 2. 验证文件存在:docker exec -it nodered cat /data/timerecord.txt# 预期:[{"key":"...","t1":...,"t2":0,"ds":"ESP32_TEAM",...}]
# 3. 模拟签退(再次发布同一标签):mosquitto_pub -t "asset/tracking/tag" \ -m '{"uid":"04A3B2C1","name":"ESP32_TEAM"}'
# 预期:# - 文件被读取并删除# - PUT 请求到 TimeTagger API# - 新记录出现在 TimeTagger 中# - MQTT 反馈:{"action":"CHECK_OUT",...}验证清单:
- 首次标签扫描创建文件(签到)
- 第二次标签扫描将记录发送到 API(签退)
- 签退后文件被删除
- 记录正确出现在 TimeTagger 中
- MQTT 反馈消息已发布
- 系统重置并准备好下次签到
问题 1:签到时未创建文件
Section titled “问题 1:签到时未创建文件”症状:FS Access 节点始终进入”文件存在”路径
原因:文件路径不正确或权限问题
解决方案:
# 检查 Node-RED 数据目录docker exec -it nodered ls -la /data/# 确保可写docker exec -it nodered touch /data/test.txt问题 2:签退时 API 返回错误
Section titled “问题 2:签退时 API 返回错误”症状:PUT 请求失败或返回错误
原因:签入和签出之间记录键发生了变化
解决方案:存储确切的记录对象并使用未更改的原始值
问题 3:重复签到检测
Section titled “问题 3:重复签到检测”症状:如果文件存在但用户再次签到
解决方案:在流程中处理边界情况:
if (msg.action === "CHECK_IN" && fileExists) { node.warn("用户已签到!"); // 发布错误反馈}- ✅ 建议:始终在删除文件前验证 API 响应
- ✅ 建议:为文件读/写失败添加错误处理
- ✅ 建议:如果需要多用户支持,为每个用户使用唯一的文件名
- ❌ 避免:硬编码文件路径——使用 Node-RED 环境变量
- ❌ 避免:在确认 API 成功之前删除文件
- 状态追踪使用服务器上的简单 JSON 平面文件
- 签到:文件不存在 → 使用开始时间戳创建文件
- 签退:文件存在 → 读取文件,添加结束时间,POST 到 API,删除文件
- 完整流程整合了 Node-RED 中的 MQTT、文件系统和 HTTP 节点
- MQTT 反馈通知 ESP32 点亮相应的 LED