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
+54
View File
@@ -0,0 +1,54 @@
// Live dry-run of the W4 Code node against REAL AList + NocoDB, outside n8n.
// Runs the exact inlined jsCode from W4-daily-reconcile.json in a vm sandbox,
// with helpers.httpRequest backed by node fetch. ZERO writes (dryRun=true).
// Usage: node scripts/live_dry_run_w4.js
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const wf = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'n8n', 'W4-daily-reconcile.json'), 'utf8'));
const code = wf.nodes.find(n => n.name === 'Reconcile & Repair (Daily Full)').parameters.jsCode;
const ENV = {
NOCODB_URL: 'https://nocodb.hoelee.com',
NOCODB_TOKEN: process.env.NOCODB_TOKEN, // from env, never hardcoded — same for passwords below
NOCODB_TABLE_CUSTOMERS: 'maqb9zgvza2te3k',
NOCODB_TABLE_PRODUCTS: 'molyb3pxv81l75g',
NOCODB_TABLE_RESOURCES: 'm6cm7tkxzd8m18c',
NOCODB_TABLE_CUSTOMERPRODUCTS: 'mvyqbv8ao7u6bct',
NOCODB_TABLE_ACCESSGRANTS: 'mxdx3yhfcyej3ao',
NOCODB_TABLE_RECONCILELOGS: 'mktsb7le6fqr2u1',
NOCODB_FK_RECONCILELOG_CUSTOMER: 'nc_jitu___Customers_id',
NOCODB_LINK_PROD_RESOURCES: 'c4ko4x8kwi16swg',
ALIST_URL: process.env.ALIST_URL || 'http://192.168.1.1:5244', // DSM host port (container DNS only inside bridge)
ALIST_ADMIN_USERNAME: process.env.ALIST_ADMIN_USERNAME || 'hoelee',
ALIST_ADMIN_PASSWORD: process.env.ALIST_ADMIN_PASSWORD, // from env
W4_DRY_RUN: 'false',
};
if (!ENV.NOCODB_TOKEN || !ENV.ALIST_ADMIN_PASSWORD) { console.error('NOCODB_TOKEN + ALIST_ADMIN_PASSWORD env required'); process.exit(1); }
const httpRequest = async (opts) => {
const { method = 'GET', url, headers = {}, body } = opts;
const res = await fetch(url, {
method,
headers: { ...headers, 'Content-Type': 'application/json' },
body: body !== undefined && body !== null ? JSON.stringify(body) : undefined,
});
const text = await res.text();
let parsed = null;
try { parsed = text ? JSON.parse(text) : null; } catch { parsed = text; }
return parsed;
};
const sandbox = {
$env: ENV,
$json: { query: { dryRun: 'true' } },
console,
};
sandbox.helpers = { httpRequest };
(async () => {
const fn = vm.runInNewContext(`(async function(){\n${code}\n})`, sandbox, { filename: 'w4-body-live.js' });
const result = await fn();
console.log(JSON.stringify(result.json, null, 1));
})().catch(e => { console.error('LIVE RUN FAILED:', e); process.exit(1); });