n8n 不只能连接在线应用,也可以处理本地文件。 这篇做一个订单日报:读 CSV → 解析 → Code 节点按城市汇总 → 生成 Markdown 报告写回磁盘。用
n8n execute在命令行跑,方便放进 cron;再用 Python 独立重算一遍核对结果。
先看清成品
- 在 box 上真实执行成功:8 条订单,7 条有效,合计 771 元,缺数量的 A1008 列入“需要人工核对”。
- 遇到两个真实报错:读文件被拒
Access to the file is not allowed.;n8n 运行时 CLI 执行报 5679 端口被占用。都已解决。- Python 重算结果与报告一致,pytest 2 项通过。
合规提醒:n8n 用 Sustainable Use License(fair-code):自用或公司内部自托管可以免费用,把它作为服务卖给别人有限制,以官方 LICENSE.md 为准。本文只用官方 npm 包和官方 CLI。订单数据属于个人信息,只收必要字段,并告知用途。
运行说明:全部在 box 上真实运行(n8n 2.44.5,Node.js 24.22.1,2026-10-21 21:19–21:22,UTC+8)。box 不能用 Docker(没有守护进程权限),所以按官方 npm 方式安装。第一次用 Node 22 安装失败,npm 只报
Tracker "idealTree" already exists,看不到真正原因。npm view n8n engines显示 2.44.5 要求node >=24.0.0,而官方安装页写的是 20.19 到 24.x。换成官方 Node 24 后安装成功。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
daily_report.json |
工作流(6 个节点) |
data/orders.csv |
示例订单(我编的) |
run.sh |
导入并执行 |
test_report.py |
用 Python 核对报告 |
需要准备哪些环境?
| 项目 | 说明 |
|---|---|
| Node.js | 24.x(以 npm view n8n engines 为准) |
| n8n | npm install n8n(本文 2.44.5),或按官方文档用 Docker |
| 数据目录 | N8N_USER_FOLDER 指定;里面有加密密钥,删掉后已存凭据无法解密 |
| Python | 3.9 以上 + pytest,用来写测试 |
关键事实
| 事实 | 来源 |
|---|---|
N8N_RESTRICT_FILE_ACCESS_TO 限制 n8n 能访问的目录,多个目录用分号分隔 |
官方 安全环境变量 |
任务代理端口 N8N_RUNNERS_BROKER_PORT 默认 5679 |
官方 Task runners 环境变量 |
n8n execute --id=<ID> 在命令行执行一个工作流 |
官方 CLI 文档 |
| Read/Write Files from Disk 节点读写 n8n 所在机器上的文件 | 官方节点文档 |
文件结构
82-n8n-B-src/
├── daily_report.json
├── data/
│ └── orders.csv
├── run.sh
└── test_report.py
第 1 步:示例数据
order_id,date,city,product,qty,amount
A1001,2026-10-19,上海,咖啡豆,2,136
A1002,2026-10-19,杭州,挂耳包,5,95
A1003,2026-10-20,上海,挂耳包,3,57
A1004,2026-10-20,北京,咖啡豆,1,68
A1005,2026-10-20,杭州,咖啡豆,2,136
A1006,2026-10-21,上海,滤杯,1,89
A1007,2026-10-21,北京,挂耳包,10,190
A1008,2026-10-21,上海,咖啡豆,,68
第 2 步:工作流 JSON
{
"id": "dailyReport0001",
"name": "订单日报(读 CSV → 汇总 → 写 Markdown)",
"active": false,
"settings": {"executionOrder": "v1"},
"nodes": [
{"id": "b1", "name": "手动触发", "type": "n8n-nodes-base.manualTrigger", "typeVersion": 1, "position": [0, 0], "parameters": {}},
{"id": "b2", "name": "读取 CSV", "type": "n8n-nodes-base.readWriteFile", "typeVersion": 1, "position": [200, 0],
"parameters": {"operation": "read", "fileSelector": "/tmp/n8n-files/orders.csv", "options": {}}},
{"id": "b3", "name": "解析 CSV", "type": "n8n-nodes-base.extractFromFile", "typeVersion": 1, "position": [400, 0],
"parameters": {"operation": "csv", "options": {"headerRow": true}}},
{"id": "b4", "name": "汇总", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [600, 0],
"parameters": {"mode": "runOnceForAllItems", "jsCode": "const rows = $input.all().map(i => i.json);\nconst byCity = {};\nconst problems = [];\nlet total = 0;\nfor (const r of rows) {\n const amount = Number(r.amount);\n const qty = r.qty === '' || r.qty == null ? NaN : Number(r.qty);\n if (!Number.isFinite(amount) || !Number.isInteger(qty)) { problems.push(`${r.order_id}: qty=${JSON.stringify(r.qty)} amount=${JSON.stringify(r.amount)}`); continue; }\n byCity[r.city] = (byCity[r.city] || 0) + amount;\n total += amount;\n}\nconst lines = ['# 订单日报', '', `有效订单 ${rows.length - problems.length} 条,合计 ${total} 元`, '', '| 城市 | 金额(元) |', '| :--- | ---: |'];\nfor (const [c, v] of Object.entries(byCity).sort((a, b) => b[1] - a[1])) lines.push(`| ${c} | ${v} |`);\nif (problems.length) { lines.push('', '## 需要人工核对', ...problems.map(p => `- ${p}`)); }\nreturn [{ json: { markdown: lines.join('\\n'), total, problems } }];"}},
{"id": "b5", "name": "转成文件", "type": "n8n-nodes-base.convertToFile", "typeVersion": 1.1, "position": [800, 0],
"parameters": {"operation": "toText", "sourceProperty": "markdown", "options": {"fileName": "report.md"}}},
{"id": "b6", "name": "写入报告", "type": "n8n-nodes-base.readWriteFile", "typeVersion": 1, "position": [1000, 0],
"parameters": {"operation": "write", "fileName": "/tmp/n8n-files/report.md", "options": {}}}
],
"connections": {
"手动触发": {"main": [[{"node": "读取 CSV", "type": "main", "index": 0}]]},
"读取 CSV": {"main": [[{"node": "解析 CSV", "type": "main", "index": 0}]]},
"解析 CSV": {"main": [[{"node": "汇总", "type": "main", "index": 0}]]},
"汇总": {"main": [[{"node": "转成文件", "type": "main", "index": 0}]]},
"转成文件": {"main": [[{"node": "写入报告", "type": "main", "index": 0}]]}
}
}
第 3 步:运行脚本
#!/usr/bin/env bash
# 导入并用 CLI 执行一次“订单日报”工作流。
set -euo pipefail
export N8N_RESTRICT_FILE_ACCESS_TO=/tmp/n8n-files # 只允许读写这个目录
mkdir -p /tmp/n8n-files && cp data/orders.csv /tmp/n8n-files/
npx n8n import:workflow --input=daily_report.json
# 如果 n8n start 正在运行,任务代理已占用 5679,CLI 执行要换一个端口
N8N_RUNNERS_BROKER_PORT=5680 npx n8n execute --id=dailyReport0001 > execution.json
cat /tmp/n8n-files/report.md
第 4 步:核对测试
"""用 Python 独立重算一遍,核对 n8n 写出的 report.md(需要先运行工作流)。"""
import csv, pathlib, re
import pytest
REPORT = pathlib.Path("/tmp/n8n-files/report.md")
def expected():
by_city, total, bad = {}, 0, []
for r in csv.DictReader(open("data/orders.csv", encoding="utf-8")):
if not r["qty"].strip().isdigit():
bad.append(r["order_id"]); continue
by_city[r["city"]] = by_city.get(r["city"], 0) + int(r["amount"]); total += int(r["amount"])
return by_city, total, bad
@pytest.mark.skipif(not REPORT.exists(), reason="先运行 n8n 工作流")
def test_report_matches_python():
md = REPORT.read_text(encoding="utf-8")
by_city, total, bad = expected()
assert f"合计 {total} 元" in md
for c, v in by_city.items():
assert f"| {c} | {v} |" in md
for o in bad:
assert re.search(rf"- {o}:", md)
def test_expected_values():
by_city, total, bad = expected()
assert total == 771 and by_city == {"上海": 282, "杭州": 231, "北京": 258} and bad == ["A1008"]
第 5 步:运行
$ ./run.sh
Successfully imported 1 workflow.
$ tail -4 execution.json
"storedAt": "db",
"status": "success",
"finished": true
}
$ cat /tmp/n8n-files/report.md
# 订单日报
有效订单 7 条,合计 771 元
| 城市 | 金额(元) |
| :--- | ---: |
| 上海 | 282 |
| 北京 | 258 |
| 杭州 | 231 |
## 需要人工核对
- A1008: qty=undefined amount="68"
$ python -m pytest -q
2 passed
怎么检查结果?
- 手算:上海 136+57+89=282,北京 68+190=258,杭州 95+136=231,合计 771。
- “需要人工核对”里的订单不计入合计,要找人补数据。
- 放进 cron 时把
run.sh的输出重定向到日志,execution.json里"status"不是 success 就报警。
调试示例
1. 读文件被拒。 第一次执行:
Execution error:
====================================
Access to the file is not allowed.
NodeApiError: Access to the file is not allowed.
at ExecuteContext.execute (.../ReadWriteFile/actions/read.operation.ts:213:10)
n8n 2.44.5 默认不允许 Read/Write Files 节点访问 /tmp/n8n-files。设置 N8N_RESTRICT_FILE_ACCESS_TO=/tmp/n8n-files 后成功。只放开这一个目录,不要设成 /。
2. CLI 执行和正在运行的 n8n 冲突。 A 篇的 n8n start 还开着,执行时报:
n8n Task Broker's port 5679 is already in use. Do you have another instance of n8n running already?
CLI 执行也会启动一个任务代理(运行 Code 节点用的)。加 N8N_RUNNERS_BROKER_PORT=5680 后正常。
3. 空字段是 undefined,不是空字符串。 A1008 的 qty 在 CSV 里是空的,报告写的是 qty=undefined。我的判断条件是 r.qty === '' || r.qty == null,两种情况都处理了;只写 === '' 的话,这条会变成 NaN 被当成“不是整数”,结果碰巧一样,但原因说错了。
常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
| Access to the file is not allowed. | 目录不在允许范围 | 设置 N8N_RESTRICT_FILE_ACCESS_TO |
| port 5679 is already in use | 另一个 n8n 在运行 | 换 N8N_RUNNERS_BROKER_PORT |
| 中文乱码 | CSV 不是 UTF-8 | 先转成 UTF-8 |
| 金额合计不对 | 有空值或非数字 | 看“需要人工核对”一节 |
模板规则
- 只放开必要的目录。
- 有问题的行单独列出,不静默丢弃。
- 用另一种语言独立核对一次。
- CLI 执行检查 status 字段。
项目资源
| 用途 | 地址 |
|---|---|
| n8n npm 安装(官方) | https://docs.n8n.io/deploy/host-n8n/install-options/install-with-npm |
| n8n CLI 命令(官方) | https://docs.n8n.io/deploy/host-n8n/configure-n8n/use-the-command-line |
| n8n GitHub | https://github.com/n8n-io/n8n |
| n8n 许可证 | https://github.com/n8n-io/n8n/blob/master/LICENSE.md |
| n8n 安全环境变量(官方) | https://docs.n8n.io/deploy/host-n8n/configure-n8n/basic-configuration/use-environment-variables/security |
| n8n Task runners 环境变量(官方) | https://docs.n8n.io/deploy/host-n8n/configure-n8n/basic-configuration/use-environment-variables/task-runners |
| Read/Write Files from Disk(官方) | https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.readwritefile |
| n8n Code 节点(官方) | https://docs.n8n.io/build/code-in-n8n/using-the-code-node |
先用 CLI 跑通、核对,再改成定时触发。