feat(W3): expiry sync / full reconciliation sweep (schedule */5) + design + tested compute
- Single Code-node sweep: desired = CP-active paths UNION active AccessGrants; expiry excluded -> role/update rewrite; optional auto-disable (env-gated, default off) - disable guard: only when had scopes before (never fresh empty-role customer) - loop guard: no NocoDB Customers.status flip (avoids W1 re-provision loop) - test_w3.js mock-verified both AUTO_DISABLE modes; gen_w3.js builds JSON from w3-compute.js
This commit is contained in:
@@ -56,8 +56,18 @@ Base: **`AlistAccess`**(`p3lzkresswyowqg`),与旧 `accessDB` 完全隔离
|
||||
- **Loop guard**:新增 "Skip if no change" IF + No-op 死端(scopes 无 diff 且已 synced 时短路);去掉 last_sync
|
||||
- ⚠️ 待用户重新 import + 测试
|
||||
|
||||
### 4. W3 Expiry Sync(全量对账)— **已实现,待 import + dry-run 测试** 🆕
|
||||
- 文件:`n8n/W3-expiry-sync.json`(Schedule `*/5` → 单 Code 节点"Reconcile & Apply")
|
||||
- 设计:`n8n/W3-DESIGN.md`;参考实现 `n8n/w3-compute.js`(自包含,read+write 都在 Code 内)
|
||||
- **核心逻辑**:对每个有 `alist_role_id` 的 customer → desired = 有效 CP 派生路径 ∪ 有效 AccessGrants 路径(**并集,防止整写 scopes 清掉 manual 授权**)→ 过期排除 → diff → `role/update` 整体重写 → 选项 disable
|
||||
- **Env 开关**:`ALIST_AUTO_DISABLE_ON_EXPIRE`(默认 false=只观察)、`W3_DRY_RUN`(true=只打印计划不写)
|
||||
- **disable 护栏**:仅当 `nowEmpty && status=active && currentPaths>0`(原本有 scope 现在空)才禁用;空 role 的新客户不动(否则刚开通没买产品就被禁,W2 不会重新启用)
|
||||
- **已本地验证**:`n8n/test_w3.js` mock 数据跑通 OFF/ON 两模式,断言全过(manual 授权保留、到期清除、disable 正确、空 role 客户不动)
|
||||
- **loop 防护**:只写 AList user disabled + role scopes;**不**翻 NocoDB Customers.status(Customers update webhook→W1 可能 re-provision/re-enable=loop)
|
||||
|
||||
## 🔴 下一步(按序)
|
||||
1. **用户测试 W2**(import 覆盖 → 用例 2、3 → AList 前端实测客户登录看目录)→ 报错贴 execution 错误
|
||||
1.5. **W3 测试**:import `W3-expiry-sync.json` → 设 `W3_DRY_RUN=true` 跑一次看计划 → 造过期 CP 观察 scope 移除 → 开关 auto-disable 做 M3(详见 W3-DESIGN 里程碑)
|
||||
2. 验证通过后:**expiry scheduler**(每 5 分钟对账:全量 diff NocoDB↔AList,到期的移除 scope)—— 规格 §26/§37
|
||||
3. 其余 workflow(W3-W6):Product/Resource 变更 → 重算受影响 customer;手动 sync;日志;通知
|
||||
4. 本地 git 留档 → 验证稳定后推 Gitea(git.hoelee.com)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# W3 — Expiry Sync / 全量对账(schedule: every 5 min)
|
||||
|
||||
> 定时任务,不是 webhook。负责「到期移除 scope、恢复客户、至空则禁用」。
|
||||
> 与 W1/W2 正交:W1/W2 是事件驱动(NocoDB 变更 → 重算单个 customer);
|
||||
> W3 是周期性全量对账(扫所有 customer → desired vs actual → 修 AList / 回写 NocoDB)。
|
||||
> 幂等规则 §28:get current → compare → apply diff,重跑不产生重复写。
|
||||
|
||||
## 触发
|
||||
|
||||
- **Schedule Trigger**(`n8n-nodes-base.scheduleTrigger`),cron `*/5 * * * *`。
|
||||
- 每次**起手先 login** AList 换新 token(JWT_EXPIRE=2h,不缓存)。
|
||||
|
||||
## 核心逻辑(desired-state)
|
||||
|
||||
对每个**已有 `alist_role_id`** 的 customer:
|
||||
|
||||
1. 读该 customer 全部 CustomerProducts(`where=(customer.Id,eq,${cid})&limit=1000`)。
|
||||
2. desired paths = 仅取 **status=active 且未过期** 的 CP 的 Product→Resources `alist_path` 集合并。
|
||||
- 过期判定:`status==='expired'` 或 `expires_at < today`,一律**排除**。
|
||||
- 有效 CP 无 `expires_at` → 视为长期有效,保留。
|
||||
3. 读 AList role 当前 scopes(`role/get?id=`)。
|
||||
4. `desired paths` vs `current paths` → 差异即要改。
|
||||
- **到期移除**:current 有、desired 无 → 从 scopes 移除(整体重写 role/update)。
|
||||
- **新生效加入**:desired 有、current 无 → 加入。
|
||||
5. 有变 → `POST /api/admin/role/update` 整体重写 `permission_scopes`(幂等)。
|
||||
6. **disable 政策(开关 `ALIST_AUTO_DISABLE_ON_EXPIRE`,默认 `false`)**:
|
||||
- 若 `desired 为空` 且 该 customer NocoDB `status==='active'` →
|
||||
回写 `status='expired'`,且(开关开时)`user/update disabled=true`。
|
||||
- 默认关 = 首轮**只观察**,不自动禁用任何客户,避免误伤。
|
||||
|
||||
## 实现形态(与 W2 的差异,需知晓)
|
||||
|
||||
- **W2**(事件驱动):Code 节点算 → 独立 HTTP 节点 apply,单客户,逐步可见。
|
||||
- **W3**(后台对账):**单个 Code 节点内完成 read + write**(`this.helpers.httpRequest`),
|
||||
每客户 try/catch 收集错误进 summary,最终返回一条 summary + 可选通知。
|
||||
理由:① 无多 item 跨节点表达式陷阱(reconcile 输出 N 条会让 `$('X').item.json` 报错);
|
||||
② 定时无人值守,原子化比逐步可见更稳;③ 幂等,重复跑无副作用。
|
||||
代价:写操作在代码里,调试靠 per-customer `errors[]`。已权衡,接受。
|
||||
|
||||
## Webhook loop 防护(关键)
|
||||
|
||||
- NocoDB **webhook 触发的是 W1(Customers) 和 W2(CustomerProducts)**。
|
||||
- W3 回写要避开:
|
||||
- 改 **CustomerProducts.sync_status** → 会触发 W2 webhook;W2 自带 "skip if already synced"
|
||||
短路,安全。但 W3 应在**确无 diff** 时完全不写,减少空触发。
|
||||
- 改 **Customers.status→expired** → 会触发 W1 webhook → 可能重新 provision → loop。
|
||||
**规避**:W3 只在自己做了「去 scope 至空」时才翻 status;并且只设一个方向
|
||||
(active→expired),同一同步周期内不回头。首期**开关默认关**,连 status 翻转也延后。
|
||||
- 铁律(照 PROJECT_STATE):不盲写 `last_sync` 时间戳;语义没变就不写。
|
||||
|
||||
## 待确认决策点
|
||||
|
||||
1. disable 政策是否首期启用?(建议否,先观察 1–2 轮)
|
||||
2. 是否顺带物化 AccessGrants(W3 写 product/manual 源之外)?—— 挂在 W2 验证后那项确认。
|
||||
3. reconciliation 结果日志:**不动 schema**(AccessLogs 是用户行为审计),先只靠 n8n execution + summary。要不要新开 `ReconcileLogs` 表,验证后再说。
|
||||
|
||||
## 里程碑 / 测试
|
||||
|
||||
- M1:以 `dryRun=true` 跑一次(只读,不写 AList/NocoDB),输出「将改的 diff」清单。
|
||||
- M2:造一条 `expires_at=昨天` 的 CP,观察 W3 将其 scope 移除(开关仍关,禁用不生效)。
|
||||
- M3:开 `ALIST_AUTO_DISABLE_ON_EXPIRE=true`,造一键全到期 customer,观察 role scopes 清空 + user disabled + status=expired。
|
||||
- 全部用 n8n execution error + summary JSON 验收。
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,47 @@
|
||||
// Builds n8n/W3-expiry-sync.json from w3-compute.js (single source of truth).
|
||||
// Run: node n8n/gen_w3.js
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const jsCode = fs.readFileSync(path.join(__dirname, 'w3-compute.js'), 'utf8');
|
||||
|
||||
const workflow = {
|
||||
name: 'W3 - Expiry Sync (AList Role Reconciliation)',
|
||||
nodes: [
|
||||
{
|
||||
parameters: {
|
||||
rule: {
|
||||
interval: [
|
||||
{ field: 'minutes', minutesInterval: 5 }
|
||||
]
|
||||
}
|
||||
},
|
||||
id: 'trigger-schedule',
|
||||
name: 'Every 5 minutes',
|
||||
type: 'n8n-nodes-base.scheduleTrigger',
|
||||
typeVersion: 1.2,
|
||||
position: [0, 0]
|
||||
},
|
||||
{
|
||||
parameters: { jsCode },
|
||||
id: 'reconcile',
|
||||
name: 'Reconcile & Apply (Expiry Sweep)',
|
||||
type: 'n8n-nodes-base.code',
|
||||
typeVersion: 2,
|
||||
position: [220, 0]
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
'Every 5 minutes': {
|
||||
main: [
|
||||
[
|
||||
{ node: 'Reconcile & Apply (Expiry Sweep)', type: 'main', index: 0 }
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
settings: { executionOrder: 'v1' },
|
||||
pinData: {}
|
||||
};
|
||||
|
||||
fs.writeFileSync(path.join(__dirname, 'W3-expiry-sync.json'), JSON.stringify(workflow, null, 2) + '\n');
|
||||
console.log('wrote W3-expiry-sync.json (' + jsCode.length + ' chars jsCode)');
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
// Standalone correctness test for w3-compute.js logic.
|
||||
// Mocks this.helpers.httpRequest + $env, runs the exact n8n Code-node code,
|
||||
// and asserts desired-union / diff / disable decisions.
|
||||
// Run: node n8n/test_w3.js (from Windows bash: node n8n/test_w3.js)
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const src = fs.readFileSync(path.join(__dirname, 'w3-compute.js'), 'utf8');
|
||||
|
||||
// ---- fixtures (mirror NocoDB records / AList API shapes) ----
|
||||
// Resources: Id -> alist_path
|
||||
const RES = { 11: '/Class/Forex', 12: '/Class/Python', 13: '/Manual/Ebook' };
|
||||
// Products: Id -> [resourceIds]
|
||||
const PROD_RES = { 101: [11, 12], 102: [13] };
|
||||
// Customers: Id -> {alist_role_id, alist_user_id, status, disabled}
|
||||
const CUST = {
|
||||
1: { alist_role_id: 501, alist_user_id: 901, status: 'active', disabled: false }, // qualified; has expired CP
|
||||
2: { alist_role_id: 502, alist_user_id: 902, status: 'active', disabled: false }, // all expired -> should clear+disable(if on)
|
||||
3: { alist_role_id: 503, alist_user_id: 903, status: 'active', disabled: false }, // no scopes now
|
||||
4: { alist_role_id: null, alist_user_id: 904, status: 'active', disabled: false }, // not provisioned -> skip
|
||||
};
|
||||
// CustomerProducts: {Id, customer:Id, product:Id, expires_at, status}
|
||||
const CPS = {
|
||||
1: [{ cid: 1, product: 101, expires_at: '2026-12-31', status: 'active' }, // live -> /Class/Forex,/Class/Python
|
||||
{ cid: 1, product: 102, expires_at: '2020-01-01', status: 'active' }], // expired -> dropped
|
||||
2: [{ cid: 2, product: 101, expires_at: '2020-01-01', status: 'active' }], // expired -> empty
|
||||
3: [],
|
||||
};
|
||||
// AccessGrants: active manual grant for cust 1 on /Manual/Ebook
|
||||
const GRANTS = {
|
||||
1: [{ resource: 13, status: 'active', expires_at: null }],
|
||||
};
|
||||
// Current AList role scopes
|
||||
const ROLE_SCOPES = {
|
||||
501: ['/Class/Forex', '/Stale/Old'], // has stale -> remove; needs /Class/Python,/Manual/Ebook
|
||||
502: ['/Class/Forex'], // all expired -> clear
|
||||
503: [],
|
||||
};
|
||||
|
||||
// ---- mock httpRequest keyed by URL ----
|
||||
const calls = { roleUpdate: [], userUpdate: [] };
|
||||
function httpRequest({ method, url, headers, body }) {
|
||||
if (url.endsWith('/api/auth/login')) return { data: { token: 'tk-<TEST>' } };
|
||||
const g = (m) => new RegExp(m).exec(url);
|
||||
let m;
|
||||
// per-table list endpoints
|
||||
if (url.includes('/tables/RES/records?limit=10000')) {
|
||||
return { list: Object.keys(RES).map((Id) => ({ Id: Number(Id), alist_path: RES[Id] })) };
|
||||
}
|
||||
if (url.includes('/tables/GRANT/records?limit=10000')) {
|
||||
const list = [];
|
||||
for (const cid of Object.keys(GRANTS)) for (const gg of GRANTS[cid]) list.push({ ...gg, customer: { Id: Number(cid) } });
|
||||
return { list };
|
||||
}
|
||||
if (url.includes('/tables/CUST/records?limit=10000')) {
|
||||
return { list: Object.keys(CUST).map((Id) => ({ Id: Number(Id), username: 'u' + Id, ...CUST[Id] })) };
|
||||
}
|
||||
// customerproducts by customer: /tables/CP/records?where=(customer.Id,eq,N)&limit=1000
|
||||
if ((m = g('/tables/CP/records\\?where=\\(customer.Id,eq,([0-9]+)\\)&limit=1000'))) {
|
||||
const cid = Number(m[1]);
|
||||
return { list: (CPS[cid] || []).map((c) => ({ ...c, customer: { Id: cid } })) };
|
||||
}
|
||||
// product links: /tables/PROD/links/LNK/records/{pid}
|
||||
if ((m = g('/tables/PROD/links/LNK/records/([0-9]+)'))) {
|
||||
const pid = Number(m[1]);
|
||||
return { list: (PROD_RES[pid] || []).map((Id) => ({ Id })) };
|
||||
}
|
||||
// role get
|
||||
if ((m = g('/api/admin/role/get\\?id=([0-9]+)'))) {
|
||||
const rid = Number(m[1]);
|
||||
return { data: { id: rid, name: 'r' + rid, description: '', permission_scopes: (ROLE_SCOPES[rid] || []).map((path) => ({ path, permission: 0 })) } };
|
||||
}
|
||||
if (url.endsWith('/api/admin/role/update')) { calls.roleUpdate.push(body); return { data: null }; }
|
||||
if (url.endsWith('/api/admin/user/update')) { calls.userUpdate.push(body); return { data: null }; }
|
||||
throw new Error('no mock for ' + method + ' ' + url);
|
||||
}
|
||||
|
||||
const $env = {
|
||||
NOCODB_URL: 'http://nc', NOCODB_TOKEN: 't',
|
||||
ALIST_URL: 'http://alist',
|
||||
NOCODB_TABLE_CUSTOMERS: 'CUST', NOCODB_TABLE_CUSTOMERPRODUCTS: 'CP',
|
||||
NOCODB_TABLE_PRODUCTS: 'PROD', NOCODB_TABLE_RESOURCES: 'RES',
|
||||
NOCODB_TABLE_ACCESSGRANTS: 'GRANT', NOCODB_LINK_PROD_RESOURCES: 'LNK',
|
||||
ALIST_ADMIN_USERNAME: 'a', ALIST_ADMIN_PASSWORD: 'p',
|
||||
ALIST_AUTO_DISABLE_ON_EXPIRE: process.env.TEST_DISABLE === '1' ? 'true' : 'false',
|
||||
W3_DRY_RUN: 'false',
|
||||
};
|
||||
// w3-compute uses plain (non-arrow) functions -> `this` resolves to globalThis
|
||||
// when called bare. Wrap in an async IIFE so the file's top-level `await` works
|
||||
// (n8n Code nodes also support top-level await). The file ends with `return {json:{...}}`
|
||||
// which becomes the IIFE's resolve value ( {json: payload} ).
|
||||
globalThis.helpers = { httpRequest };
|
||||
|
||||
(async () => {
|
||||
const runner = new Function('$env', '$json', 'return (async () => {\n' + src + '\n})();');
|
||||
const json = await runner($env, {});
|
||||
const out = json.json; // unwrap the {json: {...}} wrapper
|
||||
console.log('SUMMARY:', JSON.stringify(out, null, 2));
|
||||
console.log('SUMMARY:', JSON.stringify(out, null, 2));
|
||||
|
||||
// ---- assertions ----
|
||||
const fail = (msg) => { console.error('FAIL: ' + msg); process.exitCode = 1; };
|
||||
const ok = () => {};
|
||||
const changed = out.changed;
|
||||
|
||||
const c1 = changed.find((c) => c.customerId === 1);
|
||||
if (!c1) fail('cust1 should change'); else {
|
||||
if (!c1.toRemove.includes('/Stale/Old')) fail('cust1: stale /Stale/Old not removed');
|
||||
if (!c1.toAdd.includes('/Class/Python')) fail('cust1: /Class/Python not added');
|
||||
if (!c1.toAdd.includes('/Manual/Ebook')) fail('cust1: manual /Manual/Ebook not added');
|
||||
if (c1.toAdd.includes('/Class/Forex')) fail('cust1: /Class/Forex already present, should not be toAdd');
|
||||
if (c1.applied.includes('role_scopes') === false) ok();
|
||||
}
|
||||
const c2 = changed.find((c) => c.customerId === 2);
|
||||
if (!c2) fail('cust2 should change (all expired -> clear)'); else {
|
||||
if (!c2.toRemove.includes('/Class/Forex')) fail('cust2: expired /Class/Forex not cleared');
|
||||
if (c2.desiredPaths.length !== 0) fail('cust2 desiredPaths should be empty');
|
||||
if (process.env.TEST_DISABLE === '1' && !c2.applied.includes('disable')) fail('cust2 should disable when auto-disable ON');
|
||||
if (process.env.TEST_DISABLE !== '1' && c2.applied.includes('disable')) fail('cust2 must NOT disable when auto-disable OFF');
|
||||
}
|
||||
const c3 = changed.find((c) => c.customerId === 3);
|
||||
if (c3) fail('cust3 (already empty scope) should NOT change');
|
||||
if (changed.find((c) => c.customerId === 4)) fail('cust4 (no role) must be skipped');
|
||||
|
||||
// inspect actual writes
|
||||
const ru = calls.roleUpdate.find((b) => b.id === 501);
|
||||
if (ru) {
|
||||
const paths = (ru.permission_scopes || []).map((s) => s.path);
|
||||
if (!paths.includes('/Class/Forex')) fail('write cust1 lost /Class/Forex');
|
||||
if (!paths.includes('/Manual/Ebook')) fail('write cust1 missing manual grant');
|
||||
if (paths.includes('/Stale/Old')) fail('write cust1 kept stale');
|
||||
} else fail('no role/update call for cust1');
|
||||
if (process.env.TEST_DISABLE === '1' && !calls.userUpdate.find((b) => b.id === 902)) fail('cust2 user not disabled');
|
||||
|
||||
const nFail = Object.keys(calls.roleUpdate).length; // crude; we already assert specifics
|
||||
console.log(process.exitCode ? '\n>>> TEST FAILED' : '\n>>> ALL CHECKS PASSED');
|
||||
})();
|
||||
@@ -0,0 +1,124 @@
|
||||
// W3 - Expiry Sync / full reconciliation sweep (self-contained read+write).
|
||||
// Runs every 5 min. idempotent. get current -> compare -> apply diff.
|
||||
// Reads config via $env (works in n8n Code node).
|
||||
// dryRun: set W3_DRY_RUN=true env to only PRINT the plan (no AList/NocoDB writes).
|
||||
// AUTO_DISABLE: ALIST_AUTO_DISABLE_ON_EXPIRE=true -> disable AList user when desired empty.
|
||||
// Default off = first rollout observes only. When on, ONLY AList user disabled (NocoDB Customers.status
|
||||
// intentionally NOT flipped: Customers update webhook -> W1 could re-provision / re-enable = loop).
|
||||
|
||||
const NC = $env.NOCODB_URL;
|
||||
const NC_TOKEN = $env.NOCODB_TOKEN;
|
||||
const ALIST = $env.ALIST_URL;
|
||||
const T_CUST = $env.NOCODB_TABLE_CUSTOMERS;
|
||||
const T_CUSTPROD= $env.NOCODB_TABLE_CUSTOMERPRODUCTS;
|
||||
const T_PROD = $env.NOCODB_TABLE_PRODUCTS;
|
||||
const T_RES = $env.NOCODB_TABLE_RESOURCES;
|
||||
const T_GRANT = $env.NOCODB_TABLE_ACCESSGRANTS;
|
||||
const LNK_PROD_RES = $env.NOCODB_LINK_PROD_RESOURCES; // c4ko4x8kwi16swg
|
||||
|
||||
const DRY_RUN = ($env.W3_DRY_RUN || '').toLowerCase() === 'true';
|
||||
const AUTO_DISABLE = ($env.ALIST_AUTO_DISABLE_ON_EXPIRE || '').toLowerCase() === 'true';
|
||||
const today = new Date().toISOString().slice(0, 10); // 'YYYY-MM-DD'
|
||||
|
||||
async function ncGet(path) {
|
||||
return await this.helpers.httpRequest({
|
||||
method: 'GET', url: NC + path,
|
||||
headers: { 'xc-token': NC_TOKEN }, json: true,
|
||||
});
|
||||
}
|
||||
async function alistReq(method, path, body, token) {
|
||||
return await this.helpers.httpRequest({
|
||||
method, url: ALIST + path,
|
||||
headers: token ? { Authorization: 'Bearer ' + token } : {},
|
||||
body, json: true,
|
||||
});
|
||||
}
|
||||
function linkId(v) {
|
||||
if (v == null) return null;
|
||||
if (typeof v === 'object' && !Array.isArray(v)) return v.Id ?? v.id ?? null;
|
||||
if (Array.isArray(v)) return v[0]?.Id ?? v[0]?.id ?? null;
|
||||
return v;
|
||||
}
|
||||
|
||||
// login once
|
||||
const login = await alistReq('POST', '/api/auth/login', {
|
||||
username: $env.ALIST_ADMIN_USERNAME,
|
||||
password: $env.ALIST_ADMIN_PASSWORD,
|
||||
}, null);
|
||||
const token = login?.data?.token;
|
||||
if (!token) throw new Error('AList login failed');
|
||||
|
||||
// index all Resources by Id -> alist_path
|
||||
const allRes = (await ncGet(`/api/v2/tables/${T_RES}/records?limit=10000`)).list || [];
|
||||
const resPath = {}; allRes.forEach(r => { resPath[r.Id] = r.alist_path; });
|
||||
|
||||
// all access grants (materialized + manual), grouped by customer
|
||||
const grants = (await ncGet(`/api/v2/tables/${T_GRANT}/records?limit=10000`)).list || [];
|
||||
const gByCust = {};
|
||||
for (const g of grants) {
|
||||
const cid = linkId(g.customer); if (!cid) continue;
|
||||
(gByCust[cid] = gByCust[cid] || []).push(g);
|
||||
}
|
||||
|
||||
const customers = (await ncGet(`/api/v2/tables/${T_CUST}/records?limit=10000`)).list || [];
|
||||
const changed = [], errors = [];
|
||||
|
||||
for (const cust of customers) {
|
||||
try {
|
||||
if (!cust.alist_role_id) continue; // not provisioned
|
||||
const cid = cust.Id;
|
||||
// desired = CP-derived paths (active, not expired) union effective grants
|
||||
const desired = {}; // path -> true
|
||||
const cps = (await ncGet(`/api/v2/tables/${T_CUSTPROD}/records?where=(customer.Id,eq,${cid})&limit=1000`)).list || [];
|
||||
for (const c of cps) {
|
||||
if (c.status && c.status !== 'active') continue;
|
||||
if (c.expires_at && c.expires_at < today) continue; // expired
|
||||
const pid = linkId(c.product); if (!pid) continue;
|
||||
const res = (await ncGet(`/api/v2/tables/${T_PROD}/links/${LNK_PROD_RES}/records/${pid}`)).list || [];
|
||||
for (const r of res) { const p = resPath[r.Id]; if (p) desired[p] = true; }
|
||||
}
|
||||
for (const g of (gByCust[cid] || [])) {
|
||||
if (g.status && g.status !== 'active') continue;
|
||||
if (g.expires_at && g.expires_at < today) continue;
|
||||
const p = resPath[linkId(g.resource)]; if (p) desired[p] = true;
|
||||
}
|
||||
const desiredPaths = Object.keys(desired).sort();
|
||||
|
||||
const role = await alistReq('GET', `/api/admin/role/get?id=${cust.alist_role_id}`, null, token);
|
||||
const currentPaths = (role?.data?.permission_scopes || []).map(s => s.path);
|
||||
const toRemove = currentPaths.filter(p => !desired[p]);
|
||||
const toAdd = desiredPaths.filter(p => !currentPaths.includes(p));
|
||||
const scopesChanged = toAdd.length > 0 || toRemove.length > 0;
|
||||
const nowEmpty = desiredPaths.length === 0;
|
||||
// WF3: no remaining active grant + policy on + customer active + HAD scopes before -> disable.
|
||||
// Guard currentPaths>0: never disable a fresh customer with an empty role (avoids
|
||||
// killing a just-provisioned user before they buy anything; W2 won't re-enable them).
|
||||
const shouldDisable = AUTO_DISABLE && nowEmpty && cust.status === 'active' && currentPaths.length > 0;
|
||||
|
||||
let applied = [];
|
||||
if (scopesChanged) {
|
||||
const newScopes = desiredPaths.map(p => ({ path: p, permission: 0 }));
|
||||
if (!DRY_RUN) await alistReq('POST', '/api/admin/role/update',
|
||||
{ id: cust.alist_role_id, name: role?.data?.name, description: role?.data?.description,
|
||||
permission_scopes: newScopes }, token);
|
||||
applied.push('role_scopes');
|
||||
}
|
||||
if (shouldDisable) {
|
||||
if (!DRY_RUN && cust.alist_user_id)
|
||||
await alistReq('POST', '/api/admin/user/update', { id: cust.alist_user_id, disabled: true }, token);
|
||||
applied.push('disable');
|
||||
}
|
||||
if (applied.length) {
|
||||
changed.push({ customerId: cid, username: cust.username, roleId: cust.alist_role_id,
|
||||
toAdd, toRemove, desiredPaths, applied });
|
||||
}
|
||||
} catch (e) {
|
||||
errors.push({ customerId: cust.Id, username: cust.username, error: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
return { json: {
|
||||
dryRun: DRY_RUN, autoDisable: AUTO_DISABLE, at: new Date().toISOString(),
|
||||
customersScanned: customers.length, customersChanged: changed.length,
|
||||
changed, errors,
|
||||
} };
|
||||
Reference in New Issue
Block a user