数字商品的自愈式访问控制:NocoDB、n8n 与 AList

· 2 min read · case-studies n8nnocodbalistaccess-controldockerautomationdigital-goods

我卖数字产品——文件、美术素材、授权资源——并通过一个自建的文件门户交付。真正的难题从来不是存储,而是授权:确保客户在购买时拿到恰好他们买下的目录,拿到恰好他们付费的时长,并且在停止付费时访问权限真的被收回。

手工做这件事,前十个客户没问题。一百个就不行了,而且失败的方�式很具体、也很讨厌:静默失败。订阅到期而客户继续下载时,不会有任何报错。你只是一直在给一个几个月前就停止付费的人供文件。

所以我把它做成五个 n8n 工作流,把访问权限当作派生状态来处理——从我的业务数据库计算得出,应用到文件服务器上,并持续重新校验。下面是它的架构、让它可维护的代码共享技巧,以及那个公开接口背后的安全推理。

为什么这件事重要

对一个数字商品生意来说,访问控制就是产品本身。你保护的不是一座仓库,你保护的是你卖掉的那个东西。两种失败模式会真金白银地亏钱:

手工管理最终会必然地导致这两种。解法是不要再把一次授权当成你做的一件事,而把它当成你数据的一个函数:给定客户当前的购买记录,他现在应该能访问什么?算出来、应用它,然后按计划证明它仍然成立。

系统的形状

五个工作流,每个只做一件事:

工作流触发器职责
W1 — Customer Provision & Status LifecycleNocoDB webhook(客户行)创建/更新文件服务器用户;状态变更时启用或禁用
W2 — CustomerProduct SyncNocoDB webhook(购买行)重新计算并应用该客户被允许的路径
W3 — Expiry SyncCron,每 5 分钟扫描即将到期的授权;移除已失效的路径
W4 — Daily Full ReconciliationCron 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 天后到期),而两者都包含同一个目录,那么正确的到期时间是较晚的那一个——多买一样东西永远不该缩短你对它的访问权。

// 对每个产品的资源,按路径保留最大的 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 调用——恰恰是这个平台不让你做的事。

把那个函数复制粘贴进四个节点,注定是一场维护灾难。四份到期规则的拷贝,就是四次它们互相不一致的机会,而一个用四种方式计算访问权限的系统,比没有系统更糟。

解法是让共享发生在构建期而不是运行期:

结果是:一个算法、四个工作流、零运行期依赖——而且这个函数可以在碰到任何线上系统之前,用纯 Node 测试。代码在产物里重复,但在源码里从不重复,这和打包器做的是同一笔交易。

一个 n8n 特有的细节值得知道:自定义环境变量只有在 Code 节点里通过 $env 才可靠可读——process.env 在那里不可靠。 这就是为什么每个工作流都有一个显式的「Load Env」节点,把它需要的值提升到 item 上,而不是哪里方便就在哪里读配置。

让到期处理可以安全自动化

W3 每五分钟跑一次,对即将到期的授权做对账。有两个细节让一个 cron 任务可以安全地改动线上权限:

只有当访问权限真的用完时才禁用。 一个天真的「如果没有期望路径就禁用用户」规则是危险的——它会乐于禁用一位只是还没被授予任何东西的新客户。守卫条件是显式的:

// 只在「曾经有权限、现在一个都没有」的客户身上禁用。
// currentPaths.length > 0 避免干掉一个全新的、尚未授权的客户。
const shouldDisable = AUTO_DISABLE && nowEmpty
  && cust.status === 'active' && currentPaths.length > 0;

它有 dry-run 模式。 容器上的 W3_DRY_RUN=true(或 W4 手动 webhook 上的查询参数)会让扫描计算并报告它的计划,同时执行零写入。能在让一个计划任务动手之前先问「你会做什么?」,是我在任何自动化里加过的最有用的安全功能。

const DRY_RUN = ($env.W3_DRY_RUN || '').toLowerCase() === 'true';

每日修复:假定你一定会漂移

W4 是那个我会说才是真正产品的工作流。它在 03:00 运行,遍历每个客户,对比期望状态与实际状态——然后修复差异并验证修复

它处理的漂移矩阵:

每一次修复之后都会重新读取文件服务器状态来验证。失败验证会被记为 verify_failed 而不是假定成功,而对账日志只接收漂移、修复和错误三类记录——健康客户不产生任何行。最后这个选择正是让日志可用的原因:如果它是空的,一切正常,你不必读着一千行「无变化」去找那一条重要的。

那个公开接口,以及为什么没有 HMAC

W5 让文件门户的页脚能展示已登录客户自己的购买记录和到期日期。它是一个公开 webhook,而它的安全推理是我最刻意对待的部分。

直觉是用 HMAC 给请求签名。我没这么做,理由值得直说:密钥必须发到浏览器,所以签名只是表演。 一个每个客户端都持有的共享密钥什么都保护不了——它只增加了一层仪式,让这个接口看起来经过验证。

所以这个接口依赖的是真正成立的东西:

还有一个细微的架构选择:用户名来自调用者自己的会话 token,在客户端解码,而不是来自一个客户端可以自由设置的参数。这个接口从不向文件服务器认证,从而避开了一整类连接状态与设备注册副作用——否则每次页脚渲染都会引入这些副作用。

诚实的说法是:这个接口不是信任边界,我也不假装它是。它向客户展示的只是他们本来就知道的关于自己的信息,走的是一个对任何其他人无用的响应形状。

教会我最多的那个 bug:公网路径与级联故障

W5 最初是通过 NocoDB 的公网主机名——穿过一个 Cloudflare 隧道——去取数据的。测试中它工作良好。在真实负载下,它产生了这样一条链:

  1. 公网往返延迟在负载下超过 60 秒。
  2. nginx 上游超时触发 → 504
  3. 页脚客户端 fetch 的 8 秒超时每秒重试一次
  4. 重试堆积了连接。
  5. 这些连接耗尽连接池 → 无关请求收到 503

一个慢依赖变成了一次不同服务的级联故障。修法是停止跨过整个互联网去访问同一个 Docker 网络上的东西:

// 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] 或 WhatsApp +60 12-797 2969,也可以看看我在 hoelee.com 做什么。

Lee Teong Hoe

Full-stack developer & DevOps engineer. I build web apps, self-host infrastructure, and automate things — this blog is my living portfolio.