Files
juzi/h5/lib/admin-sources.js
T
xsl 774376fa16 feat: 完善淘宝小程序拍照、报告与登录流程
接入本地实时识别和自动三连拍,调整报告裁切、商品跳转、分享标题及居中页眉;完善游客会话、后台渠道隔离、部署脚本与回归测试。
2026-11-04 15:07:51 +08:00

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 })) };
};