From d7fad26d9d532ab6b4fd761e6779bb4019b5c5e5 Mon Sep 17 00:00:00 2001 From: hoelee Date: Fri, 28 Aug 2026 22:12:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(n8n):=20W5=20=E8=BF=94=E5=9B=9E=E6=AF=8F?= =?UTF-8?q?=E4=BA=A7=E5=93=81=20paths[]=20(Products=E2=86=92Resources=20al?= =?UTF-8?q?ist=5Fpath)=20=E6=94=AF=E6=8C=81=20footer=20modal=20path=20?= =?UTF-8?q?=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - w5-body.js: 每 product 走 Products→Resources mm link 取 alist_path, 复用 effective-grants 的 '先 fetch 全 Resources 索引 Id→alist_path' 模式 (link API 只返 {Id,name} 不返 alist_path) - 新增 paths 字段于 purchases[]; 无关联资源返 [] - deploy_w5.mjs: PUT /api/v1/workflows/MNC0BZc1FqCKFwIV 部署脚本 --- n8n/W5-purchase-check.json | 2 +- n8n/deploy_w5.mjs | 23 ++++++++++++++++++++ n8n/w5-body.js | 44 ++++++++++++++++++++++++++++++++++---- 3 files changed, 64 insertions(+), 5 deletions(-) create mode 100644 n8n/deploy_w5.mjs diff --git a/n8n/W5-purchase-check.json b/n8n/W5-purchase-check.json index 7a8c6a6..aabed81 100644 --- a/n8n/W5-purchase-check.json +++ b/n8n/W5-purchase-check.json @@ -34,7 +34,7 @@ }, { "parameters": { - "jsCode": "// ============================================================================\n// W5 runtime body — footer purchase/expiry lookup (PUBLIC, READ-ONLY).\n// Trigger: GET webhook /dk-purchase-check?u=\n// Returns: { username, known, purchases: [{product, expires_at, display_state}] }\n//\n// Security model (no HMAC — key would ship to the client, so it is theatre):\n// - CORS: response header Access-Control-Allow-Origin: https://drive.digikedai.com\n// → only the AList portal origin may READ the response from a browser.\n// - Minimal data: product name + expiry + display_state only (no internal Ids).\n// - Server-side enumeration is mitigated by a Cloudflare WAF rate-limit +\n// Origin/Referer rule on auto.hoelee.com (see delivery guide).\n// - No AList auth requests of any kind (username comes from the caller's own\n// JWT, decoded client-side in the footer) → no empty-client device drift.\n// ============================================================================\n\n// n8n Code node context (same pattern as W1–W4).\nconst ctx = { $env, helpers: this.helpers };\n\n// GET webhook: n8n puts query-string params under $json.query (fallback: body).\nconst item = $json ?? ($input && $input.item ? $input.item.json : {});\nconst q = item.query ?? item.body ?? {};\nconst username = String(q.u ?? '').trim();\n\nfunction linkId(v) {\n if (v == null) return null;\n if (typeof v === 'object' && !Array.isArray(v)) return v.Id ?? v.id ?? null;\n if (Array.isArray(v)) return v[0]?.Id ?? v[0]?.id ?? null;\n return v;\n}\n\nasync function ncGet(path) {\n return await ctx.helpers.httpRequest({\n method: 'GET',\n url: ctx.$env.NOCODB_URL + path,\n headers: { 'xc-token': ctx.$env.NOCODB_TOKEN },\n json: true,\n });\n}\n\nconst empty = () => ({ username, known: false, purchases: [] });\n\nif (!username) return { json: empty() };\n\ntry {\n const T_CUST = ctx.$env.NOCODB_TABLE_CUSTOMERS;\n const T_CP = ctx.$env.NOCODB_TABLE_CUSTOMERPRODUCTS;\n const today = new Date().toISOString().slice(0, 10);\n\n // 1. resolve username -> customer (username is unique in Customers)\n const custRows = (await ncGet(\n `/api/v2/tables/${T_CUST}/records?where=(username,eq,${encodeURIComponent(username)})&limit=1`\n )).list || [];\n const customer = custRows[0];\n if (!customer) return { json: empty() };\n\n // 2. this customer's products (link query by Id, per NocoDB v2 gotchas)\n const cps = (await ncGet(\n `/api/v2/tables/${T_CP}/records?where=(customer.Id,eq,${customer.Id})&limit=500`\n )).list || [];\n\n const purchases = cps\n .map((c) => {\n const productObj = (typeof c.product === 'object' && c.product) ? c.product : null;\n const name = productObj && productObj.name ? String(productObj.name) : 'Product';\n const expires = c.expires_at ? String(c.expires_at).slice(0, 10) : null;\n let state = (c.status && c.status !== 'active') ? c.status : 'active';\n if (state === 'active' && expires && expires <= today) state = 'expired';\n return { product: name, expires_at: expires, display_state: state };\n })\n .sort((a, b) => {\n const rank = { active: 0, suspended: 1, revoked: 2, expired: 3 };\n if (rank[a.display_state] !== rank[b.display_state]) return rank[a.display_state] - rank[b.display_state];\n return String(a.expires_at || '').localeCompare(String(b.expires_at || ''));\n });\n\n return { json: { username, known: true, purchases } };\n} catch (err) {\n // graceful: footer renders static rows only on any error\n return { json: { ...empty(), error: String((err && err.message) || err) } };\n}\n" + "jsCode": "// ============================================================================\n// W5 runtime body — footer purchase/expiry lookup (PUBLIC, READ-ONLY).\n// Trigger: GET webhook /dk-purchase-check?u=\n// Returns: { username, known, purchases: [{product, expires_at, display_state, paths:[...]}] }\n//\n// v30 (2026-08-28): adds `paths` per purchase — each product's resources' alist_path\n// (via Products→Resources mm link NOCODB_LINK_PROD_RESOURCES), so the footer modal\n// can show a collapsible resource-path list. Products with no linked resources\n// return paths: [].\n//\n// Security model (no HMAC — key would ship to the client, so it is theatre):\n// - CORS: response header Access-Control-Allow-Origin: https://drive.digikedai.com\n// → only the AList portal origin may READ the response from a browser.\n// - Minimal data: product name + expiry + display_state + public folder paths only\n// (no internal NocoDB Ids, no customer PII).\n// - Server-side enumeration is mitigated by a Cloudflare WAF rate-limit +\n// Origin/Referer rule (see delivery guide).\n// - No AList auth requests of any kind (username comes from the caller's own\n// JWT, decoded client-side in the footer) → no empty-client device drift.\n// ============================================================================\n\n// n8n Code node context (same pattern as W1–W4).\nconst ctx = { $env, helpers: this.helpers };\n\n// GET webhook: n8n puts query-string params under $json.query (fallback: body).\nconst item = $json ?? ($input && $input.item ? $input.item.json : {});\nconst q = item.query ?? item.body ?? {};\nconst username = String(q.u ?? '').trim();\n\nfunction linkId(v) {\n if (v == null) return null;\n if (typeof v === 'object' && !Array.isArray(v)) return v.Id ?? v.id ?? null;\n if (Array.isArray(v)) return v[0]?.Id ?? v[0]?.id ?? null;\n return v;\n}\n\nasync function ncGet(path) {\n return await ctx.helpers.httpRequest({\n method: 'GET',\n url: ctx.$env.NOCODB_URL + path,\n headers: { 'xc-token': ctx.$env.NOCODB_TOKEN },\n json: true,\n });\n}\n\nconst empty = () => ({ username, known: false, purchases: [] });\n\nif (!username) return { json: empty() };\n\ntry {\n const T_CUST = ctx.$env.NOCODB_TABLE_CUSTOMERS;\n const T_CP = ctx.$env.NOCODB_TABLE_CUSTOMERPRODUCTS;\n const T_PROD = ctx.$env.NOCODB_TABLE_PRODUCTS;\n const T_RES = ctx.$env.NOCODB_TABLE_RESOURCES;\n const LNK_PROD_RES = ctx.$env.NOCODB_LINK_PROD_RESOURCES; // c4ko4x8kwi16swg\n const today = new Date().toISOString().slice(0, 10);\n\n // 1. resolve username -> customer (username is unique in Customers)\n const custRows = (await ncGet(\n `/api/v2/tables/${T_CUST}/records?where=(username,eq,${encodeURIComponent(username)})&limit=1`\n )).list || [];\n const customer = custRows[0];\n if (!customer) return { json: empty() };\n\n // 2. this customer's products (link query by Id, per NocoDB v2 gotchas)\n const cps = (await ncGet(\n `/api/v2/tables/${T_CP}/records?where=(customer.Id,eq,${customer.Id})&limit=500`\n )).list || [];\n\n // 3. resolve paths per product (Products→Resources mm link → alist_path).\n // Fetch all Resources once, index Id → alist_path (link API only returns\n // {Id, name}, not alist_path — see effective-grants.js pattern).\n const allRes = (await ncGet(\n `/api/v2/tables/${T_RES}/records?limit=10000&fields=Id,alist_path`\n )).list || [];\n const resPath = {};\n allRes.forEach((r) => { resPath[r.Id] = r.alist_path; });\n\n const prodIds = [...new Set(cps.map((c) => linkId(c.product)).filter(Boolean))];\n\n const pathsByProduct = {}; // pid -> [alist_path, ...]\n for (const pid of prodIds) {\n let paths = [];\n try {\n const res = (await ncGet(\n `/api/v2/tables/${T_PROD}/links/${LNK_PROD_RES}/records/${pid}`\n )).list || [];\n paths = res.map((r) => resPath[r.Id]).filter((p) => p != null && String(p).length > 0);\n } catch (e) {\n paths = []; // graceful: no paths rather than failing the whole lookup\n }\n pathsByProduct[pid] = paths.sort();\n }\n\n const purchases = cps\n .map((c) => {\n const productObj = (typeof c.product === 'object' && c.product) ? c.product : null;\n const name = productObj && productObj.name ? String(productObj.name) : 'Product';\n const pid = linkId(c.product);\n const expires = c.expires_at ? String(c.expires_at).slice(0, 10) : null;\n let state = (c.status && c.status !== 'active') ? c.status : 'active';\n if (state === 'active' && expires && expires <= today) state = 'expired';\n const paths = pid ? (pathsByProduct[pid] || []) : [];\n return { product: name, expires_at: expires, display_state: state, paths };\n })\n .sort((a, b) => {\n const rank = { active: 0, suspended: 1, revoked: 2, expired: 3 };\n if (rank[a.display_state] !== rank[b.display_state]) return rank[a.display_state] - rank[b.display_state];\n return String(a.expires_at || '').localeCompare(String(b.expires_at || ''));\n });\n\n return { json: { username, known: true, purchases } };\n} catch (err) {\n // graceful: footer renders static rows only on any error\n return { json: { ...empty(), error: String((err && err.message) || err) } };\n}\n" }, "id": "lookup", "name": "Lookup Purchases", diff --git a/n8n/deploy_w5.mjs b/n8n/deploy_w5.mjs new file mode 100644 index 0000000..490004b --- /dev/null +++ b/n8n/deploy_w5.mjs @@ -0,0 +1,23 @@ +// Deploy regenerated W5 (v30, adds paths) to live n8n via public API. +// Usage: N8N_API_KEY=... node deploy_w5.mjs +import { readFile } from "node:fs/promises"; + +const N8N = process.env.N8N_BASE || "http://192.168.137.2:5678"; +const KEY = process.env.N8N_API_KEY; +if (!KEY) { console.error("N8N_API_KEY env required"); process.exit(1); } +const H = { "X-N8N-API-KEY": KEY, "Content-Type": "application/json" }; + +const wf = JSON.parse(await readFile("W5-purchase-check.json", "utf-8")); +delete wf.pinData; +delete wf.active; +delete wf.id; + +// W5 workflow id (live) = MNC0BZc1FqCKFwIV +const ID = "MNC0BZc1FqCKFwIV"; +const r = await fetch(`${N8N}/api/v1/workflows/${ID}`, { + method: "PUT", headers: H, body: JSON.stringify(wf), +}); +const txt = await r.text(); +console.log("W5 PUT", r.status, txt.slice(0, 300)); +if (r.status !== 200) process.exit(1); +console.log("DEPLOY OK"); diff --git a/n8n/w5-body.js b/n8n/w5-body.js index fbf2bd7..1538db1 100644 --- a/n8n/w5-body.js +++ b/n8n/w5-body.js @@ -1,14 +1,20 @@ // ============================================================================ // W5 runtime body — footer purchase/expiry lookup (PUBLIC, READ-ONLY). // Trigger: GET webhook /dk-purchase-check?u= -// Returns: { username, known, purchases: [{product, expires_at, display_state}] } +// Returns: { username, known, purchases: [{product, expires_at, display_state, paths:[...]}] } +// +// v30 (2026-08-28): adds `paths` per purchase — each product's resources' alist_path +// (via Products→Resources mm link NOCODB_LINK_PROD_RESOURCES), so the footer modal +// can show a collapsible resource-path list. Products with no linked resources +// return paths: []. // // Security model (no HMAC — key would ship to the client, so it is theatre): // - CORS: response header Access-Control-Allow-Origin: https://drive.digikedai.com // → only the AList portal origin may READ the response from a browser. -// - Minimal data: product name + expiry + display_state only (no internal Ids). +// - Minimal data: product name + expiry + display_state + public folder paths only +// (no internal NocoDB Ids, no customer PII). // - Server-side enumeration is mitigated by a Cloudflare WAF rate-limit + -// Origin/Referer rule on auto.hoelee.com (see delivery guide). +// Origin/Referer rule (see delivery guide). // - No AList auth requests of any kind (username comes from the caller's own // JWT, decoded client-side in the footer) → no empty-client device drift. // ============================================================================ @@ -44,6 +50,9 @@ if (!username) return { json: empty() }; try { const T_CUST = ctx.$env.NOCODB_TABLE_CUSTOMERS; const T_CP = ctx.$env.NOCODB_TABLE_CUSTOMERPRODUCTS; + const T_PROD = ctx.$env.NOCODB_TABLE_PRODUCTS; + const T_RES = ctx.$env.NOCODB_TABLE_RESOURCES; + const LNK_PROD_RES = ctx.$env.NOCODB_LINK_PROD_RESOURCES; // c4ko4x8kwi16swg const today = new Date().toISOString().slice(0, 10); // 1. resolve username -> customer (username is unique in Customers) @@ -58,14 +67,41 @@ try { `/api/v2/tables/${T_CP}/records?where=(customer.Id,eq,${customer.Id})&limit=500` )).list || []; + // 3. resolve paths per product (Products→Resources mm link → alist_path). + // Fetch all Resources once, index Id → alist_path (link API only returns + // {Id, name}, not alist_path — see effective-grants.js pattern). + const allRes = (await ncGet( + `/api/v2/tables/${T_RES}/records?limit=10000&fields=Id,alist_path` + )).list || []; + const resPath = {}; + allRes.forEach((r) => { resPath[r.Id] = r.alist_path; }); + + const prodIds = [...new Set(cps.map((c) => linkId(c.product)).filter(Boolean))]; + + const pathsByProduct = {}; // pid -> [alist_path, ...] + for (const pid of prodIds) { + let paths = []; + try { + const res = (await ncGet( + `/api/v2/tables/${T_PROD}/links/${LNK_PROD_RES}/records/${pid}` + )).list || []; + paths = res.map((r) => resPath[r.Id]).filter((p) => p != null && String(p).length > 0); + } catch (e) { + paths = []; // graceful: no paths rather than failing the whole lookup + } + pathsByProduct[pid] = paths.sort(); + } + const purchases = cps .map((c) => { const productObj = (typeof c.product === 'object' && c.product) ? c.product : null; const name = productObj && productObj.name ? String(productObj.name) : 'Product'; + const pid = linkId(c.product); const expires = c.expires_at ? String(c.expires_at).slice(0, 10) : null; let state = (c.status && c.status !== 'active') ? c.status : 'active'; if (state === 'active' && expires && expires <= today) state = 'expired'; - return { product: name, expires_at: expires, display_state: state }; + const paths = pid ? (pathsByProduct[pid] || []) : []; + return { product: name, expires_at: expires, display_state: state, paths }; }) .sort((a, b) => { const rank = { active: 0, suspended: 1, revoked: 2, expired: 3 };