From 414bf31ba0b75a6c68e67744c60d50e7008b8c13 Mon Sep 17 00:00:00 2001 From: hoelee Date: Fri, 28 Aug 2026 18:24:42 +0800 Subject: [PATCH] =?UTF-8?q?feat(n8n):=20W5=20footer=20purchase-check=20web?= =?UTF-8?q?hook=20(=E5=85=AC=E5=BC=80=E5=8F=AA=E8=AF=BB)=20+=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - W5 gets username ?u= → Customers→CustomerProducts → {username,known,purchases} - 无 HMAC(密钥会下发客户端): 改用 CORS 限 drive.digikedai.com + 最小数据 + CF WAF 建议 - 复用现有 NOCODB_* env, 零新增 env - 2 节点: webhook(GET, lastNode, CORS 头) + Code(w5-body.js) - footer v2.6 动态行数据源; 待手动导入 n8n - docs: N8N_WORKFLOWS + PROJECT_STATE 记录 W5 + 导入步骤 --- docs/N8N_WORKFLOWS.md | 25 ++++++++- docs/PROJECT_STATE.md | 4 ++ n8n/W5-purchase-check.json | 66 ++++++++++++++++++++++ n8n/gen_w5.js | 62 +++++++-------------- n8n/w5-body.js | 110 ++++++++++++++++++------------------- 5 files changed, 166 insertions(+), 101 deletions(-) create mode 100644 n8n/W5-purchase-check.json diff --git a/docs/N8N_WORKFLOWS.md b/docs/N8N_WORKFLOWS.md index 9c6f1ca..9cddf78 100644 --- a/docs/N8N_WORKFLOWS.md +++ b/docs/N8N_WORKFLOWS.md @@ -3,7 +3,7 @@ > n8n 运行在现有 auto.hoelee.com(`bridge_hoelee` 网络)。它是**同步器,不是 SOT**。 > 更新日期 2026-08-23。 -## 已实现的三个 Workflow +## 已实现的 Workflow | Workflow | ID | 触发 | 职责 | |---|---|---|---| @@ -11,9 +11,10 @@ | **W2** CustomerProduct Sync | `D9OWAI7JUjKnRdOn` | NocoDB CustomerProducts webhook | 重算该客有效 grants 并应用 | | **W3** Expiry Sync | `zlEfjj5eK5IfILtk` | schedule `*/5` | 定时 | 全量对账 + drift 修复(scopes 层) | | **W4** Daily Full Reconciliation | `pl8j9lpg5gjrd0dB` | 每日 03:00 + webhook `alist-w4-reconcile` | 全量对账 + 用户/角色级 drift 修复 + ReconcileLogs | +| **W5** Footer Purchase Check | (导入后由 n8n 分配) | GET webhook `dk-purchase-check` | footer 动态行数据源(公开只读) | -三个 workflow 共享同一套算法 `calculateEffectiveGrants`(`n8n/shared/effective-grants.js`, -构建时经 `gen_w1/w2/w3.js` 内联进各 Code 节点)。 +W1–W4 共享同一套算法 `calculateEffectiveGrants`(`n8n/shared/effective-grants.js`, +构建时经 `gen_w1/w2/w3/w4.js` 内联进各 Code 节点)。W5 是独立的**公开只读** webhook(不属同步器)。 ## W1 — Customer Provision & Status Lifecycle @@ -48,6 +49,24 @@ - 密码随 repair 不推送(方案 C) - 详见 `docs/W4-DESIGN.md` +## W5 — Footer Purchase Check(公开只读,不属同步器) + +- 触发:`GET /webhook/dk-purchase-check?u=`(公网经 `auto.hoelee.com`) +- 职责:footer v2.6 动态行数据源——按 username 查 Customers → 拉该客 CustomerProducts → + 返回 `{ username, known, purchases:[{product, expires_at, display_state}] }` +- 安全模型(**无 HMAC**——key 会随 footer JS 下发到客户端=空话): + - CORS:webhook `options.responseHeaders` 设 `Access-Control-Allow-Origin: https://drive.digikedai.com` + → 仅 AList 门户 origin 能读响应 + - 最小数据:只回 product 名 + 到期 + display_state,**不含内部 Id** + - 服务器枚举靠 CF WAF 在 auto.hoelee.com 上做限流 + Origin/Referer 规则(见交付指南) +- 零 AList 认证请求:username 由 footer 本地解码用户自己的 JWT(`localStorage['token']`)取出 → + 无「空客户端设备驱逐」风险(v2.3 事故机制完全不涉及) +- 复用现有 env(`NOCODB_URL`/`NOCODB_TOKEN`/`NOCODB_TABLE_CUSTOMERS`/`NOCODB_TABLE_CUSTOMERPRODUCTS`),**零新增 env** +- 生成:`node n8n/gen_w5.js` → `W5-purchase-check.json`(2 节点:webhook[GET, lastNode, CORS 头] + Code「Lookup Purchases」) +- 导入(手动):n8n → Import from File → `W5-purchase-check.json` → 记下 Webhook URL(应为 + `https://auto.hoelee.com/webhook/dk-purchase-check`)→ 激活。测试: + `curl 'https://auto.hoelee.com/webhook/dk-purchase-check?u=tester'` 应回 JSON 含 `known:true` + 2 条 purchases + ## 共用 env 变量 n8n Code 节点内用 `$env.VAR`(`process.env` 不可靠)。下表是本项目需要的 env: diff --git a/docs/PROJECT_STATE.md b/docs/PROJECT_STATE.md index b077d76..2f10b5c 100644 --- a/docs/PROJECT_STATE.md +++ b/docs/PROJECT_STATE.md @@ -33,8 +33,12 @@ 7. **W4** 每日 03:00 全量对账 + ReconcileLogs 审计 — 本地 + 直连生产实测通过(dry-run→repair→复跑幂等;34 项 mock 测试过);单向同步 NocoDB=SOT,密码不随 repair 推送(方案 C) 8. 文档体系:ARCHITECTURE / PERMISSIONS / ADMIN_GUIDE / DATABASE_SCHEMA / N8N_WORKFLOWS 等;git 已推 Gitea 9. **公网链路切换(08-25)**:DSM 文件统一收进 `/volume1/docker/alist-digikedai/`(容器名全部未动——n8n `ALIST_URL` 硬依赖);CF Tunnel `drive.digikedai.com` 回源 `http://172.16.0.1:5246`;traefik 删除 `/login`→n8n 劫持路由(曾致登录页挂起);nginx 移除 drive 域不限速白名单(现吃默认 1.5MB/s + limit_conn 10/IP)。完整记录 = DSM `/volume1/docker/alist-digikedai/alist-serve-resource/NETWORK_ARCHITECTURE.md` +10. **W5 + footer v2.6(08-28)**:footer 动态购买行——本地解码用户 JWT 取 username → GET webhook `dk-purchase-check?u=` → NocoDB 查购买/到期 → 渲染。W5 源(`n8n/w5-body.js` + `gen_w5.js` → `W5-purchase-check.json`)已入库,**待用户手动导入 n8n + 激活**;footer v2.6 已部署线上(详见 dsm-resource-management `desc/DELIVERY_GUIDE.md` §7.10 + skill `alist-footer`)。实测 headless 9/9 PASS(含 CDP 模拟 W5 响应验证整链路)。安全= CORS 限 drive 域 + 最小数据 + 零新增 env(复用 NOCODB_*) ## 🔴 下一步(按序) +**(紧迫)手动导入 W5**:n8n → Import from File → `n8n/W5-purchase-check.json` → 激活 → 记 webhook URL +(`https://auto.hoelee.com/webhook/dk-purchase-check`)。测:`curl 'https://auto.hoelee.com/webhook/dk-purchase-check?u=tester'` +应回 `known:true` + 2 条 purchases。导入后 footer 动态行即自动上线(无需再改 footer)。 1. **补 n8n env 并 rebuild(Portainer stack 144,改 env 必须 rebuild)**:`NOCODB_TABLE_RECONCILELOGS=mktsb7le6fqr2u1` + `NOCODB_FK_RECONCILELOG_CUSTOMER=nc_jitu___Customers_id`(W4 审计落库当前被跳过);顺手加 `TZ=Asia/Kuala_Lumpur` 修 UTC 时区 2. **决策:auto.hoelee.com 是否退役/迁移**——n8n 公网访问现在只剩 UI 用途;hook 已容器化。若保留,建议迁到 CF Tunnel 新链路;若退役,同步删 VPS DNS + DSM 反代条目 3. **清理上个会话残留**:NocoDB Customer Id=8 `e2e_test_01` + AList user 17 / role `customer-e2e_test_01` diff --git a/n8n/W5-purchase-check.json b/n8n/W5-purchase-check.json new file mode 100644 index 0000000..7a8c6a6 --- /dev/null +++ b/n8n/W5-purchase-check.json @@ -0,0 +1,66 @@ +{ + "name": "W5 - Footer Purchase Check (Public)", + "nodes": [ + { + "parameters": { + "httpMethod": "GET", + "path": "dk-purchase-check", + "responseMode": "lastNode", + "options": { + "responseCode": 200, + "responseHeaders": { + "entries": [ + { + "name": "Access-Control-Allow-Origin", + "value": "https://drive.digikedai.com" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ] + } + } + }, + "id": "trigger-webhook", + "name": "Footer Purchase Check (Webhook)", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "position": [ + 0, + 0 + ], + "webhookId": "dk-purchase-check" + }, + { + "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" + }, + "id": "lookup", + "name": "Lookup Purchases", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 220, + 0 + ] + } + ], + "connections": { + "Footer Purchase Check (Webhook)": { + "main": [ + [ + { + "node": "Lookup Purchases", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1" + }, + "pinData": {} +} diff --git a/n8n/gen_w5.js b/n8n/gen_w5.js index d43f7e3..e6576d8 100644 --- a/n8n/gen_w5.js +++ b/n8n/gen_w5.js @@ -1,9 +1,9 @@ // Builds n8n/W5-purchase-check.json — public footer purchase/expiry webhook. -// Query: GET /dk-purchase-check?u=&s= -// dkey = DK_HOST_KEY ($env.DK_HOST_KEY) — a per-instance shared key; the -// signature prevents username enumeration + forged requests from off-domain. +// Trigger: GET /webhook/dk-purchase-check?u= // Response (JSON, CORS: https://drive.digikedai.com): -// { username, known, purchases: [{product, product_id, expires_at, status, display_state}] } +// { username, known, purchases: [{product, expires_at, display_state}] } +// No HMAC (key would ship client-side = theatre). Access control = CORS + +// Cloudflare WAF rate-limit/Origin rule (documented in delivery guide). // Run: node n8n/gen_w5.js const fs = require('fs'); const path = require('path'); @@ -17,8 +17,16 @@ const workflow = { parameters: { httpMethod: 'GET', path: 'dk-purchase-check', - responseMode: 'responseNode', - options: {} + responseMode: 'lastNode', + options: { + responseCode: 200, + responseHeaders: { + entries: [ + { name: 'Access-Control-Allow-Origin', value: 'https://drive.digikedai.com' }, + { name: 'Content-Type', value: 'application/json' } + ] + } + } }, id: 'trigger-webhook', name: 'Footer Purchase Check (Webhook)', @@ -27,51 +35,19 @@ const workflow = { position: [0, 0], webhookId: 'dk-purchase-check' }, - { - parameters: { - jsCode: "// Extract + validate query params; verify HMAC signature.\n// u = username, s = HMAC-SHA256(username, $env.DK_HOST_KEY) hex.\nconst crypto = require('crypto');\n\nfunction sigOf(u) {\n return crypto.createHmac('sha256', $env.DK_HOST_KEY).update(u).digest('hex');\n}\n\nconst q = $input.item.json.query ?? {};\nconst username = String(q.u || '').trim();\nconst supplied = String(q.s || '').toLowerCase();\n\n// username must be non-empty; signature must match (timing-safe-ish)\nconst expected = sigOf(username);\nconst ok = username.length > 0 && supplied.length === 64 &&\n crypto.timingSafeEqual(Buffer.from(supplied), Buffer.from(expected));\n\nif (!ok) {\n return { json: { status: 403, body: { error: 'forbidden', username: '' } } };\n}\n\nreturn { json: { username, _ok: true } };\n" - }, - id: 'extract-validate', - name: 'Extract & Validate Query', - type: 'n8n-nodes-base.code', - typeVersion: 2, - position: [220, 0] - }, - { - parameters: { - jsCode: "// load env (Code node reads $env reliably only here) and pass through\nconst prev = $input.item.json;\nreturn { json: {\n username: prev.username,\n NOCODB_URL: $env.NOCODB_URL,\n NOCODB_TOKEN: $env.NOCODB_TOKEN,\n NOCODB_TABLE_CUSTOMERS: $env.NOCODB_TABLE_CUSTOMERS,\n NOCODB_TABLE_CUSTOMERPRODUCTS: $env.NOCODB_TABLE_CUSTOMERPRODUCTS,\n} };\n" - }, - id: 'load-env', - name: 'Load Env', - type: 'n8n-nodes-base.code', - typeVersion: 2, - position: [440, 0] - }, { parameters: { jsCode: body }, - id: 'compute', + id: 'lookup', name: 'Lookup Purchases', type: 'n8n-nodes-base.code', typeVersion: 2, - position: [660, 0] - }, - { - parameters: { - jsCode: "// Wrap in CORS + always-200 JSON envelope. On error return empty purchases.\nconst d = $input.item.json;\nreturn { json: {\n body: {\n username: d.username ?? '',\n known: d.known ?? false,\n purchases: d.purchases ?? [],\n }\n} };\n" - }, - id: 'respond', - name: 'Respond JSON', - type: 'n8n-nodes-base.code', - typeVersion: 2, - position: [880, 0] + position: [220, 0] } ], connections: { - 'Footer Purchase Check (Webhook)': { main: [[{ node: 'Extract & Validate Query', type: 'main', index: 0 }]] }, - 'Extract & Validate Query': { main: [[{ node: 'Load Env', type: 'main', index: 0 }]] }, - 'Load Env': { main: [[{ node: 'Lookup Purchases', type: 'main', index: 0 }]] }, - 'Lookup Purchases': { main: [[{ node: 'Respond JSON', type: 'main', index: 0 }]] }, - 'Respond JSON': { main: [] } + 'Footer Purchase Check (Webhook)': { + main: [[{ node: 'Lookup Purchases', type: 'main', index: 0 }]] + } }, settings: { executionOrder: 'v1' }, pinData: {} diff --git a/n8n/w5-body.js b/n8n/w5-body.js index 1f6abec..fbf2bd7 100644 --- a/n8n/w5-body.js +++ b/n8n/w5-body.js @@ -1,17 +1,26 @@ // ============================================================================ -// W5 runtime body — footer purchase/expiry check (public, read-only). -// Query: GET /dk-purchase-check?u=&k= -// Returns: [{ product, expires_at, status }] for the customer's products, -// or { error } when username unknown / key wrong. +// 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}] } +// +// 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). +// - Server-side enumeration is mitigated by a Cloudflare WAF rate-limit + +// Origin/Referer rule on auto.hoelee.com (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. // ============================================================================ -// n8n Code node context: ctx = { $env, helpers } +// n8n Code node context (same pattern as W1–W4). const ctx = { $env, helpers: this.helpers }; -// --- input from the "Extract Query" node (already validated the shared key) --- -const username = String($json.username || '').trim(); +// GET webhook: n8n puts query-string params under $json.query (fallback: body). +const item = $json ?? ($input && $input.item ? $input.item.json : {}); +const q = item.query ?? item.body ?? {}; +const username = String(q.u ?? '').trim(); -// --- helpers (self-contained; no shared lib needed for pure reads) --- function linkId(v) { if (v == null) return null; if (typeof v === 'object' && !Array.isArray(v)) return v.Id ?? v.id ?? null; @@ -28,53 +37,44 @@ async function ncGet(path) { }); } -const T_CUST = ctx.$env.NOCODB_TABLE_CUSTOMERS; -const T_CP = ctx.$env.NOCODB_TABLE_CUSTOMERPRODUCTS; +const empty = () => ({ username, known: false, purchases: [] }); -// 1. resolve username -> customer id (username is unique in Customers) -const custRows = (await ncGet( - `/api/v2/tables/${T_CUST}/records?where=(username,eq,${encodeURIComponent(username)})&limit=1` -)).list || []; -const customer = custRows[0]; +if (!username) return { json: empty() }; -if (!customer) { - return { json: { username, purchases: [], known: false } }; +try { + const T_CUST = ctx.$env.NOCODB_TABLE_CUSTOMERS; + const T_CP = ctx.$env.NOCODB_TABLE_CUSTOMERPRODUCTS; + const today = new Date().toISOString().slice(0, 10); + + // 1. resolve username -> customer (username is unique in Customers) + const custRows = (await ncGet( + `/api/v2/tables/${T_CUST}/records?where=(username,eq,${encodeURIComponent(username)})&limit=1` + )).list || []; + const customer = custRows[0]; + if (!customer) return { json: empty() }; + + // 2. this customer's products (link query by Id, per NocoDB v2 gotchas) + const cps = (await ncGet( + `/api/v2/tables/${T_CP}/records?where=(customer.Id,eq,${customer.Id})&limit=500` + )).list || []; + + 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 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 }; + }) + .sort((a, b) => { + const rank = { active: 0, suspended: 1, revoked: 2, expired: 3 }; + if (rank[a.display_state] !== rank[b.display_state]) return rank[a.display_state] - rank[b.display_state]; + return String(a.expires_at || '').localeCompare(String(b.expires_at || '')); + }); + + return { json: { username, known: true, purchases } }; +} catch (err) { + // graceful: footer renders static rows only on any error + return { json: { ...empty(), error: String((err && err.message) || err) } }; } - -// 2. fetch this customer's products (active + not-yet-expired) OR all for display -const cps = (await ncGet( - `/api/v2/tables/${T_CP}/records?where=(customer.Id,eq,${customer.Id})&limit=1000` -)).list || []; - -const today = new Date().toISOString().slice(0, 10); -const purchases = cps - .map(c => { - const productObj = (typeof c.product === 'object' && c.product) ? c.product : null; - const name = productObj?.name ?? 'Product'; - const expires = c.expires_at ? String(c.expires_at).slice(0, 10) : null; - let state = 'active'; - if (c.status === 'active' && expires && expires <= today) state = 'expired'; - else if (c.status && c.status !== 'active') state = c.status; - - return { - product: name, - product_id: linkId(c.product), - expires_at: expires, - status: c.status || null, - display_state: state, // active / expired / revoked / suspended - }; - }) - // sort: active (expiring soonest first), then expired (most recent expiry first) - .sort((a, b) => { - const rank = { active: 0, suspended: 1, revoked: 2, expired: 3 }; - if (rank[a.display_state] !== rank[b.display_state]) return rank[a.display_state] - rank[b.display_state]; - return String(a.expires_at || '').localeCompare(String(b.expires_at || '')); - }); - -return { - json: { - username, - known: true, - purchases, - }, -};