docs: architecture, permissions, admin guide + refresh schema/workflows

- 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
This commit is contained in:
2026-08-23 07:30:41 +08:00
parent 89b769cfd9
commit 80eb2b71be
6 changed files with 292 additions and 39 deletions
+93
View File
@@ -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`,会自动按**当前还有效**的产品重新算权限(已过期的不会回来)。
+72
View File
@@ -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。
+14 -1
View File
@@ -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
| 字段 | 类型 | 说明 |
|---|---|---|
+51 -38
View File
@@ -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-<username>`,空 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)
+59
View File
@@ -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-<username>`)
- 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 直接恢复。