posts: n8n v1→v2 migration, self-healing entitlements, TTS sidecars
Deploy / build (push) Successful in 19s
Deploy / build (push) Successful in 19s
Three new posts (EN + ZH twins, og + banner each): - n8n-v1-to-v2-upgrade-gotchas (devops): the seven deprecations that surfaced upgrading 1.123.x → 2.40.1, decoded from the boot log — telemetry schema rejection, N8N_WEBHOOK_URL rename, internal runner deprecation, task timeout 300s→60s, two compression limits, v3 storage rename, plus the DB override that silently disabled the AI sandbox. - self-healing-digital-goods-entitlements (case-studies): the W1–W5 NocoDB → n8n → AList entitlement lifecycle. Build-time code sharing for n8n Code nodes, MAX-expiry semantics, dry-run safety, daily drift repair, CORS-not-HMAC reasoning, and the public→internal NocoDB cascading-failure fix (504 → retry storm → 503). - running-tts-as-a-service-with-token-sidecars (ai): a year-long TTS service built on two cron containers that refresh Azure/Google tokens into a shared file, with the speed/voice mapping layer. banner-gen: center terminal body vertically so line counts shorter than the fixed 690px panel don't leave a dead void at the bottom. Verified via DOM measurement (gapAbove 104 / gapBelow 106).
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
---
|
||||
title: "n8n v1 升级到 v2:一个日志文件里的七项废弃警告"
|
||||
description: "我的自建 n8n 从 1.x 升级到 2.40.1,一次性暴露出七处静默失效——包括一个被 schema 校验拒绝的遥测事件、废弃的 webhook 变量,以及一个悄悄把 AI 沙盒关掉的数据库覆盖值。"
|
||||
pubDate: 2026-09-18
|
||||
category: devops
|
||||
tags: [n8n, docker, upgrade, self-hosting, debugging, automation]
|
||||
ogImage: /og/n8n-v1-to-v2-upgrade-gotchas.png
|
||||
banner: /banners/n8n-v1-to-v2-upgrade-gotchas.png
|
||||
---
|
||||
|
||||
n8n 是我整个自建技术栈的自动化中枢——它负责文件交付权限、数据库备份,还有一套阅读应用依赖的语音合成 API。它一直停留在 `1.123.x` 版本线上快一年了,默默干活,没出过什么问题。
|
||||
|
||||
然后我拉了 `n8nio/n8n:2.40.1` 镜像并重启容器。升级本身只花了大约九十秒。而搞清楚它*弄坏了什么*花了我剩下的一整晚——而其中几乎所有信息,n8n 在启动时就已经写在一个日志文件里了。只是我第一次读得不够仔细。
|
||||
|
||||
这篇文章就是那个日志文件的解读,让你在规划自己的 v1 → v2 升级时能提前准备,而不是晚上十一点才发现问题。
|
||||
|
||||
## 为什么这件事重要
|
||||
|
||||
自动化平台的大版本升级,和其他服务升级的性质不一样。n8n 是*运行其他一切东西的那个东西*:它一旦起不来,你的备份、权限同步、内部 API 全部跟着停。更麻烦的是,v2 里坏掉的东西大多不会报错——它只打印一次废弃警告,然后就悄悄换了一种行为方式。
|
||||
|
||||
下面这七项,都是在一个真实的、有点混乱的、生产形态的安装上实际命中的。其中三项改变了我的技术栈的行为。还有一项,悄悄把一个功能*关掉了*。
|
||||
|
||||
## 从这里开始:n8n 在启动时会告诉你哪里不对
|
||||
|
||||
在动任何工作流之前,先从头读容器日志。在 v2 全新的启动过程中,n8n 会明确打印出一整块废弃警告:
|
||||
|
||||
```text
|
||||
There are deprecations related to your n8n setup. Please take the recommended
|
||||
actions to update your configuration:
|
||||
- WEBHOOK_URL -> Use N8N_WEBHOOK_URL instead, which sets the base URL for
|
||||
both test and production webhooks.
|
||||
- N8N_UNVERIFIED_PACKAGES_ENABLED -> The default for this variable will
|
||||
change to `false` in a future version.
|
||||
- N8N_RUNNERS_MODE -> Internal task runner mode is deprecated and will be
|
||||
removed in a future version.
|
||||
- N8N_RUNNERS_TASK_TIMEOUT -> The default for this variable will be reduced
|
||||
from 300 (5 minutes) to 60 (1 minute) in a future version.
|
||||
- N8N_COMPRESSION_NODE_MAX_DECOMPRESSED_SIZE_BYTES -> The default will be
|
||||
reduced from 2 GiB to 256 MiB in a future version.
|
||||
- N8N_COMPRESSION_NODE_MAX_ZIP_ENTRIES -> The default will be reduced from
|
||||
5000 to 1000 in a future version.
|
||||
```
|
||||
|
||||
这一块就是你的迁移清单。我那七项坑里的六项都在里面。
|
||||
|
||||
## 1. 你的环境变量值现在可能过不了 schema 校验
|
||||
|
||||
这是最让我困惑的一项,因为它看起来完全像一条无意义的报错:
|
||||
|
||||
```text
|
||||
Telemetry event "Instance started" failed schema validation:
|
||||
execution_variables.executions_data_save_on_error: Invalid option:
|
||||
expected one of "all"|"none"
|
||||
```
|
||||
|
||||
我当时设置的是 `EXECUTIONS_DATA_SAVE_ON_ERROR=error`——这个值在 v1 里完全合法,而且是我刻意选的,因为对一台繁忙的实例来说,「只保存失败的执行记录」是合理的默认策略。但在 v2 里这个值已经不在允许集合内了,现在的合法值是 `all` 或 `none`。
|
||||
|
||||
失败方式才是关键。它没有崩溃。它甚至没有以一眼就能看出是错误的方式发出警告——它抛出的是一条 *telemetry schema validation* 消息,听起来像是 n8n 内部的问题,而不是我的配置问题。这个设置实际上被忽略了。
|
||||
|
||||
修法是把你的意图放到一个说得通的地方:选一个合法的值,然后用 pruning 控制数据量。
|
||||
|
||||
```env
|
||||
EXECUTIONS_DATA_SAVE_ON_ERROR=all
|
||||
EXECUTIONS_DATA_SAVE_ON_SUCCESS=none
|
||||
EXECUTIONS_DATA_PRUNE=true
|
||||
EXECUTIONS_DATA_MAX_AGE=336
|
||||
EXECUTIONS_DATA_PRUNE_MAX_COUNT=10000
|
||||
```
|
||||
|
||||
**教训:** 在 v2 里,把你的环境变量当成一套有类型的、带 schema 的接口来看待。一个非法的值可能被静默丢弃,而不是大声拒绝。
|
||||
|
||||
## 2. `WEBHOOK_URL` 已被 `N8N_WEBHOOK_URL` 取代
|
||||
|
||||
如果你的 webhook 发布在反向代理后面——你几乎肯定是的,因为那是它们变得可访问的方式——那么这个 base URL 变量就是关键路径上的东西。它决定了 n8n 报告的是*公网* webhook 路径,还是 `http://localhost:5678/...`。
|
||||
|
||||
旧名字目前还能用,所以这一项不会立刻咬你。但注意它的措辞:新变量为**测试和生产 webhook 同时**设置 base URL。在我的环境里这两者在行为上已经出现了分叉,而这次的合并正是为了消除这一类 bug。
|
||||
|
||||
```env
|
||||
# 升级前(仍可用,但已废弃)
|
||||
WEBHOOK_URL=https://auto.example.com/
|
||||
|
||||
# 升级后
|
||||
N8N_WEBHOOK_URL=https://auto.example.com/
|
||||
```
|
||||
|
||||
## 3. Internal task runner 模式要取消了——而我的其实早就坏了
|
||||
|
||||
这一项一直在我的日志里,就在废弃警告块上面几行,而我已经读过去好几个月了:
|
||||
|
||||
```text
|
||||
Failed to start Python task runner in internal mode. because Python 3 is
|
||||
missing from this system. Launching a Python runner in internal mode is
|
||||
intended only for debugging and is not recommended for production.
|
||||
```
|
||||
|
||||
如果你的任何工作流用了 **Python** 的 Code 节点,那它根本就没在 internal 模式下跑起来过——官方镜像里没有 Python。JavaScript 的 Code 节点是正常的(JS runner 会正常注册),所以这个问题可以无限期不被发现:一切*看起来*都是健康的。
|
||||
|
||||
v2 把方向挑明了:切到 `external` 模式,和一个独立启动器进程共享一个 auth token。
|
||||
|
||||
```env
|
||||
N8N_RUNNERS_MODE=external
|
||||
N8N_RUNNERS_AUTH_TOKEN=<一串足够长的随机字符串>
|
||||
```
|
||||
|
||||
**教训:** 「internal 模式已废弃」是标题,但真正的发现是:某一类 runner 可能已经*静默失效*好几个月了。要检查 `docker logs` 里的 runner 注册那行,而不是只看容器起没起来。
|
||||
|
||||
## 4. Task 超时默认值从 300 秒降到 60 秒
|
||||
|
||||
这一项是我最想替所有有慢工作流的人标红的:
|
||||
|
||||
```text
|
||||
N8N_RUNNERS_TASK_TIMEOUT -> The default for this variable will be reduced
|
||||
from 300 (5 minutes) to 60 (1 minute) in a future version.
|
||||
```
|
||||
|
||||
对自己的负载诚实一点。你有没有 Code 节点要遍历几千条记录,或者要调一个很慢的上游接口?我有——一个每夜的对账流程会遍历每个客户,每条记录调一次外部 API。在未来的某次升级中,它会在六十秒处停下,而我这边没有任何配置变更。
|
||||
|
||||
趁你人还在这个文件里,现在就显式设置:
|
||||
|
||||
```env
|
||||
N8N_RUNNERS_TASK_TIMEOUT=300
|
||||
```
|
||||
|
||||
对所有「默认值将会改变」形式的废弃警告,通用原则是:**如果你依赖当前的默认值,就把它显式钉死。** 否则这次升级就是一个静默的行为变更,而你会把它当成 bug 来排查,而不是认出它是一个过期的默认值。
|
||||
|
||||
## 5 和 6. 两个压缩节点上限缩水(2 GiB → 256 MiB,5000 → 1000 条)
|
||||
|
||||
这两项一起出现,只有当你在工作流里处理大负载的压缩/解压节点时才相关——而当你在工作流里搬运数据库导出或归档文件时,很容易就变成相关。
|
||||
|
||||
```text
|
||||
N8N_COMPRESSION_NODE_MAX_DECOMPRESSED_SIZE_BYTES -> reduced from 2 GiB to
|
||||
256 MiB in a future version.
|
||||
N8N_COMPRESSION_NODE_MAX_ZIP_ENTRIES -> reduced from 5000 to 1000 in a
|
||||
future version.
|
||||
```
|
||||
|
||||
内存上限降到八分之一,条目上限降到五分之一。不会报错;节点只是在一个你没设定的阈值处拒绝执行。如果你接近任何一个上限,两个都钉死。
|
||||
|
||||
## 7. 存储路径将在 v3 改名——而你的卷正好挂在旧路径上
|
||||
|
||||
这不是 v2 的破坏性变更,但 v2 会警告它,而且是真正涉及数据规划的那一项:
|
||||
|
||||
```text
|
||||
Deprecation warning: The storage directory "/home/node/.n8n/binaryData" will
|
||||
be renamed to "/home/node/.n8n/storage" in n8n v3. To migrate now, set
|
||||
N8N_MIGRATE_FS_STORAGE_PATH=true. If you have a volume mounted at the old
|
||||
path, update your mount configuration after migration.
|
||||
```
|
||||
|
||||
再读一遍最后一句:*如果你有一个卷挂载在旧路径上,请在迁移后更新你的挂载配置。* 如果你设了迁移标志却保留旧的 bind mount,你现在就有两个目录,而你的二进制数据住在容器实际指向的那一个里。把改名和挂载变更放在同一个维护窗口里做——不要一个现在、一个「以后」。
|
||||
|
||||
## 那个不在日志里的:我的 AI 沙盒把自己关掉了
|
||||
|
||||
这是跟废弃警告毫无关系的发现,也是如果我不读完整的启动序列就永远抓不到的一项:
|
||||
|
||||
```text
|
||||
Sandbox: enabled=false provider=n8n-sandbox (DB override; env was enabled=true
|
||||
provider=n8n-sandbox)
|
||||
```
|
||||
|
||||
我的环境变量说已启用。数据库说不是。**数据库赢了。**
|
||||
|
||||
环境变量是 `N8N_INSTANCE_AI_SANDBOX_ENABLED=true`,在容器上依然设置正确。但有一个持久化在 n8n 自己的配置存储里的值,在启动时覆盖了它——而这个冲突唯一被报告的地方,就是一行日志里的一个括号。
|
||||
|
||||
这是一个超越 n8n 的、非常有价值的排查教训:当一个功能明明环境变量设对了却是关闭状态,就怀疑存在一个**优先级高于环境的持久化设置层**。容器配置不总是最终答案。在日志里 grep 那个功能名,不要看到环境变量就停下。
|
||||
|
||||
## 我会怎么做得不一样
|
||||
|
||||
1. **在宣布升级完成之前先读启动日志。** 所有对我重要的废弃警告,都在第一次启动时、在一个块里、清清楚楚打印出来了。我的 v1 习惯是检查「它起来了吗、工作流跑得动吗」——而这恰恰是漏掉全部七项的检查方式。
|
||||
2. **把「默认值将会改变」当成待办事项,而不是警告。** 七项里有五项是未来的默认值变更。现在钉死它们只需要改一次配置,却能把以后的一次神秘故障转化为一次配置 diff。
|
||||
3. **把容器配置和 n8n 自己存储的配置做对比。** 沙盒覆盖这件事教会我:env 只是两个输入之一,不是真相来源。当行为和配置不一致时,相信行为,然后去找那个优先级更高的层。
|
||||
4. **钉死镜像 tag,并保留上一个。** 我是从 v1 直接跳到 `2.40.1` 的。本地留着旧镜像,是回滚能变成一条 `docker run` 而不是一次重新构建的原因。
|
||||
|
||||
## 结果
|
||||
|
||||
n8n 现在跑在 2.40.1 上,整块废弃警告都已处理:schema 非法的值已修正,`N8N_WEBHOOK_URL` 已就位,JS runner 正常注册,task 超时和压缩上限都已钉死,让下一次升级变成一次无操作而不是一次意外。
|
||||
|
||||
下游一切都还在工作——文件权限同步、每夜备份、语音合成接口。这才是这类升级值得追求的结果:不是「它起来了」,而是「它起来了,*而且*接下来三次升级的成本已经预付了」。
|
||||
|
||||
真正让人不舒服的地方在于,这些我本可以提前知道的信息占了多大比例。n8n 主动把整份清单递给了我,就在启动时。升级从来不是难的部分——读输出才是。
|
||||
|
||||
---
|
||||
|
||||
## 需要不用你天天盯着的自动化?
|
||||
|
||||
我搭建并维护自建自动化——n8n 工作流、Docker 技术栈,以及那些从来不是设计来互相通信的应用之间的胶水层。如果你正面临一次大版本升级,或者你有那种「能用,直到不能用」的自动化,我会规划迁移、在维护窗口执行、并把每一个配置决策都记录下来,让下一次升级变得无聊。
|
||||
|
||||
欢迎联系 [[email protected]](mailto:[email protected]?subject=n8n%20%E5%8D%87%E7%BA%A7)
|
||||
或 WhatsApp [+60 12-797 2969](https://wa.me/60127972969),也可以看看我在
|
||||
[hoelee.com](https://hoelee.com) 做什么。
|
||||
@@ -0,0 +1,214 @@
|
||||
---
|
||||
title: "我用两个 Cron 容器跑了一年的语音合成服务"
|
||||
description: "一个阅读应用需要 TTS,而云服务免费版的 token 十分钟、一小时就过期。这是那套让 Azure 和 Google 语音稳定运行一年的边车模式——任何工作流里都没有存放密钥。"
|
||||
pubDate: 2026-09-18
|
||||
category: ai
|
||||
tags: [n8n, tts, azure, google-cloud, docker, automation, sidecar]
|
||||
ogImage: /og/running-tts-as-a-service-with-token-sidecars.png
|
||||
banner: /banners/running-tts-as-a-service-with-token-sidecars.png
|
||||
---
|
||||
|
||||
我自建了一个电子书阅读服务器。它有个「朗读」功能,内置的引擎能用但很机械。所以我把它接到了真正的神经网络语音上——Azure Speech 和 Google Cloud TTS——通过我的 n8n 实例。
|
||||
|
||||
那是大约一年前的事。它此后一直在运行,而且设计几乎没变过。这篇文章讲它怎么工作,更有用的是讲它为什么长成这样:整个架构的存在就是为了解决一个具体问题,而这个问题会在一小时之内击垮天真的实现方式。
|
||||
|
||||
## 为什么这件事重要
|
||||
|
||||
语音合成是个加进去很愉快、但维持起来出乎意料麻烦的功能。低用量下这些语音要么便宜要么免费,音质出色,API 也直截了当——直到你发现 OAuth access token 不是一种你可以直接存进配置文件的凭据。
|
||||
|
||||
两家服务商都会签发短期 access token,而有效期差别巨大:
|
||||
|
||||
- **Azure Speech** 免费层:大约 **10 分钟**。
|
||||
- **Google Cloud**:大约 **1 小时**。
|
||||
|
||||
如果你把 token 放进一个工作流变量里,这个集成会漂亮地工作十分钟,然后开始永远返回 401。这就是那种「演示完美、生产失败」的集成的经典形状——而解法不是「记得刷新 token」,因为你不会记得。
|
||||
|
||||
## 架构:刷新 token 不是工作流的事
|
||||
|
||||
让这套东西能工作的设计决策,是拒绝让工作流管理凭据。取而代之,两个极小的容器独占 token 生命周期,把当前 token 写到一个共享卷上的文件里。工作流只负责读文件。
|
||||
|
||||
```text
|
||||
┌──────────────────┐ GET /webhook/{mtts|gtts}?pass=…&text=…&speed=…
|
||||
│ 阅读服务器 │ ───────────────────────────────────────────────┐
|
||||
│ (httpTTS 引擎) │ │
|
||||
└──────────────────┘ ▼
|
||||
┌───────────────────────────┐
|
||||
│ n8n │
|
||||
│ ├ /mtts (Microsoft) │
|
||||
│ └ /gtts (Google) │
|
||||
└───────┬───────────────────┘
|
||||
读取 accesstoken.txt
|
||||
┌─────────────┴─────────────┐
|
||||
▼ ▼
|
||||
Azure Speech (F0) Google Cloud TTS
|
||||
southeastasia 区域 cmn-CN Wavenet
|
||||
│ │
|
||||
└──────── WAV 音频 ─────────┘
|
||||
│
|
||||
回到播放器
|
||||
```
|
||||
|
||||
两个 cron 边车容器负责保持 token 新鲜:
|
||||
|
||||
| 容器 | 镜像 | 间隔 | 写入 |
|
||||
|---|---|---|---|
|
||||
| `cron-azure-refresh` | `curlimages/curl` | 每 ~570 秒 | `MicrosoftTTS/accesstoken.txt` |
|
||||
| `cron-gcloud-refresh` | `google/cloud-sdk:slim` | 每 ~3500 秒 | `GoogleTTS/accesstoken.txt` |
|
||||
|
||||
两者都 bind-mount 了**同一个宿主目录**,n8n 通过它的 Read/Write Files 节点读取这个目录。这个共享卷就是凭据层与工作流层之间的完整接口。
|
||||
|
||||
为什么间隔是这个数字:570 秒对约 600 秒的 Azure 有效期,留出 30 秒安全边际;而永远略微*提前*刷新,远比卡着到期点刷新稳健得多。Google 的 3500 秒对一小时是同样的道理。
|
||||
|
||||
```yaml
|
||||
cron-azure-refresh:
|
||||
image: curlimages/curl:8.10.1
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- /volume1/docker/n8n/file:/file
|
||||
entrypoint: /bin/sh
|
||||
command: >
|
||||
-c 'while true; do
|
||||
curl -s -X POST "https://southeastasia.api.cognitive.microsoft.com/sts/v1.0/issueToken"
|
||||
-H "Ocp-Apim-Subscription-Key: $AZURE_SPEECH_KEY" > /file/MicrosoftTTS/accesstoken.txt;
|
||||
sleep 570;
|
||||
done'
|
||||
```
|
||||
|
||||
## 为什么用文件,而不是那些显而易见的替代方案
|
||||
|
||||
**为什么不把 token 存进 n8n 凭据、在工作流里刷新?** 因为刷新逻辑就会被复制进每一个需要 token 的工作流,而每一份拷贝都需要自己的错误处理。当凌晨三点刷新失败时,你希望只有一个进程需要关心这件事。
|
||||
|
||||
**为什么不让工作流每次请求都去调 token 接口?** 可行,而且它让每次朗读请求的延迟和依赖面都翻倍。更糟的是,它意味着 token 接口的一次抖动就变成一次 TTS 故障。
|
||||
|
||||
**那为什么用文件?** 因为它是双方都已经支持的最简单接口。n8n 有内置的 Read/Write Files 节点;cron 容器可以用 `curl` 和 shell 重定向写入。没有队列、没有数据库表、没有共享库——只有一个内容永远是当前 token 的文件。
|
||||
|
||||
这笔取舍是诚实的:每次请求读一次文件,是热路径上的一次磁盘读。在一个阅读应用的请求频率下,这完全是免费的,而它换来的是凭据生命周期与请求处理之间的彻底解耦。
|
||||
|
||||
## n8n 这一侧:两个工作流,一种形状
|
||||
|
||||
两个 TTS 工作流骨架相同,值得走一遍,因为细节才是有意思的地方。
|
||||
|
||||
**1. `responseMode: responseNode` 的 webhook。** 工作流必须返回原始音频字节而不是 JSON,所以响应由一个显式的 Respond to Webhook 节点控制,而不是 n8n 的默认行为。
|
||||
|
||||
**2. 一道密码闸门。** 一个查询参数会与期望值比对,不匹配时返回真正的 403,而不是一个空的 200:
|
||||
|
||||
```text
|
||||
Respond to Webhook → text: "403 unauthorized", responseCode: 403
|
||||
```
|
||||
|
||||
`pass` 值就在 URL 里,这一点我稍后会诚实交代。
|
||||
|
||||
**3. 读取 token 文件。** `Read/Write Files from Disk` 读取
|
||||
`/home/user/file/MicrosoftTTS/accesstoken.txt`。然后两个节点做清理:
|
||||
`Extract from File`(文本模式),以及一个删掉换行的 Set 节点——因为
|
||||
`Authorization` 头里一个尾随的 `\n` 会产生一个令人抓狂、且看起来完全不像空白字符问题的 401:
|
||||
|
||||
```js
|
||||
// Edit Fields 节点
|
||||
{{ $json.data.replace(/(\r\n|\n|\r)/g, '') }}
|
||||
```
|
||||
|
||||
**4. 调用服务商。** 对 Azure 来说,请求体是插入了语音和语速的 SSML:
|
||||
|
||||
```xml
|
||||
<speak version='1.0' xmlns="http://www.w3.org/2001/10/synthesis"
|
||||
xmlns:mstts="http://www.w3.org/2001/mstts" xml:lang="zh-CN">
|
||||
<voice name='{{ $('Code in JavaScript').item.json.voice }}'>
|
||||
<prosody rate="{{ $('Code in JavaScript').item.json.rate }}"
|
||||
pitch="{{ $('Webhook').item.json.query.pitch }}">
|
||||
{{ $('Webhook').item.json.query.text }}
|
||||
</prosody>
|
||||
</voice>
|
||||
</speak>
|
||||
```
|
||||
|
||||
**5. 以二进制响应返回音频**,并带上正确的内容类型:
|
||||
|
||||
```text
|
||||
Respond to Webhook → binary, set
|
||||
Content-Type: audio/wav
|
||||
Content-Disposition: filename="output.wav"
|
||||
```
|
||||
|
||||
## 映射问题:客户端说的是另一种语言
|
||||
|
||||
这个细节花的心思比 API 调用本身还多。阅读应用发送一个 `speed` 值,用的是它自己的刻度——5 到 50,因为那是它 UI 滑块产生的范围。Azure 想要的是百分比的 prosody rate,而 Google 想要的是一个约等于 1.0 的 `speakingRate` 乘数。
|
||||
|
||||
两家服务商的刻度都和应用的刻度不一致。所以这里有一个刻意的转换步骤,而这一步值得照抄,因为把 UI 控件映射到 API 参数是一个反复出现的琐事:
|
||||
|
||||
```js
|
||||
// 把阅读器的 5–50 速度滑块映射到 Azure 的 -20%…+150% 语速区间。
|
||||
const inMin = 5, inMax = 50;
|
||||
const outMin = -20, outMax = 150;
|
||||
|
||||
// 映射之前先钳制输入,这样客户端一个越界的值不会产生荒谬的 prosody rate。
|
||||
if (speed < inMin) speed = inMin;
|
||||
if (speed > inMax) speed = inMax;
|
||||
|
||||
const mapped = ((speed - inMin) / (inMax - inMin)) * (outMax - outMin) + outMin;
|
||||
// → rate: `${Math.round(mapped)}%`
|
||||
```
|
||||
|
||||
Google 那边则是直接相除,因为它的刻度在同一区间里接近线性:
|
||||
|
||||
```js
|
||||
speakingRate: speed / 25 // Google 期望约 1.0,而不是百分比
|
||||
```
|
||||
|
||||
两家服务商、两套单位制、一个客户端概念。把映射留在工作流里(而不是要求客户端了解 Azure 的百分比),正是让阅读应用保持服务商无关的原因——也是我能在完全不动应用的情况下加上第二个服务商的原因。
|
||||
|
||||
此外还有一张语音表,因为客户端发送的是整数索引而不是语音名:
|
||||
|
||||
```js
|
||||
const voices = [
|
||||
"zh-CN-XiaochenMultilingualNeural", // 1
|
||||
"zh-CN-XiaoxiaoMultilingualNeural", // 2
|
||||
// ...
|
||||
"zh-CN-XiaoshuangNeural", // 7(女声,儿童)
|
||||
"zh-CN-XiaoyouNeural" // 8(女声,儿童)
|
||||
];
|
||||
```
|
||||
|
||||
八种语音——六种成人、两种儿童——可从阅读应用 UI 选择。工作流会把索引钳制进范围,而不是信任它,这和语速钳制是同一个防御习惯。
|
||||
|
||||
## 安全方面,诚实作答
|
||||
|
||||
闸门是一个 `pass` 查询参数,比对一个固定字符串。我不打算美化它:**这就是一个放在 URL 里的共享密钥。** 它阻止了针对一个每次请求都要花我钱的接口的随意滥用。它阻止不了任何能读到阅读应用配置的人,也扛不住认真的攻击者。
|
||||
|
||||
我能接受这一点,是因为它所保护的东西。最坏的结果是有人烧掉我的免费层 TTS 配额——一件烦人事,不是数据泄露。这个接口后面没有客户数据,也没有对任何东西的特权访问。为真正的认证(OAuth、签名请求、按用户限流)付出的代价,远超这点暴露所值。
|
||||
|
||||
可迁移的习惯是:**明确说出**一道闸门属于哪一层级——这是**滥用威慑**,不是授权。系统出问题,往往是因为把威慑误当成了边界。如果这个接口碰到客户记录或文件访问,它就需要真正的认证——而我会从一开始就设计得不一样。
|
||||
|
||||
## 隐形成本:它被钉在一个早已过时的分支上
|
||||
|
||||
这项服务跑了将近一年,零代码改动。这既是好消息也是坏消息:
|
||||
|
||||
```text
|
||||
n8nio/n8n:1.123.72
|
||||
```
|
||||
|
||||
那是 TTS 文档里写明的镜像。它一直能用,所以也就没有任何东西促使我重新审视它——这正是「*过于*可靠」的基础设施的经典失败模式。当我终于把 n8n 升到 v2 时,这是整个技术栈里最后一处 1.x 时代的引用,而 token 刷新容器恰恰是最容易受平台行为悄悄变化影响的那部分。
|
||||
|
||||
教训不是「升级更勤一点」——而是:**一个没有活动部件的服务,没有任何自然契机去重审它的假设。** 给自己设个日历提醒去复查钉死的依赖版本,因为系统本身永远不会告诉你。
|
||||
|
||||
## 我会怎么做得不一样
|
||||
|
||||
1. **把映射逻辑和应用一起版本化,而不是埋在某个工作流里。** 语速映射和语音表编码的是阅读应用的 UI 契约。它们活在 n8n Code 节点里的 JavaScript 中,任何做应用的人在那边都看不见——而如果滑块范围哪天变了,没有任何东西会告诉我。
|
||||
2. **加一个真正做一次合成请求的健康检查端点。** 我目前能查的只有容器起没起来。token 可以存在于文件里但*依然*是过期的(如果某次刷新静默失败了),而在用户撞上之前,没有便宜的办法发现这一点。
|
||||
3. **把钉死的版本记录在一个我真的会看的地方。** `1.123.72` 在一个 markdown 文件里躺了一年。运维手册里加一行「复查钉死的镜像」,就能让它在下一个维护窗口浮现,而不是通过一次迁移。
|
||||
|
||||
## 结果
|
||||
|
||||
一年可用性、两个容器、一个共享目录,以及八种阅读应用可以用滑块选择的神经语音。请求含服务商往返在内几秒内完成,而整个东西除免费层之外零成本——因为 token 生命周期问题被一次性解决在了正确的地方。
|
||||
|
||||
这个模式可以泛化到任何短期凭据:**别教会每一个调用方去刷新 token——跑一个唯一职责就是让某个文件保持最新的进程,让其他所有人读这个文件。** 它不聪明,而这就是重点。聪明的凭据处理,正是你最后会得到四份刷新实现、其中三份是错的的原因。
|
||||
|
||||
---
|
||||
|
||||
## 需要在现有应用里接上 AI 功能?
|
||||
|
||||
我搭建 AI 集成里那些不性感的中层——也就是演示之后还能继续工作的那部分:token 刷新、服务商故障转移、语音与模型映射、限流处理。如果你想把语音合成、转写或 LLM 功能加进一个现有应用,并且希望它明年还能用,那就是我在做的事。
|
||||
|
||||
欢迎联系 [[email protected]](mailto:[email protected]?subject=TTS%20%E9%9B%86%E6%88%90)
|
||||
或 WhatsApp [+60 12-797 2969](https://wa.me/60127972969),也可以看看我在
|
||||
[hoelee.com](https://hoelee.com) 做什么。
|
||||
@@ -0,0 +1,181 @@
|
||||
---
|
||||
title: "数字商品的自愈式访问控制:NocoDB、n8n 与 AList"
|
||||
description: "我如何用五个工作流搭出一套权限系统,自动授予、到期失效并持续修复客户的文件访问——包括一个构建期代码共享技巧,以及一个设计上刻意不泄露任何信息的公开接口。"
|
||||
pubDate: 2026-09-18
|
||||
category: case-studies
|
||||
tags: [n8n, nocodb, alist, access-control, docker, automation, digital-goods]
|
||||
ogImage: /og/self-healing-digital-goods-entitlements.png
|
||||
banner: /banners/self-healing-digital-goods-entitlements.png
|
||||
---
|
||||
|
||||
我卖数字产品——文件、美术素材、授权资源——并通过一个自建的文件门户交付。真正的难题从来不是存储,而是**授权**:确保客户在购买时拿到恰好他们买下的目录,拿到恰好他们付费的时长,并且在停止付费时访问权限真的被收回。
|
||||
|
||||
手工做这件事,前十个客户没问题。一百个就不行了,而且失败的方�式很具体、也很讨厌:静默失败。订阅到期而客户继续下载时,不会有任何报错。你只是一直在给一个几个月前就停止付费的人供文件。
|
||||
|
||||
所以我把它做成五个 n8n 工作流,把访问权限当作**派生状态**来处理——从我的业务数据库计算得出,应用到文件服务器上,并持续重新校验。下面是它的架构、让它可维护的代码共享技巧,以及那个公开接口背后的安全推理。
|
||||
|
||||
## 为什么这件事重要
|
||||
|
||||
对一个数字商品生意来说,访问控制*就是*产品本身。你保护的不是一座仓库,你保护的是你卖掉的那个东西。两种失败模式会真金白银地亏钱:
|
||||
|
||||
- **授予不足**——付费客户拿不到他买的东西,而你是从愤怒的消息里而不是监控告警里知道的。
|
||||
- **授予过度**——已到期或已撤销的客户继续有访问权,而泄露会一直不可见,直到有人转卖你的目录。
|
||||
|
||||
手工管理最终会必然地导致这两种。解法是不要再把一次授权当成*你做的一件事*,而把它当成*你数据的一个函数*:给定客户当前的购买记录,他现在应该能访问什么?算出来、应用它,然后按计划证明它仍然成立。
|
||||
|
||||
## 系统的形状
|
||||
|
||||
五个工作流,每个只做一件事:
|
||||
|
||||
| 工作流 | 触发器 | 职责 |
|
||||
|---|---|---|
|
||||
| **W1** — Customer Provision & Status Lifecycle | NocoDB webhook(客户行) | 创建/更新文件服务器用户;状态变更时启用或禁用 |
|
||||
| **W2** — CustomerProduct Sync | NocoDB webhook(购买行) | 重新计算并应用该客户被允许的路径 |
|
||||
| **W3** — Expiry Sync | Cron,每 5 分钟 | 扫描即将到期的授权;移除已失效的路径 |
|
||||
| **W4** — Daily Full Reconciliation | Cron 03:00 + 手动 webhook | 对比全体客户的*期望*与*实际*;修复漂移;记录日志 |
|
||||
| **W5** — Footer Purchase Check | 公开 GET webhook | 让门户展示客户自己的购买记录;只读 |
|
||||
|
||||
数据存在 **NocoDB**(一个自建的 Airtable 类数据库)里,包括 Customers、CustomerProducts、Products、Resources 和 AccessGrants 几张表。文件服务器是 **AList**,它为每个客户提供一个用户和一个角色,角色的 `permission_scopes` 就是一串路径。
|
||||
|
||||
至关重要的设计决策:**NocoDB 是业务真相来源,AList 只是执行状态。** 同步是单向的。在 AList 管理后台手动改一笔不算配置变更——那叫漂移,W4 会把它修复回去。
|
||||
|
||||
## 算法:这个客户*应该*有什么?
|
||||
|
||||
一切都系于一个函数。客户的期望路径是两个来源的并集:
|
||||
|
||||
1. 所有可从**有效的、未过期**购买记录到达的资源。
|
||||
2. 所有通过**有效的、未过期**手工授权直接授予的资源。
|
||||
|
||||
有意思的情况是:同一个资源可以通过*两个不同*产品到达。如果客户买了产品 A(30 天后到期)和产品 B(200 天后到期),而两者都包含同一个目录,那么正确的到期时间是**较晚**的那一个——多买一样东西永远不该缩短你对它的访问权。
|
||||
|
||||
```js
|
||||
// 对每个产品的资源,按路径保留最大的 expires_at。
|
||||
for (const c of cps) {
|
||||
if (c.status && c.status !== 'active') continue;
|
||||
if (c.expires_at && String(c.expires_at) <= today) continue; // 已过期
|
||||
const pid = linkId(c.product);
|
||||
if (!pid) continue;
|
||||
const res = (await ncGet(ctx,
|
||||
`/api/v2/tables/${T_PROD}/links/${LNK_PROD_RES}/records/${pid}`)).list || [];
|
||||
const exp = c.expires_at;
|
||||
for (const r of res) {
|
||||
const p = resPath[r.Id];
|
||||
if (!p) continue;
|
||||
const cur = desired[p];
|
||||
if (!cur || !cur.expires || (exp && exp > cur.expires)) {
|
||||
desired[p] = { expires: exp || null, permission: cur?.permission ?? 0 };
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
注意边界:`<= today`。**当天到期就算已过期。** 到期检查上的一个差一错误,就是订阅期与白送一天之间的区别;如果你不把它写明确,你一定会写错,而且会错在对客户有利的那一边。
|
||||
|
||||
## 问题所在:n8n 的 Code 节点无法共享代码
|
||||
|
||||
这个约束塑造了整个代码库。n8n 的 Code 节点是自包含的:没有 `require`、没有 `import`、也没有访问共享模块的文件系统权限。所以最自然的结构——一个授权算法,被 W1、W2、W3、W4 调用——恰恰是这个平台不让你做的事。
|
||||
|
||||
把那个函数复制粘贴进四个节点,注定是一场维护灾难。四份到期规则的拷贝,就是四次它们互相不一致的机会,而一个用四种方式计算访问权限的系统,比没有系统更糟。
|
||||
|
||||
解法是让共享发生在**构建期**而不是运行期:
|
||||
|
||||
- `shared/effective-grants.js` 是唯一真相来源,而且它被写成自包含的:没有 `require`、没有 `process.exit`、函数外没有顶层 `return`。它通过一个 `ctx` 参数(`{ $env, helpers }`)接收配置,而不是去抓全局变量——这带来一个令人愉快的副作用:它**可以在 n8n 之外做单元测试**。
|
||||
- `gen_w1.js`、`gen_w2.js`、`gen_w3.js`、`gen_w4.js` 读取这个文件,把它内联进工作流的 Code 节点主体,并更新工作流。
|
||||
|
||||
结果是:一个算法、四个工作流、零运行期依赖——而且这个函数可以在碰到任何线上系统之前,用纯 Node 测试。代码在*产物*里重复,但在*源码*里从不重复,这和打包器做的是同一笔交易。
|
||||
|
||||
一个 n8n 特有的细节值得知道:**自定义环境变量只有在 Code 节点里通过 `$env` 才可靠可读——`process.env` 在那里不可靠。** 这就是为什么每个工作流都有一个显式的「Load Env」节点,把它需要的值提升到 item 上,而不是哪里方便就在哪里读配置。
|
||||
|
||||
## 让到期处理可以安全自动化
|
||||
|
||||
W3 每五分钟跑一次,对即将到期的授权做对账。有两个细节让一个 cron 任务可以安全地改动线上权限:
|
||||
|
||||
**只有当访问权限真的用完时才禁用。** 一个天真的「如果没有期望路径就禁用用户」规则是危险的——它会乐于禁用一位只是还没被授予任何东西的新客户。守卫条件是显式的:
|
||||
|
||||
```js
|
||||
// 只在「曾经有权限、现在一个都没有」的客户身上禁用。
|
||||
// currentPaths.length > 0 避免干掉一个全新的、尚未授权的客户。
|
||||
const shouldDisable = AUTO_DISABLE && nowEmpty
|
||||
&& cust.status === 'active' && currentPaths.length > 0;
|
||||
```
|
||||
|
||||
**它有 dry-run 模式。** 容器上的 `W3_DRY_RUN=true`(或 W4 手动 webhook 上的查询参数)会让扫描计算并报告它的计划,同时**执行零写入**。能在让一个计划任务动手之前先问「你会做什么?」,是我在任何自动化里加过的最有用的安全功能。
|
||||
|
||||
```js
|
||||
const DRY_RUN = ($env.W3_DRY_RUN || '').toLowerCase() === 'true';
|
||||
```
|
||||
|
||||
## 每日修复:假定你一定会漂移
|
||||
|
||||
W4 是那个我会说才是真正产品的工作流。它在 03:00 运行,遍历每个客户,对比期望状态与实际状态——然后修复差异并**验证修复**。
|
||||
|
||||
它处理的漂移矩阵:
|
||||
|
||||
- **应当是有效的** → 用户必须存在(**先用用户名查找**,以避免在存储的 ID 丢失时创建重复用户)、处于启用状态,并且恰好带有计算出的角色权限范围。
|
||||
- **应当是无效的** → 禁用,并清空权限范围。待处理客户完全不建用户——不自动创建。
|
||||
- **悬空引用** → 存储的用户或角色 ID 指向一个已不存在的记录。按名字查找,找到就收编,找不到就重建。
|
||||
- **用户名漂移** → 只检测并*记录日志*,绝不做破坏性迁移。
|
||||
|
||||
每一次修复之后都会重新读取文件服务器状态来验证。失败验证会被记为 `verify_failed` 而不是假定成功,而对账日志只接收**漂移、修复和错误三类记录**——健康客户不产生任何行。最后这个选择正是让日志可用的原因:如果它是空的,一切正常,你不必读着一千行「无变化」去找那一条重要的。
|
||||
|
||||
## 那个公开接口,以及为什么没有 HMAC
|
||||
|
||||
W5 让文件门户的页脚能展示已登录客户自己的购买记录和到期日期。它是一个**公开** webhook,而它的安全推理是我最刻意对待的部分。
|
||||
|
||||
直觉是用 HMAC 给请求签名。我没这么做,理由值得直说:**密钥必须发到浏览器,所以签名只是表演。** 一个每个客户端都持有的共享密钥什么都保护不了——它只增加了一层仪式,让这个接口*看起来*经过验证。
|
||||
|
||||
所以这个接口依赖的是真正成立的东西:
|
||||
|
||||
- **CORS 锁定单一来源。** 响应带有
|
||||
`Access-Control-Allow-Origin: https://drive.example.com`,所以只有门户自己的页面能在浏览器里读取响应。
|
||||
- **数据最小化是设计出来的。** 响应只返回产品名、到期日期、展示状态和公开目录路径。没有内部数据库 ID、没有客户个人信息、没有任何其他客户的信息。
|
||||
- **边缘限流**,通过请求路径上的一条 WAF 规则,削弱用户名枚举。
|
||||
|
||||
还有一个细微的架构选择:**用户名来自调用者自己的会话 token,在客户端解码**,而不是来自一个客户端可以自由设置的参数。这个接口从不向文件服务器认证,从而避开了一整类连接状态与设备注册副作用——否则每次页脚渲染都会引入这些副作用。
|
||||
|
||||
诚实的说法是:这个接口不是信任边界,我也不假装它是。它向客户展示的只是他们本来就知道的关于自己的信息,走的是一个对任何其他人无用的响应形状。
|
||||
|
||||
## 教会我最多的那个 bug:公网路径与级联故障
|
||||
|
||||
W5 最初是通过 NocoDB 的**公网**主机名——穿过一个 Cloudflare 隧道——去取数据的。测试中它工作良好。在真实负载下,它产生了这样一条链:
|
||||
|
||||
1. 公网往返延迟在负载下超过 60 秒。
|
||||
2. nginx 上游超时触发 → **504**。
|
||||
3. 页脚客户端 fetch 的 8 秒超时**每秒重试一次**。
|
||||
4. 重试堆积了连接。
|
||||
5. 这些连接耗尽连接池 → 无关请求收到 **503**。
|
||||
|
||||
一个慢依赖变成了一次不同服务的级联故障。修法是停止跨过整个互联网去访问同一个 Docker 网络上的东西:
|
||||
|
||||
```js
|
||||
// n8n 与 NocoDB 同处 bridge_hoelee 网络;NocoDB 监听 :10380。
|
||||
// 走公网路由会引入 CF 隧道抖动,且可能超过代理超时。
|
||||
const NOCODB_URL = 'http://nocodb:10380';
|
||||
```
|
||||
|
||||
**内部约 30 ms,公网 300 ms 以上,且没有隧道抖动**——整条故障链消失了,因为触发条件(数秒级延迟)已不可能出现。
|
||||
|
||||
这个教训可以很好地泛化到这个技术栈之外:**当一个服务和它的依赖在同一个容器网络里时,用公网主机名就是一个等着负载来触发的 bug。** 而当你看到一个 503 出现在一个 504 的下游时,去找那个激进重试的客户端——重试循环通常才是放大器,而不是原始问题。
|
||||
|
||||
## 我会怎么做得不一样
|
||||
|
||||
1. **先做漂移修复,而不是最后做。** 我先写授权路径,然后到期扫描,最后才对账。回头看,对账才是让另外两个可以安全运行的东西,它应该从第一天就存在——因为「假定你一定会漂移」是一种设计立场,不是一个功能。
|
||||
2. **在任何工作流之前先写期望状态函数。** 它能待在 `shared/` 里、能在 n8n *之外*做单元测试,正是整个系统在四个工作流之间保持连贯的原因。如果我一开始就把逻辑粘贴进节点,我就会发布四份微妙的、彼此不同的到期规则。
|
||||
3. **永远不要在同一台主机的两个容器之间走公网。** 这条让我真的经历了一次故障,而它现在是我默认应用的规则,而不是每个集成都要重新发现一次。
|
||||
4. **从第一天就把 dry-run 开关放进去。** 事后加 `DRY_RUN` 很容易;而在没有它的情况下运行一个会改动权限的计划任务,是几周完全不必要的提心吊胆。
|
||||
|
||||
## 结果
|
||||
|
||||
五个工作流跑完整个授权生命周期:数据库里的一笔购买在几秒内变成可用访问,到期每五分钟扫一次,每夜的完整对账修复任何漂移并验证每次修复。一切健康时对账日志是空的——而大多数日子里,它说的就是这件事。
|
||||
|
||||
值得带走的设计原则,是让这件事变得可控的那一条:**别再「管理」访问权限,开始「断言」它。** 把客户应该拥有什么定义成业务数据的纯函数,在数据变化时应用这个函数,并按计划重新断言它以捕获其他所有情况。这样系统就不需要你小心翼翼——它只需要你在一个函数里,一次性地正确。
|
||||
|
||||
---
|
||||
|
||||
## 想让你的生意也用上这套?
|
||||
|
||||
如果你卖数字产品,还在手工授予文件访问权限——或者你不确定上个月到期的客户是否真的已经失去访问权——我搭的正是这套东西:自建授权系统,访问权限从你的数据计算得出、自动到期、每夜自我修复。我熟悉 NocoDB、n8n、AList 和 Docker,并且会把文档一并交付,让你不用依赖我也能运维。
|
||||
|
||||
欢迎联系 [[email protected]](mailto:[email protected]?subject=%E6%95%B0%E5%AD%97%E5%95%86%E5%93%81%E8%AE%BF%E9%97%AE%E6%8E%A7%E5%88%B6)
|
||||
或 WhatsApp [+60 12-797 2969](https://wa.me/60127972969),也可以看看我在
|
||||
[hoelee.com](https://hoelee.com) 做什么。
|
||||
Reference in New Issue
Block a user