73 lines
4.1 KiB
JavaScript
73 lines
4.1 KiB
JavaScript
'use strict';
|
|
// Administrative reads only. Detection/authentication continue to write solely
|
|
// to the process's own DATA_ROOT. Source paths never come from an HTTP request.
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
|
|
module.exports = function ({ localRoot, localChannel = 'h5', taobaoRoot, publicRoot }) {
|
|
const sources = new Map([['local', { id: 'local', root: path.resolve(localRoot), channel: localChannel, readonly: false }]]);
|
|
const inside = (file, root) => file === root || file.startsWith(root + path.sep);
|
|
if (taobaoRoot) {
|
|
if (!path.isAbsolute(taobaoRoot)) throw Error('JUZI_ADMIN_TAOBAO_DATA_ROOT must be absolute');
|
|
const root = fs.realpathSync(taobaoRoot);
|
|
const primary = fs.realpathSync(localRoot);
|
|
if (inside(root, primary) || inside(primary, root)) throw Error('Administrative data sources must be separate directories');
|
|
if (publicRoot && inside(root, fs.realpathSync(publicRoot))) throw Error('Administrative external data must be outside the public application directory');
|
|
sources.set('taobao', { id: 'taobao', root, channel: 'taobao', readonly: true });
|
|
}
|
|
function source(id = 'local') { return sources.get(id) || null; }
|
|
function records(id) {
|
|
const selected = id ? [source(id)].filter(Boolean) : [...sources.values()];
|
|
return selected.flatMap(s => {
|
|
let text;
|
|
const target = file(s.id, 'records.jsonl');
|
|
if (!target) return [];
|
|
try { text = fs.readFileSync(target, 'utf8'); }
|
|
catch (e) { if (e.code === 'ENOENT') return []; throw e; }
|
|
return text.split('\n').flatMap(line => {
|
|
let rec; try { rec = JSON.parse(line); } catch (_) { return []; }
|
|
if (!rec || typeof rec.record_id !== 'string' || !/^[\w-]+$/.test(rec.record_id)) return [];
|
|
return [{ ...rec, channel: rec.channel || s.channel, admin_source: s.id,
|
|
admin_key: s.id + ':' + rec.record_id, admin_readonly: s.readonly }];
|
|
});
|
|
});
|
|
}
|
|
function find(id, sourceId = 'local') { return records(sourceId).find(r => r.record_id === id) || null; }
|
|
function file(sourceId, ...parts) {
|
|
const s = source(sourceId);
|
|
if (!s || parts.some(p => typeof p !== 'string' || !p || p !== path.basename(p) || p === '.' || p === '..')) return null;
|
|
const target = path.resolve(s.root, ...parts);
|
|
if (!inside(target, s.root)) return null;
|
|
try { return inside(fs.realpathSync(target), fs.realpathSync(s.root)) ? target : null; }
|
|
catch (e) { if (e.code === 'ENOENT') return null; throw e; }
|
|
}
|
|
function presentation(rec) {
|
|
const target = file(rec.admin_source, 'accounts', 'reports', rec.owner_id, rec.record_id + '.json');
|
|
if (!target) return null;
|
|
try { return JSON.parse(fs.readFileSync(target, 'utf8')).presentation || null; }
|
|
catch (_) { return null; }
|
|
}
|
|
function funnel(now, channel, localFunnel) {
|
|
const stats = [...sources.values()].filter(s => !channel || s.channel === channel).map(s => {
|
|
if (s.id === 'local') return localFunnel.stats(now);
|
|
return require('./capture-funnel')(path.join(s.root, 'capture-funnel.jsonl')).stats(now);
|
|
});
|
|
function sum(scope) {
|
|
const rows = stats.map(st => st[scope]);
|
|
const counts = {};
|
|
for (const st of rows) for (const [stage, n] of Object.entries(st.counts)) counts[stage] = (counts[stage] || 0) + n;
|
|
const reports = counts.report || 0;
|
|
return { counts, rate: counts.started ? Math.round(reports / counts.started * 1000) / 10 : 0,
|
|
response_without_report: rows.reduce((n, r) => n + r.response_without_report, 0),
|
|
unfinished: rows.reduce((n, r) => n + r.unfinished, 0),
|
|
avg_ms: reports ? Math.round(rows.reduce((n, r) => n + (r.avg_ms || 0) * r.counts.report, 0) / reports) : null };
|
|
}
|
|
// Keep the event schema even when a selected channel has no telemetry yet.
|
|
if (!stats.length) return null;
|
|
const starts = stats.map(st => st.since).filter(Number.isFinite);
|
|
return { since: starts.length ? Math.min(...starts) : null, total: sum('total'), today: sum('today') };
|
|
}
|
|
return { source, records, find, file, presentation, funnel,
|
|
descriptors: () => [...sources.values()].map(({ id, channel, readonly }) => ({ id, channel, readonly })) };
|
|
};
|