跳转到内容

签到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(文件系统)节点已安装

系统使用基于 Node-RED 服务器上文件存在性的简单状态追踪机制:

┌─────────────────────┐
│ 检测到 RFID 标签 │
│ (MQTT 消息) │
└──────────┬──────────┘
┌───────▼────────┐
│ 检查文件: │
│ timerecord.txt │
└───────┬────────┘
┌────────────┴────────────┐
│ │
┌────▼────┐ ┌────▼────┐
│ 文件不存在│ │ 文件存在 │
└────┬────┘ └────┬────┘
│ │
┌────▼────┐ ┌────▼──────────┐
│ 签到 │ │ 签退 │
│ │ │ │
│ 创建 │ │ 读取文件 │
│ JSON │ │ 更新 t2 │
│ 文件 │ │ POST 到 API │
│ 含 t1 │ │ 删除文件 │
└─────────┘ └────────────────┘

关键逻辑

  • 签到(文件不存在):创建包含开始时间戳的 JSON 文件
  • 签退(文件存在):读取文件,添加结束时间戳,POST 到 TimeTagger,删除文件

为什么使用平面文件而不是内存变量?

原因说明
持久性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 节点”
Terminal window
# 在 Node-RED 容器或终端中
npm install node-red-contrib-fs-ops
# 或通过 Node-RED 面板管理器:
# 搜索 "node-red-contrib-fs-ops" 并安装

或者,使用内置的文件系统节点(如果可用)或安装:

管理面板 → 安装 → 搜索 "node-red-contrib-fs"

以下是签到/签退系统的完整 Node-RED 流程:

流程结构:
[MQTT In:RFID 标签]
[函数:构建记录对象]
[FS Access:检查文件是否存在]
├──[输出 1:文件存在 → 签退]──→[函数:处理签退]
│ │
│ ▼
│ [FS Read:读取文件]
│ │
│ ▼
│ [函数:添加结束时间]
│ │
│ ▼
│ [HTTP 请求:PUT 到 API]
│ │
│ ▼
│ [FS Remove:删除文件]
└──[输出 2:文件不存在 → 签到]──→[函数:处理签到]
[FS Write:创建文件]
节点:MQTT In(RFID 标签)
┌──────────────────────────────────┐
│ 服务器:localhost:1883 │
│ 主题:asset/tracking/tag │
│ QoS:1 │
│ 输出:解析后的 JSON 对象 │
└──────────────────────────────────┘

ESP32 发布

{
"uid": "04A3B2C1",
"name": "ESP32_TEAM"
}
// 函数节点:"构建记录对象"
// 从 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(不可访问):文件不存在 → 这是签到
// 函数节点:"处理签到"
// 创建一个新的 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 │
│ 操作:覆盖文件 │
│ 如果缺失则创建:是 │
└──────────────────────────────────┘
// 函数节点:"处理签退"
// 读取现有文件,使用结束时间更新,发送到 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 反馈用于 LED
msg.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;

成功 POST 到 API 后,删除文件:

节点:FS Remove(删除文件)
┌──────────────────────────────────┐
│ 操作:删除文件 │
│ 文件名:msg.filename │
│ 类型:字符串 │
└──────────────────────────────────┘

流程完成:文件删除后,发送确认 MQTT 消息到 ESP32 用于 LED 反馈。

将此完整流程导入 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": []
}
]

测试完整流程:

Terminal window
# 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 反馈消息已发布
  • 系统重置并准备好下次签到

症状:FS Access 节点始终进入”文件存在”路径

原因:文件路径不正确或权限问题

解决方案

Terminal window
# 检查 Node-RED 数据目录
docker exec -it nodered ls -la /data/
# 确保可写
docker exec -it nodered touch /data/test.txt

症状:PUT 请求失败或返回错误

原因:签入和签出之间记录键发生了变化

解决方案:存储确切的记录对象并使用未更改的原始值

症状:如果文件存在但用户再次签到

解决方案:在流程中处理边界情况:

if (msg.action === "CHECK_IN" && fileExists) {
node.warn("用户已签到!");
// 发布错误反馈
}
  • 建议:始终在删除文件前验证 API 响应
  • 建议:为文件读/写失败添加错误处理
  • 建议:如果需要多用户支持,为每个用户使用唯一的文件名
  • 避免:硬编码文件路径——使用 Node-RED 环境变量
  • 避免:在确认 API 成功之前删除文件
  1. 状态追踪使用服务器上的简单 JSON 平面文件
  2. 签到:文件不存在 → 使用开始时间戳创建文件
  3. 签退:文件存在 → 读取文件,添加结束时间,POST 到 API,删除文件
  4. 完整流程整合了 Node-RED 中的 MQTT、文件系统和 HTTP 节点
  5. MQTT 反馈通知 ESP32 点亮相应的 LED