feat(W4): daily full reconciliation — user/role drift repair + ReconcileLogs audit

- w4-body.js: ONE-DIRECTIONAL sync (NocoDB status = SOT, never reverse);
  drift matrix: missing user/role (provision w/ username lookup to avoid
  duplicates), dangling refs (adopt/recreate), disabled<->status, canonical
  base_path '/' + permission 16384, scopes vs calculateEffectiveGrants,
  username drift detect+log_only; verify-after-repair on every change;
  password never pushed on repair (方案 C); dry-run via ?dryRun=true
- gen_w4.js: daily 03:00 cron + webhook alist-w4-reconcile; API-import-safe
  (UUID node ids, no pinData, webhookId)
- ReconcileLogs table mktsb7le6fqr2u1 (add_reconcilelogs_table.py, idempotent,
  v2 API); FK column nc_jitu___Customers_id for bt-link writes (v2 ignores
  link column itself)
- test_w4_reconcile.js: 34 assertions / 12 mock worlds, ALL PASSED
- live-verified against production: dry-run (3 drifts + 1 error found),
  real repair (3 repaired, verify all passed, 4 log rows), idempotent rerun
- NocoDB 2026.08.x pitfalls documented (v1 col endpoints gone, row delete
  via body [{Id}]); n8n API import/activation pitfalls (activeVersionId)
- docs: W4-DESIGN.md new; DATABASE_SCHEMA 8 tables; N8N_WORKFLOWS, SETUP,
  PROJECT_STATE updated
