Files
alist-serve-resource/n8n/W5-purchase-check.json
T
hoelee 414bf31ba0 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 + 导入步骤
2026-08-28 18:24:42 +08:00

67 lines
4.9 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"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": {}
}