feat: 完善淘宝小程序拍照、报告与登录流程

接入本地实时识别和自动三连拍,调整报告裁切、商品跳转、分享标题及居中页眉;完善游客会话、后台渠道隔离、部署脚本与回归测试。
This commit is contained in:
xsl
2026-11-04 15:07:51 +08:00
parent 381da60ae4
commit 774376fa16
147 changed files with 17420 additions and 597 deletions
+10 -3
View File
@@ -17,6 +17,12 @@
</header>
<main>
<div class="filters channel-filter">
<label>渠道
<select id="f-channel"><option value="">全部渠道</option><option value="h5">H5</option><option value="taobao">淘宝小程序</option></select>
</label>
<span class="hint" id="channel-summary">正在读取渠道数据…</span>
</div>
<!-- ─── 记录列表(文档 4.1/4.2) ─── -->
<section id="tab-records" class="panel on">
<div class="filters">
@@ -71,7 +77,7 @@
<table id="tbl-records">
<thead>
<tr>
<th>照片</th><th>记录ID</th><th>年龄/性别/地区/肤质</th><th>拍摄状态</th><th>上传状态</th>
<th>照片</th><th>记录ID / 来源</th><th>年龄/性别/地区/肤质</th><th>拍摄状态</th><th>上传状态</th>
<th>检测状态</th><th title="紧塑力×4+透亮感×3+稳韧力×3,四舍五入取整数">综合得分</th><th>肌龄(原始 / 展示)</th><th>三维度评分 / 档位</th><th>推荐精华</th><th>耗时</th><th>创建时间</th>
</tr>
</thead>
@@ -92,7 +98,7 @@
<table id="tbl-fails">
<thead>
<tr>
<th>记录ID</th><th>失败环节</th><th>失败码</th><th>失败信息</th><th>年龄</th><th>拍摄状态</th><th>创建时间</th><th>操作</th>
<th>记录ID / 来源</th><th>失败环节</th><th>失败码</th><th>失败信息</th><th>年龄</th><th>拍摄状态</th><th>创建时间</th><th>操作</th>
</tr>
</thead>
<tbody></tbody>
@@ -103,6 +109,7 @@
<!-- ─── 统计概览(文档 4.5) ─── -->
<section id="tab-stats" class="panel">
<div class="cards" id="stat-cards"></div>
<div class="block"><h3>全部渠道检测概览</h3><div id="channel-stats"></div><p class="hint">此表展示全部渠道对照,上方指标按当前渠道筛选。按检测记录统计,不是跨渠道去重人数;游客临时报告不进入已保存检测记录统计。</p></div>
<div class="block">
<h3>拍照 → 报告页转化(上线后新流程)</h3>
<div id="capture-funnel"></div>
@@ -128,7 +135,7 @@
<label>起始日期 <input type="date" id="exp-from"></label>
<label>结束日期 <input type="date" id="exp-to"></label>
<button type="button" class="btn primary" id="btn-export">导出 CSV</button>
<span class="hint">日期及导出时间均为北京时间;UTF-8 BOM,Excel 直接打开;不含接口原始 JSON</span>
<span class="hint">沿用当前渠道及记录筛选条件;导出日期可另选。北京时间,UTF-8 BOM,不含接口原始 JSON。</span>
</div>
</div>
</section>
+64 -22
View File
@@ -49,6 +49,27 @@ function api(url, opts) {
});
});
}
function channelName(channel) { return ({ h5: 'H5', taobao: '淘宝小程序', rednote: '小红书' })[channel] || channel || 'H5'; }
function selectedChannelQuery(url) {
var channel = $('#f-channel').value;
return channel ? url + (url.indexOf('?') < 0 ? '?' : '&') + 'channel=' + encodeURIComponent(channel) : url;
}
function sourceQuery(source) { return '?source=' + encodeURIComponent(source || 'local'); }
function renderSources(data) {
var sources = data.sources || [];
$('#channel-summary').textContent = sources.length > 1
? '已汇总 H5 与淘宝数据;外部渠道记录只读,重试请进入原服务后台。'
: '当前读取本服务的数据;尚未接入其他服务的数据源。';
}
function renderChannelStats(data) {
renderSources(data);
var rows = data.by_channel || {}, html = '<table><thead><tr><th>渠道</th><th>累计检测</th><th>今日检测</th><th>成功</th><th>失败</th></tr></thead><tbody>';
Object.keys(rows).forEach(function (key) {
var row = rows[key];
html += '<tr><td>' + esc(channelName(key)) + '</td><td>' + row.total + '</td><td>' + row.today_total + '</td><td>' + row.success_total + '</td><td>' + row.fail_total + '</td></tr>';
});
$('#channel-stats').innerHTML = html + '</tbody></table>';
}
/* ─── 页签切换 ─── */
var TABS = { records: 1, fails: 1, stats: 1 };
@@ -116,6 +137,7 @@ function collectFilters() {
add('from', $('#f-from').value);
add('to', $('#f-to').value);
add('detect_status', $('#f-status').value);
add('channel', $('#f-channel').value);
add('fail_code', $('#f-failcode').value);
add('age_min', $('#f-agemin').value);
add('age_max', $('#f-agemax').value);
@@ -130,6 +152,7 @@ function collectFilters() {
function loadRecords() {
api('/api/admin/records?' + collectFilters()).then(function (data) {
renderSources(data);
pageState.total = data.total;
var tbody = $('#tbl-records tbody');
tbody.innerHTML = '';
@@ -145,7 +168,7 @@ function loadRecords() {
: '<span class="sub">无</span>';
tr.innerHTML =
'<td>' + thumb + '</td>' +
'<td class="mono">' + esc(it.record_id) + (it.retry_of ? '<div class="sub">重试自 ' + esc(it.retry_of) + '</div>' : '') + '</td>' +
'<td class="mono">' + esc(it.record_id) + '<div class="sub">' + esc(channelName(it.channel)) + (it.readonly ? ' · 只读' : '') + '</div>' + (it.retry_of ? '<div class="sub">重试自 ' + esc(it.retry_of) + '</div>' : '') + '</td>' +
'<td>' + profileHtml(it) + '</td>' +
'<td>' + esc(it.shoot_status) + captureQualityHtml(it) + '</td>' +
'<td>' + esc(it.upload_status) + '</td>' +
@@ -159,7 +182,7 @@ function loadRecords() {
'<td class="mono">' + fmtTime(it.created_at) + '</td>';
tr.addEventListener('click', function (e) {
if (e.target && e.target.className === 'thumb') { showLightbox(e.target.src); return; }
openDetail(it.record_id);
openDetail(it.record_id, it.source);
});
tbody.appendChild(tr);
})(data.items[i]);
@@ -175,6 +198,7 @@ function loadRecords() {
function renderCaptureFunnel(st) {
var funnel = st.capture_funnel;
var panel = $('#capture-funnel');
if (panel) panel.parentNode.hidden = !funnel;
if (funnel && panel) {
var labels = [['started','开始拍照'],['front','正面拍完'],['right','右脸拍完'],['left','三张拍完'],['submitted','提交检测'],['response','收到成功结果'],['report','进入报告页']];
var html = '<table><thead><tr><th>阶段</th><th>累计流程</th><th>今日流程</th></tr></thead><tbody>';
@@ -190,7 +214,8 @@ function renderCaptureFunnel(st) {
}
}
function loadFails() {
api('/api/admin/stats').then(function (st) {
api(selectedChannelQuery('/api/admin/stats')).then(function (st) {
renderSources(st);
var box = $('#fail-dist');
box.innerHTML = '';
if (!st.fail_dist.length) box.appendChild(el('div', 'pie-empty', '暂无失败记录'));
@@ -205,7 +230,7 @@ function loadFails() {
}
}).catch(function (e) { toast('加载失败:' + e.message); });
api('/api/admin/records?detect_status=失败&page_size=100').then(function (data) {
api(selectedChannelQuery('/api/admin/records?detect_status=失败&page_size=100')).then(function (data) {
var tbody = $('#tbl-fails tbody');
tbody.innerHTML = '';
if (!data.items.length) {
@@ -215,17 +240,17 @@ function loadFails() {
(function (it) {
var tr = el('tr');
tr.innerHTML =
'<td class="mono">' + esc(it.record_id) + '</td>' +
'<td class="mono">' + esc(it.record_id) + '<div class="sub">' + esc(channelName(it.channel)) + '</div></td>' +
'<td>' + esc(it.fail_stage || '-') + '</td>' +
'<td><span class="st st-fail">' + esc(it.fail_code || '-') + '</span></td>' +
'<td style="white-space:normal;max-width:340px">' + esc(it.fail_msg || '-') + '</td>' +
'<td>' + (it.real_age != null ? it.real_age + '岁' : '-') + '</td>' +
'<td>' + esc(it.shoot_status) + '</td>' +
'<td class="mono">' + fmtTime(it.created_at) + '</td>' +
'<td><button type="button" class="btn small primary">重试</button></td>';
'<td>' + (it.readonly ? '<span class="sub">只读,请到原服务后台重试</span>' : '<button type="button" class="btn small primary">重试</button>') + '</td>';
tr.addEventListener('click', function (e) {
if (e.target && e.target.tagName === 'BUTTON') { doRetry(it.record_id); return; }
openDetail(it.record_id);
if (e.target && e.target.tagName === 'BUTTON') { doRetry(it.record_id, it.source); return; }
openDetail(it.record_id, it.source);
});
tbody.appendChild(tr);
})(data.items[i]);
@@ -233,10 +258,10 @@ function loadFails() {
}).catch(function (e) { toast('加载失败:' + e.message); });
}
function doRetry(id) {
function doRetry(id, source) {
if (!confirm('将使用原照片重新检测并生成新记录,确认重试 ' + id + ' ?')) return;
toast('重试中,请稍候(最长约 1 分钟)…');
api('/api/admin/records/' + encodeURIComponent(id) + '/retry', { method: 'POST' }).then(function (data) {
api('/api/admin/records/' + encodeURIComponent(id) + '/retry' + sourceQuery(source), { method: 'POST' }).then(function (data) {
toast('重试完成,新记录 ' + data.record.record_id + '(' + data.record.detect_status + ')');
var active = $('.panel.on').id;
if (active === 'tab-records') loadRecords();
@@ -247,7 +272,8 @@ function doRetry(id) {
/* ═══════════ 统计概览 ═══════════ */
var PIE_COLORS = ['#2563eb', '#2fa84f', '#f0a020', '#8a5cf6', '#d93026'];
function loadStats() {
api('/api/admin/stats').then(function (st) {
api(selectedChannelQuery('/api/admin/stats')).then(function (st) {
renderChannelStats(st);
renderCaptureFunnel(st);
var cards = [
['今日检测', st.today_total],
@@ -332,18 +358,23 @@ function loadStats() {
/* ═══════════ 记录详情(抽屉) ═══════════ */
var currentDetailId = null;
var currentDetailSource = 'local';
var detailSeq = 0;
function openDetail(id) {
function openDetail(id, source) {
var seq = ++detailSeq;
currentDetailId = id;
currentDetailSource = source || 'local';
$('#d-title').textContent = '记录详情 ' + id;
$('#btn-retry').disabled = true;
$('#d-body').innerHTML = '<div class="sub">加载中…</div>';
$('#drawer').classList.remove('hidden');
$('#drawer-mask').classList.remove('hidden');
api('/api/admin/records/' + encodeURIComponent(id)).then(renderDetail)
.catch(function (e) { $('#d-body').innerHTML = '<div class="sub">加载失败:' + esc(e.message) + '</div>'; });
api('/api/admin/records/' + encodeURIComponent(id) + sourceQuery(currentDetailSource)).then(function (data) { if (seq === detailSeq) renderDetail(data); })
.catch(function (e) { if (seq === detailSeq) $('#d-body').innerHTML = '<div class="sub">加载失败:' + esc(e.message) + '</div>'; });
}
function closeDetail() {
detailSeq++;
$('#drawer').classList.add('hidden');
$('#drawer-mask').classList.add('hidden');
currentDetailId = null;
@@ -355,7 +386,8 @@ function dSec(title, innerHtml) {
function renderDetail(data) {
var r = data.record;
$('#btn-retry').disabled = !(data.photos && data.photos.front);
$('#btn-retry').disabled = data.readonly || !(data.photos && data.photos.front);
$('#btn-retry').title = data.readonly ? '只读汇总记录,请在原服务后台重试' : '';
var h = '';
/* 三张照片 */
@@ -375,6 +407,7 @@ function renderDetail(data) {
/* 状态行 */
h += dSec('状态', '<div class="d-kv">' +
'<span>渠道 <b>' + esc(channelName(r.channel)) + (data.readonly ? '(只读汇总)' : '') + '</b></span>' +
'<span>检测状态 <b>' + esc(r.detect_status) + '</b></span>' +
'<span>拍摄状态 <b>' + esc(r.shoot_status) + '</b></span>' +
'<span>上传状态 <b>' + esc(r.upload_status) + '</b></span>' +
@@ -553,7 +586,7 @@ function renderDetail(data) {
if (data.raw_json) {
var rawFile = data.raw_file || (window.JuziEntry && window.JuziEntry.mode === 'rednote' ? r.raw_file : null);
var rawDl = rawFile
? ' <a href="' + adminURL('/api/admin/records/' + encodeURIComponent(r.record_id) + '/raw') + '" download>下载完整原始 JSON</a>'
? ' <a href="' + adminURL('/api/admin/records/' + encodeURIComponent(r.record_id) + '/raw' + sourceQuery(data.source)) + '" download>下载完整原始 JSON</a>'
: '';
h += dSec('接口原始 JSON', '<details><summary>展开/收起(' + (rawFile || '') + ')' + rawDl + '</summary><pre>' +
esc(JSON.stringify(data.raw_json, null, 1)) + '</pre></details>');
@@ -572,7 +605,7 @@ function renderDetail(data) {
/* 版本链跳转 */
var links = body.querySelectorAll('.d-versions a');
for (var v = 0; v < links.length; v++) {
links[v].addEventListener('click', function () { openDetail(this.getAttribute('data-id')); });
links[v].addEventListener('click', function () { openDetail(this.getAttribute('data-id'), data.source); });
}
}
@@ -584,6 +617,14 @@ function showLightbox(src) {
/* ═══════════ 事件绑定与初始化 ═══════════ */
function bind() {
if (window.JuziEntry && window.JuziEntry.mode === 'rednote') {
$('#f-channel').innerHTML = '<option value="">全部渠道</option><option value="rednote">小红书</option>';
}
$('#f-channel').addEventListener('change', function () {
pageState.page = 1;
var active = $('.panel.on').id.replace('tab-', '');
switchTab(active);
});
var tabs = document.querySelectorAll('.tab');
for (var i = 0; i < tabs.length; i++) {
tabs[i].addEventListener('click', function () { switchTab(this.dataset.tab); });
@@ -599,13 +640,14 @@ function bind() {
$('#btn-next').addEventListener('click', function () { pageState.page++; loadRecords(); });
$('#btn-close').addEventListener('click', closeDetail);
$('#drawer-mask').addEventListener('click', closeDetail);
$('#btn-retry').addEventListener('click', function () { if (currentDetailId) doRetry(currentDetailId); });
$('#btn-retry').addEventListener('click', function () { if (currentDetailId) doRetry(currentDetailId, currentDetailSource); });
$('#lightbox').addEventListener('click', function () { this.classList.add('hidden'); });
$('#btn-export').addEventListener('click', function () {
var p = [];
if ($('#exp-from').value) p.push('from=' + $('#exp-from').value);
if ($('#exp-to').value) p.push('to=' + $('#exp-to').value);
var url = adminURL('/api/admin/export' + (p.length ? '?' + p.join('&') : ''));
var p = new URLSearchParams(collectFilters());
p.delete('page'); p.delete('page_size');
if ($('#exp-from').value) p.set('from', $('#exp-from').value);
if ($('#exp-to').value) p.set('to', $('#exp-to').value);
var url = adminURL('/api/admin/export?' + p.toString());
var a = el('a');
a.href = url;
a.download = '';
+16 -1
View File
@@ -33,6 +33,7 @@ module.exports = function createAccounts(root, env = process.env) {
if (base) { base.pathname = prefix; base.search = ''; base.hash = ''; }
const secure = !!(base && base.protocol === 'https:');
const guestSessions = new Map(); // No guest identities/sessions are written to disk.
const taobaoGuests = new Map(); // Separate, temporary Bearer sessions; never written to db.
const appId = env.WECHAT_APP_ID || '';
const secret = env.WECHAT_APP_SECRET || '';
const available = !rednote && !!(appId && secret && secure && !/^(localhost|127\.|192\.168\.|10\.|172\.(1[6-9]|2\d|3[01])\.)/.test(base.hostname));
@@ -226,13 +227,27 @@ module.exports = function createAccounts(root, env = process.env) {
save();
return { token, expires_in: 30 * 86400, user: publicUser(user) };
}
function loginTaobaoGuest() {
for (const [key, value] of taobaoGuests) if (value.expires <= Date.now()) taobaoGuests.delete(key);
if (taobaoGuests.size >= 1000) return null;
const token = random(), user = { id: crypto.randomUUID(), kind: 'taobao_guest' };
taobaoGuests.set(hash(token), { user, expires: Date.now() + 8 * 3600000 });
return { token, expires_in: 8 * 3600, user: publicUser(user) };
}
function taobaoSession(req) {
const match = /^Bearer ([a-f0-9]{64})$/.exec(req.headers.authorization || '');
const key = match && hash(match[1]), guest = key && taobaoGuests.get(key);
if (guest) {
if (guest.expires > Date.now()) return guest.user;
taobaoGuests.delete(key);
return null;
}
const row = match && db.sessions[hash(match[1])];
return row && row.expires > Date.now() ? db.users.find(u => u.id === row.user && u.kind === 'taobao') : null;
}
function taobaoLogout(req) {
const match = /^Bearer ([a-f0-9]{64})$/.exec(req.headers.authorization || '');
if (match && taobaoGuests.delete(hash(match[1]))) return;
if (match && taobaoSession(req)) { delete db.sessions[hash(match[1])]; save(); }
}
function reports(owner) {
@@ -247,5 +262,5 @@ module.exports = function createAccounts(root, env = process.env) {
return snapshot;
}
return { route, session, storeReport, reportPresentation, reportSnapshot, updateWrinkleViews, sameOrigin, wechatAvailable: available,
loginTaobao, taobaoSession, taobaoLogout, reports, ownedReport };
loginTaobao, loginTaobaoGuest, taobaoSession, taobaoLogout, reports, ownedReport };
};
+72
View File
@@ -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 })) };
};
+12 -5
View File
@@ -18,19 +18,26 @@ function signature(params, body, secret) {
}
return crypto.createHash('md5').update(secret + content + secret).digest('hex').toUpperCase();
}
function verify(url, body, env = process.env) {
// Guest requests still require the real signed app gateway, without userInfo
// authorization or an open_id. Body fields never establish an identity.
function verifyGateway(url, body, env = process.env) {
if (!env.TAOBAO_APP_KEY || !env.TAOBAO_APP_SECRET) return null;
const params = Object.create(null);
for (const [key, value] of new URL(url, 'http://localhost').searchParams) {
if (key in params) return null; // Never ambiguously verify duplicate parameters.
params[key] = value;
}
if (params.app_key !== env.TAOBAO_APP_KEY || !/^[A-Za-z0-9_-]{4,256}$/.test(params.open_id || '') ||
if (params.app_key !== env.TAOBAO_APP_KEY ||
!/^[A-Fa-f0-9]{32,64}$/.test(params.sign || '')) return null;
const expected = signature(params, body, env.TAOBAO_APP_SECRET);
if (!expected || expected.length !== params.sign.length ||
!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(params.sign.toUpperCase()))) return null;
// Login challenge is single-use and signed inside the body, preventing replay.
return { providerKey: crypto.createHash('sha256').update(params.app_key + ':' + params.open_id).digest('hex') };
return { appKey: params.app_key, openId: params.open_id };
}
module.exports = { signature, verify };
function verify(url, body, env = process.env) {
const gateway = verifyGateway(url, body, env);
if (!gateway || !/^[A-Za-z0-9_-]{4,256}$/.test(gateway.openId || '')) return null;
// Login challenge is single-use and signed inside the body, preventing replay.
return { providerKey: crypto.createHash('sha256').update(gateway.appKey + ':' + gateway.openId).digest('hex') };
}
module.exports = { signature, verify, verifyGateway };
+26 -9
View File
@@ -11,6 +11,7 @@ module.exports = function ({ accounts, wrinkleJobs, readBody, sendJson, handleAn
try {
const raw = (await readBody(req, pathname === '/api/taobao/photo' ? 70 * 1024 : 4096)).toString('utf8');
const body = JSON.parse(raw);
if (!body || typeof body !== 'object' || Array.isArray(body)) return reply(400, { ok: false, message: '请求数据异常' });
if (pathname === '/api/taobao/challenge') {
const challenge = taobaoSessions.challenge(req.socket.remoteAddress);
return reply(challenge ? 200 : 429, challenge ? { ok: true, challenge } : { ok: false, message: '操作频繁,请稍后重试' });
@@ -21,29 +22,45 @@ module.exports = function ({ accounts, wrinkleJobs, readBody, sendJson, handleAn
if (!identity || !taobaoSessions.consume(body.challenge)) return reply(401, { ok: false, message: '淘宝身份校验失败,请重新授权登录' });
return reply(200, { ok: true, ...accounts.loginTaobao(identity.providerKey) });
}
if (pathname === '/api/taobao/guest-session') {
if (body.consent !== true) return reply(400, { ok: false, message: '请先阅读并同意隐私协议' });
if (!TaobaoAuth.verifyGateway(req.url, raw) || !taobaoSessions.consume(body.challenge)) {
return reply(401, { ok: false, message: '游客会话校验失败,请重新开始' });
}
const guest = accounts.loginTaobaoGuest();
return reply(guest ? 200 : 429, guest ? { ok: true, ...guest } : { ok: false, message: '操作频繁,请稍后再试' });
}
const owner = accounts.taobaoSession(req);
if (!owner) return reply(401, { ok: false, message: '检测会话已失效,请重新登录' });
if (pathname === '/api/taobao/me') return reply(200, { ok: true, user: { id: owner.id, kind: owner.kind } });
if (!owner) return reply(401, { ok: false, message: '检测会话已失效,请重新开始' });
if (pathname === '/api/taobao/me') {
// A cached Bearer must not restore the previous Taobao account after
// the host app switches accounts. Guests require a signed app gateway
// but intentionally have no dependency on userInfo/open_id authorization.
const identity = owner.kind === 'taobao_guest' ? null : TaobaoAuth.verify(req.url, raw);
const matches = owner.kind === 'taobao_guest' ? !!TaobaoAuth.verifyGateway(req.url, raw)
: !!identity && identity.providerKey === owner.provider_key;
return reply(matches ? 200 : 401, matches ? { ok: true, user: { id: owner.id, kind: owner.kind } }
: { ok: false, message: '淘宝账号状态已变化,请重新进入' });
}
if (pathname === '/api/taobao/photo') {
const { status, ...data } = taobaoSessions.upload(req, body);
return reply(status, { ok: status === 200, ...data });
}
if (pathname === '/api/taobao/skin-analyze') {
const photos = taobaoSessions.takePhotos(req, body.photo_refs);
if (!photos) return reply(409, { ok: false, message: '照片上传未完成或检测进行中,请重试' });
// Start once and poll: cloud gateway requests have a maximum 10s timeout.
const job = taobaoSessions.start(req, () => new Promise(resolve => {
const attempt = taobaoSessions.beginAnalysis(req, body, photos => new Promise(resolve => {
const response = { setHeader() {}, writeHead() {}, end(text, cb) { resolve(JSON.parse(text)); if (cb) cb(); } };
handleAnalyze(req, response, true, { ...body, ...photos }, owner).catch(() => resolve({ ok: false, message: '检测服务暂不可用,请重试' }));
}), true);
if (!job) { taobaoSessions.finish(req); return reply(429, { ok: false, message: '服务繁忙,请稍后再试' }); }
return reply(202, { ok: true, job });
}));
return reply(attempt.status, { ok: attempt.status === 202,
...(attempt.job ? { job: attempt.job } : { message: attempt.message }) });
}
if (pathname === '/api/taobao/analysis') {
const result = taobaoSessions.result(req, body.job);
return reply(result ? 200 : 404, result ? { ok: true, ...result } : { ok: false, message: '检测任务已失效,请查看历史报告或重新检测' });
return reply(result ? 200 : 404, result ? { ok: true, ...result } : { ok: false, message: owner.kind === 'taobao_guest' ? '本次检测任务已失效,请重新开始' : '检测任务已失效,请查看历史报告或重新检测' });
}
if (pathname === '/api/taobao/reports') {
if (owner.kind === 'taobao_guest') return reply(body.id ? 404 : 200, body.id ? { ok: false, message: '游客模式不保存历史报告' } : { ok: true, total: 0, items: [] });
if (!body.id) return reply(200, { ok: true, total: accounts.reports(owner.id).length, items: accounts.reports(owner.id) });
const report = accounts.ownedReport(owner.id, body.id);
return reply(report ? 200 : 404, report ? { ok: true, report } : { ok: false, message: '报告不存在' });
+73 -13
View File
@@ -3,8 +3,11 @@ const crypto = require('node:crypto');
// Short-lived transfers/jobs/challenges. Identity/history live in accounts.js.
module.exports = function ({ session, now = Date.now } = {}) {
const transfers = new Map(), challenges = new Map(), rates = new Map(), jobs = new Map();
const transfers = new Map(), challenges = new Map(), rates = new Map(), jobs = new Map(), submissions = new Map();
const lifetime = 5 * 60000, maxChunk = 64 * 1024, maxPhoto = 4 * 1024 * 1024;
// Keep only small idempotency tombstones after result expiry. Never silently
// charge again when a client retries an old request_id in the same session.
const submissionLifetime = 30 * 86400000;
let bytes = 0;
function clearPhotos(row) {
for (const photo of Object.values(row.photos)) bytes -= photo.bytes;
@@ -15,6 +18,12 @@ module.exports = function ({ session, now = Date.now } = {}) {
for (const [id, until] of challenges) if (until <= now()) challenges.delete(id);
for (const [id, row] of rates) if (row.until <= now()) rates.delete(id);
for (const [id, job] of jobs) if (job.expires <= now() && job.status !== 'running') jobs.delete(id);
for (const [id, value] of submissions) if (value.expires <= now()) submissions.delete(id);
}
function scope(req, owner) {
const authorization = req.headers && req.headers.authorization;
return typeof authorization === 'string' && authorization
? crypto.createHash('sha256').update(authorization).digest('hex') : owner.id;
}
function challenge(address) {
sweep();
@@ -35,8 +44,9 @@ module.exports = function ({ session, now = Date.now } = {}) {
sweep();
const owner = session(req);
if (!owner) return null;
if (!transfers.has(owner.id) && create && transfers.size < 1000) transfers.set(owner.id, { photos: {}, active: false, expires: now() + lifetime });
return transfers.get(owner.id) || null;
const key = scope(req, owner);
if (!transfers.has(key) && create && transfers.size < 1000) transfers.set(key, { photos: {}, active: false, expires: now() + lifetime });
return transfers.get(key) || null;
}
function upload(req, body) {
const current = row(req, true);
@@ -64,35 +74,85 @@ module.exports = function ({ session, now = Date.now } = {}) {
current.expires = now() + lifetime;
return { status: 200, complete: photo.chunks.length === total, photo_ref: id };
}
function takePhotos(req, refs) {
const current = row(req);
function availablePhotos(current, refs) {
if (!current || current.active) return null;
if (!refs || !['front', 'left', 'right'].every(view => {
const photo = current.photos[view];
return photo && photo.id === refs[view] && photo.chunks.length === photo.total;
})) return null;
const photos = Object.fromEntries(['front', 'left', 'right'].map(view => [view + '_image', current.photos[view].chunks.join('')]));
return Object.fromEntries(['front', 'left', 'right'].map(view => [view + '_image', current.photos[view].chunks.join('')]));
}
function takePhotos(req, refs) {
const current = row(req), photos = availablePhotos(current, refs);
if (!photos) return null;
clearPhotos(current); current.active = true;
return photos;
}
function finish(req, ownerId) { const current = ownerId ? transfers.get(ownerId) : row(req); if (current) { current.active = false; current.expires = now() + lifetime; } }
function revoke(req) { const current = row(req); if (current) { clearPhotos(current); current.expires = 0; sweep(); } }
function start(req, run, releasePhotos = false) {
function finish(req, key) { const current = key ? transfers.get(key) : row(req); if (current) { current.active = false; current.expires = now() + lifetime; } }
function revoke(req) {
const owner = session(req);
if (!owner) return;
const key = scope(req, owner), current = transfers.get(key);
if (current) { clearPhotos(current); transfers.delete(key); }
for (const [id, job] of jobs) if (job.scope === key) { job.result = null; jobs.delete(id); }
for (const [id, value] of submissions) if (value.scope === key) submissions.delete(id);
}
function stable(value) {
if (Array.isArray(value)) return value.map(stable);
return value && typeof value === 'object'
? Object.fromEntries(Object.keys(value).sort().map(key => [key, stable(value[key])])) : value;
}
function submission(req, requestId, body) {
sweep();
const owner = session(req);
if (!owner) return { status: 401, message: '检测会话已失效,请重新开始' };
if (requestId === undefined) return { status: 200 }; // Older clients remain supported.
if (typeof requestId !== 'string' || !/^[A-Za-z0-9_-]{8,128}$/.test(requestId)) {
return { status: 400, message: '检测请求标识异常,请重新开始' };
}
const key = scope(req, owner) + ':' + requestId;
const fingerprint = crypto.createHash('sha256').update(JSON.stringify(stable({ photo_refs: body.photo_refs, profile: body.profile }))).digest('hex');
const previous = submissions.get(key);
if (previous) {
if (previous.fingerprint !== fingerprint) return { status: 409, message: '检测请求已用于其他照片或档案,请重新开始' };
if (!jobs.has(previous.job)) return { status: 410, message: '本次检测任务已失效,请重新开始新的检测' };
return { status: 202, job: previous.job };
}
if (submissions.size >= 10000) return { status: 429, message: '服务繁忙,请稍后再试' };
return { status: 200, key, fingerprint };
}
function start(req, run, releasePhotos = false, attempt) {
sweep();
const owner = session(req);
if (!owner || jobs.size >= 1000) return null;
const id = crypto.randomBytes(24).toString('hex');
const job = { owner: owner.id, status: 'running', expires: now() + lifetime, result: null };
const key = scope(req, owner);
const job = { owner: owner.id, scope: key, status: 'running', expires: now() + lifetime, result: null };
jobs.set(id, job);
if (attempt && attempt.key) submissions.set(attempt.key, { job: id, scope: key, fingerprint: attempt.fingerprint, expires: now() + submissionLifetime });
Promise.resolve().then(run).then(result => { job.result = result; }, () => { job.result = { ok: false, message: '检测服务暂不可用,请重试' }; })
.finally(() => { job.status = 'complete'; job.expires = now() + lifetime; if (releasePhotos) finish(req, owner.id); });
.finally(() => { job.status = 'complete'; job.expires = now() + lifetime; if (releasePhotos) finish(req, key); });
return id;
}
function beginAnalysis(req, body, run) {
const attempt = submission(req, body.request_id, body);
if (attempt.status !== 200) return attempt;
const current = row(req), photos = availablePhotos(current, body.photo_refs);
if (!photos) return { status: 409, message: '照片上传未完成或检测进行中,请重试' };
// Reserve the job and its idempotency key BEFORE consuming uploads. There
// are no awaits here; the paid callback is queued for the next microtask.
// A full job queue leaves the complete uploads available for a later retry.
const job = start(req, () => run(photos), true, attempt);
if (!job) return { status: 429, message: '服务繁忙,请稍后再试' };
clearPhotos(current); current.active = true;
return { status: 202, job };
}
function result(req, id) {
sweep();
const job = jobs.get(id), owner = session(req);
return job && owner && job.owner === owner.id ? { status: job.status, payload: job.result } : null;
return job && owner && job.owner === owner.id && job.scope === scope(req, owner)
? { status: job.status, payload: job.result } : null;
}
const timer = setInterval(sweep, 60000); timer.unref();
return { challenge, consume, upload, takePhotos, finish, revoke, start, result };
return { challenge, consume, upload, takePhotos, finish, revoke, start, beginAnalysis, submission, result };
};
+62 -38
View File
@@ -33,6 +33,8 @@ const CaptureDiagnostics = require('./lib/capture-diagnostics.js');
const WrinkleViews = require('./lib/wrinkle-views.js');
const captureFunnel = require('./lib/capture-funnel.js')(path.join(DATA_ROOT, 'capture-funnel.jsonl'));
const accounts = require('./lib/accounts.js')(path.join(DATA_ROOT, 'accounts'));
const adminSources = require('./lib/admin-sources')({ localRoot: DATA_ROOT, localChannel: entry.channel,
taobaoRoot: entry.rednote ? null : process.env.JUZI_ADMIN_TAOBAO_DATA_ROOT, publicRoot: ROOT });
const wrinkleJobs = require('./lib/wrinkle-jobs.js')({
dir:path.join(DATA_ROOT,'wrinkle-jobs'),
collect:async context=>WrinkleViews.forReport(await WrinkleViews.collect(context.primary,context.photos,{
@@ -93,7 +95,8 @@ const MIME = {
'.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8',
'.js': 'application/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8',
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.svg': 'image/svg+xml',
'.ico': 'image/x-icon', '.webp': 'image/webp', '.woff2': 'font/woff2', '.csv': 'text/csv; charset=utf-8'
'.ico': 'image/x-icon', '.webp': 'image/webp', '.woff2': 'font/woff2', '.csv': 'text/csv; charset=utf-8',
'.mp3': 'audio/mpeg', '.aac': 'audio/aac'
};
function serveStatic(req, res, pathname) {
let p;
@@ -578,9 +581,10 @@ function sendJson(res, status, obj) {
function photoUrls(rec) {
const urls = {};
const prefix = entry.rednote ? '/rednote' : '';
if (rec.img_front) urls.front = `${prefix}/api/admin/photos/${rec.record_id}/front`;
if (rec.img_left) urls.left = `${prefix}/api/admin/photos/${rec.record_id}/left`;
if (rec.img_right) urls.right = `${prefix}/api/admin/photos/${rec.record_id}/right`;
const source = rec.admin_source ? '?source=' + encodeURIComponent(rec.admin_source) : '';
if (rec.img_front) urls.front = `${prefix}/api/admin/photos/${rec.record_id}/front${source}`;
if (rec.img_left) urls.left = `${prefix}/api/admin/photos/${rec.record_id}/left${source}`;
if (rec.img_right) urls.right = `${prefix}/api/admin/photos/${rec.record_id}/right${source}`;
return urls;
}
@@ -603,6 +607,8 @@ function listItem(rec) {
created_at: rec.created_at,
real_age: rec.real_age, gender: rec.gender, region: rec.region, daily_skin_type: rec.daily_skin_type,
channel: rec.channel || 'h5',
source: rec.admin_source || 'local', admin_key: rec.admin_key || 'local:' + rec.record_id,
readonly: !!rec.admin_readonly,
shoot_status: rec.shoot_status, upload_status: rec.upload_status, detect_status: rec.detect_status,
capture_quality: rec.capture_quality || null,
capture_policy_version: rec.capture_diagnostics ? rec.capture_diagnostics.version : null,
@@ -648,7 +654,7 @@ function matchQuery(rec, q) {
return true;
}
function handleRecordsQuery(req, res, url) {
function adminQuery(url) {
const sp = url.searchParams;
const q = {
from: sp.get('from') || null,
@@ -665,21 +671,32 @@ function handleRecordsQuery(req, res, url) {
age_max: sp.has('age_max') && sp.get('age_max') !== '' ? Number(sp.get('age_max')) : null
};
if ((q.age_min != null && !Number.isFinite(q.age_min)) || (q.age_max != null && !Number.isFinite(q.age_max))) {
return sendJson(res, 400, { ok: false, message: 'age_min/age_max 必须是数字' });
throw Error('age_min/age_max 必须是数字');
}
try { q.timeRange = BeijingTime.range(q.from, q.to); }
if (q.channel && !['h5', 'taobao', 'rednote'].includes(q.channel)) throw Error('渠道参数无效');
q.timeRange = BeijingTime.range(q.from, q.to);
return q;
}
function adminRecordOrder(a, b) {
const delta = BeijingTime.milliseconds(b.created_at) - BeijingTime.milliseconds(a.created_at);
return (Number.isFinite(delta) ? delta : 0) || String(a.admin_key).localeCompare(String(b.admin_key));
}
function handleRecordsQuery(req, res, url) {
let q;
try { q = adminQuery(url); }
catch (e) { return sendJson(res, 400, { ok: false, message: e.message }); }
const sp = url.searchParams;
const page = Math.max(1, parseInt(sp.get('page') || '1', 10) || 1);
const pageSize = Math.min(100, Math.max(1, parseInt(sp.get('page_size') || '20', 10) || 20));
const filtered = readAllRecords().map(displayRecommendationRecord).filter(r => matchQuery(r, q))
.sort((a, b) => (a.created_at < b.created_at ? 1 : -1)); // created_at 倒序
const filtered = adminSources.records().map(displayRecommendationRecord).filter(r => matchQuery(r, q))
.sort(adminRecordOrder); // 跨数据源统一排序;同时间用来源+ID稳定分页
const total = filtered.length;
const items = filtered.slice((page - 1) * pageSize, page * pageSize).map(listItem);
sendJson(res, 200, { ok: true, total, page, page_size: pageSize, items });
sendJson(res, 200, { ok: true, total, page, page_size: pageSize, items, sources: adminSources.descriptors() });
}
function handleRecordDetail(req, res, id) {
const rec = displayRecommendationRecord(findRecord(id));
function handleRecordDetail(req, res, id, source = 'local') {
const rec = displayRecommendationRecord(adminSources.find(id, source));
if (!rec) return sendJson(res, 404, { ok: false, message: '记录不存在' });
// 原始 JSON 全文:读文件失败给 null。
// face_maps 是整幅 base64 叠加图(单文件可达数 MB),详情页直接渲染会卡死浏览器:
@@ -687,7 +704,7 @@ function handleRecordDetail(req, res, id) {
let rawJson = null;
if (rec.raw_file) {
const fname = String(rec.raw_file).replace(/[^\w.,-]/g, '');
try { rawJson = JSON.parse(fs.readFileSync(path.join(DATA_DIR, fname), 'utf8')); }
try { rawJson = JSON.parse(fs.readFileSync(adminSources.file(source, 'results', fname), 'utf8')); }
catch (e) { rawJson = null; }
}
if (rawJson && rawJson.result && rawJson.result.face_maps) {
@@ -699,11 +716,11 @@ function handleRecordDetail(req, res, id) {
}
}
// 版本链:它 retry_of 指向的(父)+ retry_of 指向它的(子)
const all = readAllRecords();
const all = adminSources.records(source);
const parent = rec.retry_of ? (all.find(r => r.record_id === rec.retry_of) || null) : null;
const children = all.filter(r => r.retry_of === rec.record_id)
.map(r => ({ record_id: r.record_id, detect_status: r.detect_status, created_at: r.created_at }));
let presentation = rec.presentation || accounts.reportPresentation(rec.owner_id, rec.record_id);
.map(r => ({ record_id: r.record_id, source, detect_status: r.detect_status, created_at: r.created_at }));
let presentation = rec.presentation || (source === 'local' ? accounts.reportPresentation(rec.owner_id, rec.record_id) : adminSources.presentation(rec));
if (presentation && (presentation.gradeRule !== ReportUI.gradeRule || presentation.copyRule !== ReportUI.copyRule) && rawJson && rawJson.result) {
// Recover text variables from raw data, but retain the saved scores,
// recommendation and care order. No database/snapshot mutation.
@@ -719,23 +736,26 @@ function handleRecordDetail(req, res, id) {
if(presentation && rec.recommendation && rec.recommendation.ageBasis==='display')presentation=Object.assign({},presentation,{product:ReportUI.products.find(p=>p.tier===rec.recommendation.tier)||null});
sendJson(res, 200, {
ok: true,
source, readonly: !!rec.admin_readonly,
raw_file: rec.raw_file || null,
record: Object.assign({}, rec, { total_score: displayTotalScore(rec), total_score_rule: 'dimensions-433-v1', skin_age_display: displaySkinAge(rec),
dims: displayScoreLevels(rec.dims,true), sub_scores: displayScoreLevels(rec.sub_scores), grade_rule: ReportUI.gradeRule, presentation }),
raw_json: rawJson,
photos: photoUrls(rec),
versions: {
parent: parent ? { record_id: parent.record_id, detect_status: parent.detect_status, created_at: parent.created_at } : null,
parent: parent ? { record_id: parent.record_id, source, detect_status: parent.detect_status, created_at: parent.created_at } : null,
children
}
});
}
/* 完整原始 JSON 下载(face_maps base64 未裁剪,动辄数 MB,仅供复核下载,不做页面内展示) */
function handleRawDownload(req, res, id) {
const rec = findRecord(id);
function handleRawDownload(req, res, id, source = 'local') {
const rec = adminSources.find(id, source);
if (!rec || !rec.raw_file) return sendJson(res, 404, { ok: false, message: '记录或原始 JSON 不存在' });
const fname = String(rec.raw_file).replace(/[^\w.,-]/g, '');
const file = path.join(DATA_DIR, fname);
const file = adminSources.file(source, 'results', fname);
if (!file) return sendJson(res, 404, { ok: false, message: '原始 JSON 文件不存在' });
fs.readFile(file, (err, data) => {
if (err) return sendJson(res, 404, { ok: false, message: '原始 JSON 文件不存在' });
res.writeHead(200, {
@@ -746,14 +766,15 @@ function handleRawDownload(req, res, id) {
});
}
function handlePhoto(req, res, id, which) {
const safe = String(id || '').replace(/[^\w-]/g, '');
const file = path.join(PHOTOS_DIR, safe, which + '.jpg');
function handlePhoto(req, res, id, which, source = 'local') {
const rec = adminSources.find(id, source);
const file = rec && adminSources.file(source, 'photos', id, which + '.jpg');
if (!file) { res.writeHead(404); return res.end('Not Found'); }
fs.readFile(file, (err, data) => {
if (err) { res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); return res.end('Not Found'); }
res.writeHead(200, {
'Content-Type': 'image/jpeg',
'Content-Disposition': `inline; filename="${safe}-${which}.jpg"`
'Content-Disposition': `inline; filename="${id}-${which}.jpg"`
});
res.end(data);
});
@@ -761,8 +782,9 @@ function handlePhoto(req, res, id, which) {
function handleStats(req, res, url) {
const channel = url.searchParams.get('channel') || null; // 传 channel 时以下指标仅统计该渠道
if (channel && !['h5', 'taobao', 'rednote'].includes(channel)) return sendJson(res, 400, { ok: false, message: '渠道参数无效' });
const now = Date.now(), today = BeijingTime.day(now);
const allRaw = readAllRecords().map(displayRecommendationRecord);
const allRaw = adminSources.records().map(displayRecommendationRecord);
// 按渠道拆分概览(始终基于全量,历史无 channel 字段的记录按 'h5' 计)
const by_channel = {};
for (const r of allRaw) {
@@ -822,7 +844,8 @@ function handleStats(req, res, url) {
fail_total: failed.length,
success_rate: finished.length ? Math.round(succeeded.length * 1000 / finished.length) / 10 : 0,
avg_cost_ms: avgCost,
capture_funnel: captureFunnel.stats(now),
capture_funnel: adminSources.funnel(now, channel, captureFunnel),
sources: adminSources.descriptors(),
by_channel,
fail_dist,
dim_dist,
@@ -883,15 +906,11 @@ function csvCell(v) {
}
function handleExport(req, res, url) {
const q = {
from: url.searchParams.get('from') || null,
to: url.searchParams.get('to') || null,
channel: url.searchParams.get('channel') || null
};
try { q.timeRange = BeijingTime.range(q.from, q.to); }
let q;
try { q = adminQuery(url); }
catch (e) { return sendJson(res, 400, { ok: false, message: e.message }); }
const filtered = readAllRecords().map(displayRecommendationRecord).filter(r => matchQuery(r, q))
.sort((a, b) => (a.created_at < b.created_at ? 1 : -1));
const filtered = adminSources.records().map(displayRecommendationRecord).filter(r => matchQuery(r, q))
.sort(adminRecordOrder);
const dimP = (r, d) => (r.dims && r.dims[d]) ? r.dims[d].P : null;
const dimS = (r, d) => (r.dims && r.dims[d]) ? SkinScore.capDimensionScore(r.dims[d].S) : null;
const dimLv = (r, d) => (r.dims && r.dims[d]) ? ReportUI.levelForScore(r.dims[d].S) : null;
@@ -899,6 +918,8 @@ function handleExport(req, res, url) {
'失败环节', '失败码', '失败信息', '综合得分(4:3:3)', '原始肌龄', '前端展示肌龄(推荐依据)',
'紧塑力P', '紧塑力S', '紧塑力档', '透亮感P', '透亮感S', '透亮感档', '稳韧力P', '稳韧力S', '稳韧力档',
'均分S', '产品组别', '推荐精华', '触发判据', '耗时ms', '推荐规则版本', '推荐原因', '侧脸质量状态', '前端拍摄参数版本', '渠道'];
const includeSources = adminSources.descriptors().length > 1;
if (includeSources) header.push('数据来源', '记录唯一键');
const lines = [header.join(',')];
for (const r of filtered) {
lines.push([
@@ -915,7 +936,7 @@ function handleExport(req, res, url) {
r.capture_quality ? r.capture_quality.status : null,
r.capture_diagnostics ? r.capture_diagnostics.version : null,
r.channel || 'h5'
].map(csvCell).join(','));
].concat(includeSources ? [r.admin_source, r.admin_key] : []).map(csvCell).join(','));
}
const fromTag = (q.from || 'all').replace(/[^\d-]/g, '');
const toTag = (q.to || 'all').replace(/[^\d-]/g, '');
@@ -929,14 +950,17 @@ function handleExport(req, res, url) {
async function handleAdmin(req, res, pathname, url) {
try {
const source = url.searchParams.get('source') || 'local';
if (!adminSources.source(source)) return sendJson(res, 404, { ok: false, message: '数据来源不存在' });
const retryM = pathname.match(/^\/api\/admin\/records\/([\w-]+)\/retry$/);
if (req.method === 'POST' && retryM && source !== 'local') return sendJson(res, 409, { ok: false, message: '该渠道为只读汇总,请在原服务后台重试' });
if (req.method === 'POST' && retryM) return await handleRetry(req, res, retryM[1]);
const rawM = pathname.match(/^\/api\/admin\/records\/([\w-]+)\/raw$/);
if (req.method === 'GET' && rawM) return handleRawDownload(req, res, rawM[1]);
if (req.method === 'GET' && rawM) return handleRawDownload(req, res, rawM[1], source);
const detailM = pathname.match(/^\/api\/admin\/records\/([\w-]+)$/);
if (req.method === 'GET' && detailM) return handleRecordDetail(req, res, detailM[1]);
if (req.method === 'GET' && detailM) return handleRecordDetail(req, res, detailM[1], source);
const photoM = pathname.match(/^\/api\/admin\/photos\/([\w-]+)\/(front|left|right)$/);
if (req.method === 'GET' && photoM) return handlePhoto(req, res, photoM[1], photoM[2]);
if (req.method === 'GET' && photoM) return handlePhoto(req, res, photoM[1], photoM[2], source);
if (req.method === 'GET' && pathname === '/api/admin/records') return handleRecordsQuery(req, res, url);
if (req.method === 'GET' && pathname === '/api/admin/stats') return handleStats(req, res, url);
if (req.method === 'GET' && pathname === '/api/admin/export') return handleExport(req, res, url);