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