feat(n8n): W5 footer purchase-check webhook (公开只读) + docs

- 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 + 导入步骤
This commit is contained in:
2026-08-28 18:24:42 +08:00
parent 7dd14f9f25
commit 414bf31ba0
5 changed files with 166 additions and 101 deletions
+66
View File
@@ -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=<username>\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": {}
}