feat: 完善淘宝小程序拍照、报告与登录流程
接入本地实时识别和自动三连拍,调整报告裁切、商品跳转、分享标题及居中页眉;完善游客会话、后台渠道隔离、部署脚本与回归测试。
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
'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 })) };
|
||||
};
|
||||
Reference in New Issue
Block a user