From 80eb2b71be9441f36ce5a2d43b382465952ede2f Mon Sep 17 00:00:00 2001 From: hoelee Date: Sun, 23 Aug 2026 07:30:41 +0800 Subject: [PATCH] docs: architecture, permissions, admin guide + refresh schema/workflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New ARCHITECTURE.md (3-layer model + data flow + lifecycle matrix) - New PERMISSIONS.md (grant calculation rules + enforcement) - New ADMIN_GUIDE.md (華文极简:Customer/CustomerProduct 授权 + Product/Resource 管理) - Rewrite N8N_WORKFLOWS.md from WF1-6 (obsolete) to actual W1/W2/W3 - Update DATABASE_SCHEMA.md: fix status enum (drop 'disabled'), add 5-status semantics - Update README.md doc index --- README.md | 3 ++ docs/ADMIN_GUIDE.md | 93 +++++++++++++++++++++++++++++++++++++++++ docs/ARCHITECTURE.md | 72 +++++++++++++++++++++++++++++++ docs/DATABASE_SCHEMA.md | 15 ++++++- docs/N8N_WORKFLOWS.md | 89 ++++++++++++++++++++++----------------- docs/PERMISSIONS.md | 59 ++++++++++++++++++++++++++ 6 files changed, 292 insertions(+), 39 deletions(-) create mode 100644 docs/ADMIN_GUIDE.md create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/PERMISSIONS.md diff --git a/README.md b/README.md index 3f734cb..04b15b5 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,9 @@ alist-serve-resource/ ├── .gitignore ├── docs/ │ ├── API_INTEGRATION.md # ★ AList API 源码级验证 +│ ├── ARCHITECTURE.md # 三层职责与数据流 +│ ├── PERMISSIONS.md # 权限计算规则 +│ ├── ADMIN_GUIDE.md # ★ 管理员操作指南(华文,极简) │ ├── INSTALL.md │ ├── CONFIGURATION.md │ ├── DATABASE_SCHEMA.md diff --git a/docs/ADMIN_GUIDE.md b/docs/ADMIN_GUIDE.md new file mode 100644 index 0000000..40c7870 --- /dev/null +++ b/docs/ADMIN_GUIDE.md @@ -0,0 +1,93 @@ +# 管理员操作指南(ADMIN_GUIDE) + +> 极简版。只讲你在 NocoDB 里怎么点,不讲技术细节。 + +--- + +## 第一部分:给顾客授权(Customers + CustomerProducts) + +### 目标 +让一个顾客能登录 AList,看到他买的产品对应的文件夹,并在他到期时自动失效。 + +### 步骤 + +**第 1 步 — 建顾客(Customers 表)** + +1. 打开 NocoDB 的 `AlistAccess` → `Customers` 表,点「+新记录」。 +2. 填这两个字段(其余先留空): + - `username`:顾客登录 AList 的账号(唯一,别重复) + - `password`:顾客的密码 +3. `status` 先选 **`active`**。 + +> **期待值**:保存后几秒,n8n 会自动在 AList 建好这个顾客的账号 + 专属角色, +> 并回填 `alist_user_id`、`alist_role_id`,`sync_status` 变成 `synced`。 +> 此时顾客还看不到任何文件夹(还没买产品,权限为空)。 + +**第 2 步 — 给顾客挂产品(CustomerProducts 表)** + +1. 打开 `CustomerProducts` 表,点「+新记录」。 +2. 填: + - `customer`:选刚才那个顾客 + - `product`:选一个产品(比如「Test Product」) + - `start_at`:开始日期 + - `expires_at`:到期日期(**到期当天就失效**,要含当天请往后多填一天) + - `status`:选 **`active`** + +> **期待值**:保存后,n8n 会算出这个产品包含的文件夹路径,写进该顾客的 AList 权限。 +> 顾客登录后就能看到对应文件夹。`expires_at` 一过,权限自动移除。 + +**第 3 步 — 验证** + +- 用顾客的 `username` / `password` 登录 AList 前端,确认只看到该产品对应的文件夹。 +- 想看整体权限是否同步正确:该顾客记录的 `sync_status` 应为 `synced`。 + +--- + +## 第二部分:管理产品(Products + Resources) + +### 目标 +定义「产品」是什么,每个产品对应哪些文件夹,好让第一部分能直接选用。 + +### 概念对应 + +| NocoDB 表 | 是什么 | 例子 | +|---|---|---| +| `Resources` | 一个真实文件夹路径 | `alist_path = /Class/Forex` | +| `Products` | 一个可售产品 = 打包若干 Resources | 「Python 全套」含 `/Class/Python/01`、`/Class/Python/02` | + +### 步骤 + +**第 1 步 — 加资源(Resources 表)** + +1. 打开 `Resources` 表,点「+新记录」。 +2. 填: + - `name`:给人看的名字(如「Forex 课程」) + - `alist_path`:AList 里的**绝对路径**(如 `/Class/Forex`,必须以 `/` 开头) + +> **期待值**:这个资源能被产品引用。路径必须真实存在于 AList 的挂载里,否则顾客会 403。 + +**第 2 步 — 建产品(Products 表)** + +1. 打开 `Products` 表,点「+新记录」。 +2. 填: + - `name`:产品名(如「Python Basic」) + - `status`:选 **`active`** +3. 在 `Resources` 这一列(关联列)里,勾选这个产品**包含**的资源(一个产品可勾多个)。 + +> **期待值**:第一部分给顾客挂这个产品时,n8n 会把产品里勾选的所有资源的路径 +> 一次性算给顾客。 + +--- + +## 附:Customers.status 怎么配 + +| status | 含义 | 顾客能看到文件吗 | 何时用 | +|---|---|---|---| +| `pending` | 未正式开通 | ❌(且还没建账号)| 刚建顾客、还没决定卖什么时 | +| `active` | 正常 | ✅(按已买产品)| 客户正在使用中 | +| `suspended` | 管理员封禁 | ❌ | 暂时停权(欠费/风控)| +| `expired` | 整户期满 | ❌ | 客户整体服务结束 | +| `deleted` | 软删除 | ❌(账号保留)| 停用但保留历史记录 | + +- 只要是非 `active`,顾客就登不进去、也看不到文件。 +- 改回 `active`,会自动按**当前还有效**的产品重新算权限(已过期的不会回来)。 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..fd2c074 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,72 @@ +# 架构说明(Architecture) + +> 本文解释系统的分层职责与数据流。更新日期 2026-08-23。 + +## 三层职责(核心心智模型) + +| 层 | 载体 | 职责 | 谁改它 | +|---|---|---|---| +| **Account lifecycle** | `Customers.status` | 账户整体状态开关(active / pending / suspended / expired / deleted)| 管理员手工 | +| **Product entitlement** | `CustomerProducts` + `Products` + `Resources` | 「这个客户买了哪些产品 → 能访问哪些路径」+ 每个产品的到期时间 | 管理员手工 | +| **Effective permission** | AList Role `permission_scopes` | 最终生效的路径授权(由 n8n 从上面两层计算出来,写到 AList)| **仅 n8n**,管理员不直接碰 | + +``` +Customers.status (account-level:整户开关) + + +CustomerProducts (product entitlement:买了什么、何时到期) + + +Product → Resources (产品包含哪些路径) + + +CustomerProduct.expires_at + ↓ +calculateEffectiveGrants (n8n 共享算法,单一事实源) + ↓ +AList Role.permission_scopes (effective/materialized permission) +``` + +## 关键原则 + +1. **`Customers.status` 不进入权限计算**。它只决定「整户开关」: + - `active` → 允许计算并应用 grants + - `pending` / `suspended` / `expired` / `deleted` → AList user disabled + scopes 清空(账户级封禁) + - 权限的「细粒度」永远来自 CustomerProducts 的到期时间,不由 status 替代。 + +2. **AccessGrants 是预留的物化表,当前不经 n8n 写入**。将来(W4)可用于 reconciliation 日志/物化审计,但现在不作为 source-of-truth,也不参与计算。 + +3. **n8n 是同步器,不是 SOT**。Source of Truth = NocoDB;AList 只做 enforcement。 + +## 数据流 + +``` +管理员在 NocoDB 改 Customers.status 或 CustomerProducts + │ (NocoDB webhook:after insert / after update) + ▼ +n8n W1 (Customer webhook) ── 处理整户 status lifecycle +n8n W2 (CustomerProduct webhook) ── 重算该客有效 grants 并应用 +n8n W3 (每 5 分钟 schedule) ── 全量对账:重算所有客户,修 drift + │ + ▼ +AList Admin API(role/update 写 permission_scopes;user/update 写 disabled) +``` + +- **W1** 单一 Code 节点:provision + status lifecycle(5 状态)+ 幂等 write-back。 +- **W2** 单一 Code 节点:CustomerProduct 变更 → 重算该客 grants → diff → 应用。 +- **W3** 单一 Code 节点:全量 sweep,逐客调共享算法,diff 后应用;支持 dry-run / auto-disable 开关。 + +三个 workflow 共用同一个 `calculateEffectiveGrants`(`n8n/shared/effective-grants.js`,构建时内联),保证三处算法零复制、零漂移。 + +## Status lifecycle(W1) + +| status | AList user | scopes | 说明 | +|---|---|---|---| +| `pending`(无 user)| —(不建)| — | 纯标签,customer 未正式开通 | +| `pending`/`suspended`/`expired`/`deleted`(有 user)| disabled | 清空 | 账户级封禁,soft,保留历史 | +| `active`(无 user)| 新建 + enabled | 计算应用 | 首次开通 | +| `active`(有 user)| re-enable | 重算 + 应用 | 恢复/续期,永不恢复旧值 | +| 任意 `X → active` | enable | 重算(仅当前有效)| resume,忽略已过期产品 | + +## Loop 防护 + +- n8n 只写回 bookkeeping 字段(`alist_user_id`/`alist_role_id`/`sync_status`/`sync_required`/`last_sync`/`last_sync_error`)。 +- W1 用 **old/new field comparison**(NocoDB webhook 的 `previous_rows` vs `rows`)区分「管理员业务变更」vs「自己的 write-back echo」:echo 直接短路,零 PATCH。 +- `writeBack()` 有值比对守卫:字段值没变就不发 PATCH,从物理上掐断 echo loop。 diff --git a/docs/DATABASE_SCHEMA.md b/docs/DATABASE_SCHEMA.md index 6a5f6fa..13d2bcc 100644 --- a/docs/DATABASE_SCHEMA.md +++ b/docs/DATABASE_SCHEMA.md @@ -52,12 +52,25 @@ | notes | LongText | | | alist_user_id | Number | AList 用户 ID(n8n 回填) | | alist_role_id | Number | AList role ID(n8n 回填) | -| status | SingleSelect | pending/active/suspended/expired/disabled/deleted | +| status | SingleSelect | pending/active/suspended/expired/deleted(account-level 整户开关,见下) | | sync_status | SingleSelect | pending/syncing/synced/error/unknown | | sync_required | Checkbox | 系统维护,管理员勿手改 | | last_sync | DateTime | | | last_sync_error | LongText | | +**Customers.status 语义(account-level 整户开关,不替代 CustomerProduct 到期):** + +| 值 | 含义 | AList user | scopes | +|---|---|---|---| +| `pending` | 已建但未正式开通 | 若无则**不建**;有则 disabled | empty | +| `active` | 正常使用 | enabled | 按当前有效 CP 重算 | +| `suspended` | 管理员暂时封禁 | disabled | empty | +| `expired` | 整户生命周期结束 | disabled | empty | +| `deleted` | 软删除(保留账号+历史)| disabled | empty | + +- 任意 `X → active` 恢复时,重算当前有效 grants(忽略已过期产品),不恢复旧 scopes。 +- status 变化**不自动改** CustomerProducts(不批量改 status/expires_at、不删除)。 + ### 2. Products | 字段 | 类型 | 说明 | |---|---|---| diff --git a/docs/N8N_WORKFLOWS.md b/docs/N8N_WORKFLOWS.md index 58d3bfc..800e656 100644 --- a/docs/N8N_WORKFLOWS.md +++ b/docs/N8N_WORKFLOWS.md @@ -1,54 +1,67 @@ -# n8n Workflow 说明(Phase 3–6 — 待实施) +# n8n Workflow 说明(已实施) -> n8n 运行在现有 auto.hoelee.com(`bridge_hoelee` 网络)。它是同步器,不是 SOT。 +> n8n 运行在现有 auto.hoelee.com(`bridge_hoelee` 网络)。它是**同步器,不是 SOT**。 +> 更新日期 2026-08-23。 -## 依赖的 AList API(已源码验证,见 API_INTEGRATION.md) +## 已实现的三个 Workflow -- 登录换 token:`POST http://alist-serve-resource:5244/api/auth/login` -- User:`/api/admin/user/{list,get,create,update,delete,cancel_2fa}` -- Role:`/api/admin/role/{list,get,create,update,delete}` +| Workflow | ID | 触发 | 职责 | +|---|---|---|---| +| **W1** Customer Provision & Status Lifecycle | `gU2ee9Eg5w1KzB5p` | NocoDB Customers webhook | provision + 整户 status lifecycle | +| **W2** CustomerProduct Sync | `D9OWAI7JUjKnRdOn` | NocoDB CustomerProducts webhook | 重算该客有效 grants 并应用 | +| **W3** Expiry Sync | schedule `*/5` | 定时 | 全量对账 + drift 修复 | -## 需要的 Workflow +三个 workflow 共享同一套算法 `calculateEffectiveGrants`(`n8n/shared/effective-grants.js`, +构建时经 `gen_w1/w2/w3.js` 内联进各 Code 节点)。 -### WF1 — Customer Create(trigger: Customers 新记录) -1. 校验 customer。 -2. 查重 username → 建/改 AList user(`base_path=/Class`,`role=[专属role]`,`permission=16384`)。 -3. 建专属 role(`name=customer-`,空 scopes)。 -4. 用 `/api/admin/user/update` 把 user 绑定到新 role(显式传 role ID,勿留空)。 -5. 回填 `alist_user_id` / `alist_role_id` 到 NocoDB。 -6. `sync_status=synced`。 +## W1 — Customer Provision & Status Lifecycle -### WF2 — AccessGrant Create/Update(trigger: AccessGrants 记录变更) -1. 找 customer + resource path。 -2. 判断当前日期 vs start_at/expires_at。 -3. 若 active → 把该 path 并入该客专属 role 的 `permission_scopes`(permission=0)。 -4. 若非 active → 不加入。 -5. 整体 `post /api/admin/role/update` 重写 scopes(幂等)。 -6. 记录 `sync_status` / `last_sync_at` / `last_sync_error`。 +- 触发:Customers 表 after-insert / after-update(webhook `alist-customer-provision`) +- 单一 Code 节点,处理 5 状态: + - `active`(无 user)→ 建 role + 建 user + 重算 grants + enable + - `active`(有 user)→ re-enable + 重算 grants(不恢复旧值)+ 应用 + - `pending`/`suspended`/`expired`/`deleted`(有 user)→ disable + 清空 scopes + - `pending`/`suspended`/`expired`/`deleted`(无 user)→ no-op +- 回填 `alist_user_id` / `alist_role_id`,写 `sync_status=synced` -### WF3 — Expiry Sync(schedule: every 5 min) -1. 查所有 active/scheduled grants。 -2. 计算 desired scopes(NocoDB 有效 grants 并集)。 -3. 对比 AList 实际 scopes(`role/get`)。 -4. 移除过期 scope、加入新生效 scope。 -5. 若无剩余 active grant 且政策启用 → disable 该 customer(`user/update disabled=true`)。 -6. 记录结果;失败发通知。 +## W2 — CustomerProduct Sync -### WF4 — Full Reconciliation(schedule: daily) -- desired(NocoDB) vs actual(AList) 全量 diff,修复 AList,记录 reconciliation。 +- 触发:CustomerProducts 表 after-insert / after-update(webhook `alist-customerproduct-sync`) +- 读 CP → 定位 customer → 重算该客 grants → diff AList role → 有变化才 `role/update` 整体重写 +- `customer` link 为空时优雅跳过(不回写) -### WF5 — Customer Suspension(trigger: status=suspended) -- `user/update disabled=true` + 清空 role scopes;恢复时重算。 +## W3 — Expiry Sync(全量对账) -### WF6 — Customer Delete(soft delete) -- NocoDB `status=deleted`;AList 禁用用户 + 清空 scopes,不物理删除。 +- 触发:每 5 分钟 schedule +- 逐客调 `calculateEffectiveGrants` → diff → 应用 +- Env 开关:`W3_DRY_RUN=true`(只打印计划)、`ALIST_AUTO_DISABLE_ON_EXPIRE=true`(scopes 变空时 disable 用户) -## 幂等规则(规格 §28) +## 共用 env 变量 + +n8n Code 节点内用 `$env.VAR`(`process.env` 不可靠)。下表是本项目需要的 env: + +| 变量 | 说明 | +|---|---| +| `NOCODB_URL` / `NOCODB_TOKEN` | NocoDB 地址 + `xc-token` | +| `NOCODB_TABLE_CUSTOMERS` / `..._CUSTOMERPRODUCTS` / `..._PRODUCTS` / `..._RESOURCES` / `..._ACCESSGRANTS` | 各表 model_id | +| `NOCODB_LINK_PROD_RESOURCES` | Product↔Resource M2M 列 id(`c4ko4x8kwi16swg`)| +| `ALIST_URL` | `http://alist-serve-resource:5244`(n8n 容器内)| +| `ALIST_ADMIN_USERNAME` / `ALIST_ADMIN_PASSWORD` | AList admin 登录 | +| `W3_DRY_RUN` / `ALIST_AUTO_DISABLE_ON_EXPIRE` | W3 开关 | + +## 幂等规则 所有操作 = get current → compare → apply diff。重跑不产生重复 user/role/scope。 更新 scope 一律「整体重写该 role 的 `permission_scopes`」。 -## 待定项(实施时确认) +## Loop 防护(重要) -- n8n 内网访问 AList 的 host 名(预期 `http://alist-serve-resource:5244`)。 -- 登录 token 缓存 / 刷新策略(JWT_EXPIRE=2h → 建议每次 workflow 起手先 login)。 +- n8n 只写回 bookkeeping 字段(`alist_user_id`/`alist_role_id`/`sync_status`/`sync_required`/`last_sync`/`last_sync_error`)。 +- W1 用 **old/new field comparison**(NocoDB `previous_rows` vs `rows`)区分「业务变更」vs「echo」;echo 直接短路。 +- `writeBack()` 值比对守卫:字段值不变就不发 PATCH,掐断 echo loop。 + +## 依赖的 AList API(源码级验证,见 API_INTEGRATION.md) + +- 登录:`POST /api/auth/login` +- User:`/api/admin/user/{list,get,create,update,delete}` +- Role:`/api/admin/role/{list,get,create,update,delete}`(delete 用 `?id=` query param) diff --git a/docs/PERMISSIONS.md b/docs/PERMISSIONS.md new file mode 100644 index 0000000..9b0b0df --- /dev/null +++ b/docs/PERMISSIONS.md @@ -0,0 +1,59 @@ +# 权限模型说明(Permissions) + +> 本文解释「最终生效的 AList 权限」是怎么从 NocoDB 数据算出来的。更新日期 2026-08-23。 + +## 最终生效权限的三要素 + +一个客户最终能访问哪些 AList 路径,由三样东西决定: + +1. `Customers.status` — 整户开关(account-level) +2. `CustomerProducts` — 买了哪些产品 + 何时到期(product entitlement) +3. `Product → Resources` — 每个产品包含哪些路径(M2M),资源有 `alist_path` + +三者合起来 → n8n 调 `calculateEffectiveGrants` → 算出 desired scopes → 写到 AList Role。 + +## 计算规则(calculateEffectiveGrants) + +对某个客户,desired scopes = 以下两类的**并集**: + +### (A) CustomerProduct 派生 +遍历该客所有 CustomerProduct: +- 跳过 `status != active` 的 +- 跳过 `expires_at <= 今天` 的(**到期当天视为已过期**) +- 通过 `product` link → Product 的 M2M Resources → 拿到每个 resource 的路径 + +### (B) AccessGrant 派生(预留,当前不经 n8n 写入) +- 同 (A),跳过非 active / 已过期 + +### 合并规则 +- 同一个 path 来自多个来源 → 取 **MAX(expires_at)**(两个产品共享一个资源时,取较晚的到期) +- permission:`readwrite` 覆盖 `read`(readwrite=1,否则 0) +- basis:本系统客户一律 **只读**(permission 0),读写在 AccessGrant 手工授权时可用 + +## AList 侧落地(enforcement) + +- 每个客户 = 1 个专属 AList Role(`name = customer-`) +- Role 的 `permission_scopes` = desired scopes 的绝对路径数组 +- 客户 User:`base_path = '/'`(**必须 `/`**,否则 JoinPath 双重前缀 → 全 403) +- 客户 User:`permission = 16384`(bit 14 = Check path limit 置位 → 只能访问 role scope 内的路径) +- `disabled` 由 status 控制:非 active → `disabled=true` + +## 多文件夹授权正确做法(已实测) + +- `base_path = "/"` + role scope 用**绝对路径**(如 `/Class/Forex`、`/Template/Music`) +- 结果:客户只看到被授权路径,未授权挂载点(`/Book` 等)不泄露 + +## 到期与状态的关系 + +| 场景 | 结果 | +|---|---| +| CustomerProduct 到期 | 该产品派生的路径从 scopes 移除(W2/W3 对账自动修)| +| 整个 Customer status 变 non-active | 整户 disable + scopes 清空(W1)| +| non-active → active 恢复 | 重算当前有效 scopes,**不恢复已过期产品** | + +## 重要边界 + +- `Customers.status` 是账户级开关,**不替代** CustomerProduct 的到期。 + - 例:Customer = `expired`,其 Product A 仍 active 且未到期。恢复 `expired → active` 后 Product A 会自动回来;已过期的 Product B 不会回来。 +- 状态变化**不自动改** CustomerProducts(不批量改 CP.status、不改 expires_at、不删除 CP)。 +- AccessGrants 历史记录**不删除**;恢复时重算,不用旧 AccessGrant scope 直接恢复。