This commit is contained in:
2026-08-23 09:34:11 +08:00
parent 29ff66de88
commit 4693c9d684
12 changed files with 1257 additions and 27 deletions
+19 -3
View File
@@ -1,8 +1,8 @@
# NocoDB 数据结构(Phase 2b — 重建,7 表最终模型)
# NocoDB 数据结构(Phase 2b — 重建,8 表最终模型)
> Source of Truth = NocoDB。AList 只做 enforcement,不存业务授权数据。
> 本 schema 按《Hermes 调整指导》最终业务模型重建(2026-08-22)。
> 通过 NocoDB API 自动建表,无需手动点 UI。
> 通过 NocoDB API 自动建表,无需手动点 UI。ReconcileLogs 于 2026-08-23 W4 新增。
- **Base**: `AlistAccess`(id `p3lzkresswyowqg`),独立新 base,与旧 `accessDB` 隔离。
- **MySQL source**: mysql-server 上的 `alistaccess` schema(由 NocoDB 自动管理)。
@@ -19,6 +19,7 @@
| AccessGrants | `mxdx3yhfcyej3ao` |
| AccessLogs | `ml12zcyzlehgi21` |
| Purchases | `mwp12z9lcohhsuk` |
| ReconcileLogs | `mktsb7le6fqr2u1`(FK 列 `nc_jitu___Customers_id` = `coa6ynwcdlune6p`) |
> ⚠ ID 是本次重建生成的。若再次删库重建,ID 会变,需同步更新 n8n 节点 + 本文档。
@@ -35,6 +36,7 @@
| AccessLogs | `customer` | bt | Customers | |
| Purchases | `customer` | bt | Customers | |
| Purchases | `product` | bt | Products | |
| ReconcileLogs | `customer` | bt | Customers | ⚠ 写入时用 FK 列 `nc_jitu___Customers_id`(v2 忽略 link 列本身) |
> M2M 读写方式(已实测):`GET/POST /api/v2/tables/{products}/links/c4ko4x8kwi16swg/records/{productId}`,
> POST body `{"Id": <resourceId>}`。反向用 Resources 的 `cfxi44n9ixkimqc`。
@@ -138,7 +140,21 @@
| bytes | Number |
| timestamp | DateTime |
### 7. Purchases(纯记录,不参与权限)
### 7. ReconcileLogs(W4 对账审计,n8n W4 写入)
| 字段 | 类型 | 说明 |
|---|---|---|
| Id | number (PK) | |
| run_id | SingleLineText | 本次对账 run(`recon-<ISO>`) |
| customer | link bt → Customers | FK 列 `nc_jitu___Customers_id`(v2 写链接必须写 FK 列) |
| drift_type | SingleSelect | user_missing / role_missing / dangling_user_ref / dangling_role_ref / user_disabled_drift / scopes_drift / base_path_drift / permission_drift / role_binding_drift / username_drift / verify_failed / reconcile_error |
| details | LongText | 前后值 JSON |
| action | SingleLineText | 做了什么(provisioned / adopted / fixed / rewritten / disabled / log_only / verify …) |
| result | SingleSelect | repaired / log_only / failed / skipped |
| timestamp | DateTime | |
> 只记 drift / repair / error 行,健康客户不产生行。W4 每日全量或 webhook 触发时写入。
### 8. Purchases(纯记录,不参与权限)
| 字段 | 类型 |
|---|---|
| Id | number (PK) |
+15 -2
View File
@@ -9,7 +9,8 @@
|---|---|---|---|
| **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 修复 |
| **W3** Expiry Sync | `zlEfjj5eK5IfILtk` | schedule `*/5` | 定时 | 全量对账 + drift 修复(scopes 层) |
| **W4** Daily Full Reconciliation | `pl8j9lpg5gjrd0dB` | 每日 03:00 + webhook `alist-w4-reconcile` | 全量对账 + 用户/角色级 drift 修复 + ReconcileLogs |
三个 workflow 共享同一套算法 `calculateEffectiveGrants`(`n8n/shared/effective-grants.js`,
构建时经 `gen_w1/w2/w3.js` 内联进各 Code 节点)。
@@ -36,6 +37,17 @@
- 逐客调 `calculateEffectiveGrants` → diff → 应用
- Env 开关:`W3_DRY_RUN=true`(只打印计划)、`ALIST_AUTO_DISABLE_ON_EXPIRE=true`(scopes 变空时 disable 用户)
## W4 — Daily Full Reconciliation(每日全量对账)
- 触发:每日 03:00 cron + 手动 webhook(`/webhook/alist-w4-reconcile`,`?dryRun=true` 出计划)
- 单向同步:NocoDB status 是 SOT,W4 只修 AList,**永不反向改 NocoDB status**
- 覆盖 W3 不管的 drift:缺 user/role → 按 username 反查防重再 provision;悬空引用 → adopt/recreate;
非 active → disable + 清 scopes;base_path/permission 修回 canonical(`/` + 16384);
username drift → detect + log_only;修复后逐个 **verify 回读**,失败记 `verify_failed`
- 写 ReconcileLogs(表 `mktsb7le6fqr2u1`),只记 drift/repair/error 行;customer 链接用 FK 列
- 密码随 repair 不推送(方案 C)
- 详见 `docs/W4-DESIGN.md`
## 共用 env 变量
n8n Code 节点内用 `$env.VAR`(`process.env` 不可靠)。下表是本项目需要的 env:
@@ -43,7 +55,8 @@ n8n Code 节点内用 `$env.VAR`(`process.env` 不可靠)。下表是本项
| 变量 | 说明 |
|---|---|
| `NOCODB_URL` / `NOCODB_TOKEN` | NocoDB 地址 + `xc-token` |
| `NOCODB_TABLE_CUSTOMERS` / `..._CUSTOMERPRODUCTS` / `..._PRODUCTS` / `..._RESOURCES` / `..._ACCESSGRANTS` | 各表 model_id |
| `NOCODB_TABLE_CUSTOMERS` / `..._CUSTOMERPRODUCTS` / `..._PRODUCTS` / `..._RESOURCES` / `..._ACCESSGRANTS` / `..._RECONCILELOGS` | 各表 model_id(ReconcileLogs = `mktsb7le6fqr2u1`) |
| `NOCODB_FK_RECONCILELOG_CUSTOMER` | ReconcileLogs.customer 的 FK 列名 `nc_jitu___Customers_id`(W4 写链接必须用 FK 列) |
| `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 登录 |
+50 -16
View File
@@ -78,17 +78,33 @@ Base: **`AlistAccess`**(`p3lzkresswyowqg`),与旧 `accessDB` 完全隔离
- **W1/W2/W3 零复制算法**:三处都调同一个 `calculateEffectiveGrants`
- **测试**(全过):`test_effective_grants.js`(共享算法)、`test_w3_refactored.js`(W3 回归)、`test_w1_lifecycle.js`(W1 lifecycle,10 场景全覆盖:pending/active/suspended/expired/deleted 全转换 + echo 短路 + sync_required 强制重算)
### 6. W4 每日全量对账(Step 4)— **本地 + 直连生产实测通过** ✅
- 文件:`n8n/W4-daily-reconcile.json`(`n8n/w4-body.js` + 共享库 → `gen_w4.js`);已导入 n8n(id `pl8j9lpg5gjrd0dB`,active,webhook `alist-w4-reconcile` 已注册)
- 触发:每日 03:00 cron + 手动 webhook(`?dryRun=true` 出计划)
- 单向同步(用户定稿):NocoDB status = SOT,W4 只修 AList,永不反向;密码不随 repair 推送(方案 C)
- Drift 矩阵 + ReconcileLogs 表(`mktsb7le6fqr2u1`,只记 drift/repair/error 行)→ 详见 `docs/W4-DESIGN.md`
- **实测记录(2026-08-23 直连生产)**:
1. dry-run:5 客户 → 3 drift(TEST-Alice: dangling_role_ref + base_path_drift + role_binding_drift)+ 1 error(customer 7 缺 username),3 skip,零写入 ✅
2. 真实跑:3 repaired + 4 行 ReconcileLogs(FK 正确),verify-after-repair 全过(无 verify_failed)✅
3. 复跑:0 drift / 0 repair / 4 skip → 幂等 ✅
- 测试:`n8n/test_w4_reconcile.js`(34 项全过,mock AList+NocoDB 12 场景)
- ⚠ **n8n env 还未加**(`NOCODB_TABLE_RECONCILELOGS` / `NOCODB_FK_RECONCILELOG_CUSTOMER`):容器 env 是 Portainer stack 144 管的,需 Portainer 访问后补并 redeploy(W4 代码有 env 缺失守卫:日志写入静默跳过并在 summary 注明,不会炸)
## 🔴 下一步(按序)
> 功能已全部实现并通过本地测试(用户已确认「已测试功能正常」)。剩余事项如下:
> W4 代码与实测已完成。剩余为部署收尾 + 一个 n8n 实例问题:
1. **reimport W1 到 n8n 并做真实验收**(W2/W3 也重新生成过,可一并覆盖):
- 改 `Customers.status` 一次 → 只产生**一次**真正 W1 execution(`active_provision` 或 `deactivate`),其后 echo 全是 `skip_writeback_echo`
- 验证 W1 execution 的 `desiredPaths` 非空、AList role `permission_scopes` 落对
2. **(Step 4) W4 Full Reconciliation**:NocoDB desired vs AList actual 全量 drift 修复 + reconciliation 日志(W3 已具备基础,W4 补每日全量 + 日志)
3. **(待设计) AccessGrants 物化**:当前 W2/W3 不写 AccessGrants 表,不作为 source-of-truth。W4 时统一设计 reconciliation/materialization 策略
4. 密码回写问题(客户自改 AList 密码 → NocoDB plaintext 失真):当前不强制回写(用户已选方案 C),必要时再议
5. 本地 git 已留档并推 Gitea(`git.hoelee.com/hoelee/alist-serve-resource`,凭据已脱敏)
1. **给 n8n 容器补 env 并 redeploy(Portainer stack 144)**:
`NOCODB_TABLE_RECONCILELOGS=mktsb7le6fqr2u1` + `NOCODB_FK_RECONCILELOG_CUSTOMER=nc_jitu___Customers_id`
(容器 env 改后必须重建,不是 restart)。之后 W4 每日 03:00 才会真正落 ReconcileLogs。
2. **⚠ n8n 执行管道疑似停摆(2026-08-23 ~23:00Z 起,待查)**:webhook 全返回 200
"Workflow was started" 但不产生任何 execution(W1/W3 也一样,连 failed 都不落库);
W3 的 5 分钟 schedule 也停了(最后一次执行 23:00:51Z)。疑似与 API key 激活权限错误
("attempted to activate a workflow without permissions")或 01:21Z 的一次外部容器 recreate
有关。**需要用户确认:是否在 Portainer 动过 n8n?** 建议查 n8n 日志/executions 后恢复。
3. AccessGrants 物化:维持现状(手工人为授权入口,n8n 不写)——W4 已定案不物化。
4. 密码回写问题(客户自改 AList 密码 → NocoDB plaintext 失真):保持方案 C(不强制回写)。
5. 本地 git 已留档并推 Gitea(`git.hoelee.com/hoelee/alist-serve-resource`,凭据已脱敏)。
> ⚠️ **Status 语义已定稿(不再待决策)**:5 个值 `pending/active/suspended/expired/deleted`,
> account-level 整户开关,**不进** permission calc。详见 `docs/ARCHITECTURE.md`、`docs/PERMISSIONS.md`、`docs/DATABASE_SCHEMA.md`。
@@ -122,21 +138,39 @@ Base: **`AlistAccess`**(`p3lzkresswyowqg`),与旧 `accessDB` 完全隔离
- **writeBack 值比对守卫**:PATCH 前先 GET 当前值,只写真会变的字段;值没变 → 不发 PATCH → 不触发 webhook(物理层面掐断 loop)
- extract-id 节点必须透传 `previousRow`/`newRow`(经 Load Env 转发)到 lifecycle Code 节点
- **W4 新坑(2026-08-23)**:
- n8n API 导入(`POST /api/v1/workflows`)校验严格:**节点 id 必须 UUID、顶层不能有 `pinData`**,
否则 400 `request/body must NOT have additional properties`(UI 导入不受此限)
- webhook 节点必须有 `webhookId`,否则生产 URL 不注册(404 "The requested webhook ... is not registered")
- n8n 1.123 的 API 激活 `POST /workflows/{id}/activate` 返回 `active:true` 但运行时可能被权限拒绝
(日志 "User attempted to activate a workflow without permissions",webhook/cron 不注册)。
**激活的真实标记在 DB:`workflow_entity.activeVersionId`**(不是 `active` 列);boot 时只加载
`active + activeVersionId` 的工作流。手动救法:`UPDATE workflow_entity SET "activeVersionId"="versionId" WHERE id='...'` + 重启容器。
- **NocoDB 2026.08.x:v1 column GET/PATCH 端点已删**(列 id 从 `GET /api/v2/meta/tables/{id}` 的 columns 拿;
v2 的 columns create/PATCH 响应返回表对象而非列)
- **NocoDB v2 写 bt 链接:写 link 列本身无效**(标量/数组/对象全被忽略)→ 必须写 FK 列
`nc_jitu___Customers_id`(实测确认),env `NOCODB_FK_RECONCILELOG_CUSTOMER`
- **NocoDB v2 删行**:`DELETE /api/v2/tables/{t}/records` + body `[{"Id": N}, ...]`;
`/records/{id}` 和 `where=` 参数都不行
- **n8n 执行可能全局停摆**(2026-08-23 23:00Z 起):webhook 200 但不落 execution——见「下一步」#2,
恢复前 n8n 侧一切「执行」都不可信(本机直连跑 W4 代码是替代验收路径,见 W4-DESIGN.md)
## 📁 项目结构
```
alist-serve-resource/
├── docker-compose.yml / .env.example / .gitignore
├── n8n/
│ W1/W2/W3-*.json (generated) · w1/w2/w3-body.js (runtime logic)
│ W1/W2/W3/W4-*.json (generated) · w1/w2/w3/w4-body.js (runtime logic)
│ shared/effective-grants.js (single source of truth)
│ gen_w1/w2/w3.js + inline_shared.js (build-time inlining)
│ test_effective_grants.js · test_w1_lifecycle.js · test_w3_refactored.js
│ SETUP.md · W2-DESIGN.md · W3-DESIGN.md
├── scripts/ create_nocodb_schema.py · healthcheck.sh
│ gen_w1/w2/w3/w4.js + inline_shared.js (build-time inlining)
│ test_effective_grants.js · test_w1_lifecycle.js · test_w3_refactored.js · test_w4_reconcile.js
│ SETUP.md · W2-DESIGN.md · W3-DESIGN.md · W4-DESIGN.md
├── scripts/ create_nocodb_schema.py · add_reconcilelogs_table.py · live_dry_run_w4.js · healthcheck.sh
└── docs/ API_INTEGRATION.md · ARCHITECTURE.md · PERMISSIONS.md · ADMIN_GUIDE.md · DATABASE_SCHEMA.md · N8N_WORKFLOWS.md · INSTALL.md · CONFIGURATION.md · SECURITY.md · BACKUP_RESTORE.md · UPGRADE.md · TROUBLESHOOTING.md · PROJECT_STATE.md
```
## 待确认的设计点
1. **AccessGrants 物化**(已定:本次不物化,不作为 source-of-truth)—— W4 时统一设计 reconciliation/materialization 策略,需避免 AccessGrant→n8n loop
2. 密码:客户可自改 AList 密码 → NocoDB plaintext 会失真;当前不强制回写(用户已选方案 C)
3. 对账/到期只靠 n8n 定时扫描(AList 无 expiry 概念)—— 已确认
1. **AccessGrants 物化**(已定案:**不物化**,保持手工人为授权入口源 b;n8n 永不写它)——W4 已确认
2. 密码:客户可自改 AList 密码 → NocoDB plaintext 会失真;保持方案 C(W4 repair 也不推密码,仅 provision 新建时用)
3. 对账/到期只靠 n8n 定时扫描(AList 无 expiry 概念)—— 已确认
4. **n8n 执行管道停摆**(见「下一步」#2)——需要用户确认 Portainer 是否动过 + 恢复方式
+78
View File
@@ -0,0 +1,78 @@
# W4 — Daily Full Reconciliation / 全量对账 + drift 修复 + 对账日志
> 2026-08-23 定稿并实测(本地单元测试 + 直连生产 dry-run + 真实修复跑 + 幂等复跑)。
> 与 W3 的差异:W3 每 5 分钟只对账 **scopes**(事件对账的补网);
> W4 每日全量对账 **用户/角色/绑定的完整状态**,修复 W3 不管的 drift,并落 ReconcileLogs 审计。
## 触发
- **Schedule Trigger**:每日 03:00(cron `0 3 * * *`)。
- **Webhook**(`POST /webhook/alist-w4-reconcile`)手动触发;`?dryRun=true` 或 env `W4_DRY_RUN=true` = 只出计划,零写入。
## 单向同步铁律(用户定稿)
- **NocoDB `Customers.status` 是 business source of truth;AList enabled/disabled 只是 enforcement state。**
- W4 **永不**反向修改 NocoDB status。管理员在 AList 手工 disable 用户 → W4 按 NocoDB status 修回;绝不反过来。
- 密码**不随 repair 推送**(方案 C:NocoDB plaintext 可能旧,推送会把客户自改的 AList 密码覆盖掉)。仅 provision 新建用户时用 NocoDB 密码。
## Drift 矩阵(自动修复)
| 场景 | 期望状态 | 处理 |
|---|---|---|
| **active 客户缺 user/role** | 存在 + enabled + canonical 配置 | 自动 provision:**先按 username 查 AList**(避免 NocoDB 丢 id 时建重)→ 没有才 create(role → user 顺序,空 role 会被 AList 拒) |
| **alist_user_id / alist_role_id 悬空**(指向已删记录) | — | username / role name 反查 → 找到即 **adopt**(回写 bookkeeping id,不新建)→ 找不到才 recreate → 重新 sync |
| **非 active(pending/suspended/expired/deleted)** | user disabled + scopes 空 | 有 user → disable + 清空 scopes;**pending 从未 provision → 保持无 AList user,不自动建**(纯 NocoDB 标签) |
| **user.disabled 漂移** | 与 status 一致 | 按 status 单向修(active→enable,非 active→disable) |
| **base_path / permission 漂移** | `/` + 16384 | 强制修回(= W1 已验证的 canonical 配置,不重新发明) |
| **role binding 漂移**(user 没绑 role) | user.role = [专属 role id] | rebind |
| **scopes 漂移 vs desired** | = calculateEffectiveGrants | 整体重写 role `permission_scopes`(与 W3 同算法) |
| **username drift**(NocoDB 名 ≠ AList 名) | — | **detect + log_only**(不假设 rename API,不做 destructive migration) |
| **客户行缺 username** | — | reconcile_error 记日志 + summary.errors |
## ReconcileLogs 表(新建,2026-08-23)
- 表 id `mktsb7le6fqr2u1`;列:`run_id` / `customer`(bt→Customers) / `drift_type` / `details` / `action` / `result` / `timestamp`
- **只记 drift / repair / error 行**——健康客户不产生任何行(不为每天的全量跑刷日志)。
- drift_type 选项:user_missing / role_missing / dangling_user_ref / dangling_role_ref /
user_disabled_drift / scopes_drift / base_path_drift / permission_drift /
role_binding_drift / username_drift / verify_failed / reconcile_error
- result 选项:repaired / log_only / failed / skipped
- ⚠ **写 customer 链接必须用 FK 列 `nc_jitu___Customers_id`**(v2 records API 写 link 列本身:标量/数组/对象全被忽略,实测确认),env `NOCODB_FK_RECONCILELOG_CUSTOMER` 可覆盖。
## 修复后必须 verify
每个 customer 修复后 **GET 回读 AList user/role 实测状态**,与期望比对;
不通过 → 记 `verify_failed` 行(result=failed,不计入 repaired)。
dry-run 不 verify(没写入可验)。
## 输出 summary
```
{ runId, dryRun, at, customersScanned, driftsDetected, repaired, skipped,
errors: [{customerId, username, error}], details: [{customerId, ..., drifts[]}],
logRowsWritten, plannedLogRows (dry-run), logWriteSkipped (env 缺失时) }
```
## 与 W1/W2/W3 的交互
- 写回 Customers 只有 `alist_user_id` / `alist_role_id`(adopt 时)→ W1 的 old/new 比对视为 echo → 短路,无 loop。
- 不写 `sync_status` / `sync_required` / `last_sync`。
- W4 不碰 AccessGrants(保持原定位:手工人为授权入口,源 b)。
- AccessGrants/CustomerProducts/Customers 无 webhook 指向 W4,写 ReconcileLogs 无 loop。
## 本地实跑(绕过 n8n,直连生产)
`NOCODB_TOKEN=... ALIST_ADMIN_PASSWORD=... node scripts/live_dry_run_w4.js`(dry-run)
与 `scripts/run_real_w4.js`(本机脚本版 run_real_w4.js 在 hermes scripts/,凭据走 env)。
2026-08-23 实测记录:
1. dry-run:5 客户,3 drift(TEST-Alice: dangling_role_ref + base_path_drift + role_binding_drift),1 error(Id 7 缺 username),3 skip,零写入 ✅
2. 真实跑:3 repaired + 4 行 ReconcileLogs(FK 正确),verify 全过 ✅
3. 复跑:0 drift / 0 repair / 4 skip / 仅 Id-7 error 照记 → 幂等 ✅
## 坑(已沉淀,勿再踩)
- n8n API 导入校验严格:**节点 id 必须 UUID、顶层不能有 `pinData`**,否则 400 "additional properties"。
- n8n webhook 节点必须带 `webhookId`,否则生产 URL 永不注册(404 "not registered")。
- n8n 1.123 API 激活返回 `active:true` 但运行时激活可能被权限拒绝(日志 "attempted to activate a workflow without permissions")→ 激活状态在 DB 是 `workflow_entity.activeVersionId`(不是 active 列),boot 时加载 active + activeVersionId 的工作流注册 webhook/cron。
- NocoDB 2026.08.x:v1 column GET/PATCH 端点已删除;列 id 从 `GET /api/v2/meta/tables/{id}` 拿;v2 的列 create/PATCH 响应返回的是**表对象**不是列。
- NocoDB v2 行删除:`DELETE /records` + body `[{Id: N}, ...]`(路径 /records/{id} 和 where 参数都不行)。