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
+55 -55
View File
@@ -1,17 +1,26 @@
// ============================================================================
// W5 runtime body — footer purchase/expiry check (public, read-only).
// Query: GET /dk-purchase-check?u=<username>&k=<shared-key>
// 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=<username>
// 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,
},
};