feat(taobao): add native mini program with shared backend

Preserve H5 flows while adding Taobao identity, native capture, reports and isolated local development. Fix canceled province selection and expired-session logout. Record passing local regressions, two pre-existing H5 assertion failures and pending platform validation.
This commit is contained in:
xsl
2026-10-19 20:14:31 +08:00
parent d07f8cc1bb
commit 9eec4369da
61 changed files with 4827 additions and 17 deletions
+35 -11
View File
@@ -60,6 +60,7 @@ const wrinkleJobs = require('./lib/wrinkle-jobs.js')({
/* 服务端评分引擎:与 C 端同一份公式(禁止复制公式,直接复用) */
const SkinScore = require('./js/skin-score.js');
const handleTaobao = require('./lib/taobao-routes.js')({ accounts, wrinkleJobs, readBody, sendJson, handleAnalyze });
/* ─── 密钥加载:环境变量优先,其次 apikey.bash,最后 reference 下的 credentials.env ─── */
function loadCredentials() {
@@ -421,16 +422,18 @@ async function callFacepp(front, left, right) {
return fetchErr ? { error_message: 'NETWORK_ERROR' } : fpJson;
}
async function handleAnalyze(req, res) {
async function handleAnalyze(req, res, miniProgram = false, preparedBody = null, preparedOwner = null) {
res.setHeader('Cache-Control', 'no-store');
let rec = null; // 记录:正脸照片校验通过后创建(此前失败无从落库)
let persist = false;
try {
const owner = accounts.session(req);
const owner = miniProgram ? preparedOwner : accounts.session(req);
if (!owner) return sendJson(res, 401, { ok: false, message: '请先登录后检测' });
persist = owner.kind === 'wechat';
if (!accounts.sameOrigin(req)) return sendJson(res, 403, { ok: false, message: '请求来源不匹配' });
const body = JSON.parse((await readBody(req, 20 * 1024 * 1024)).toString('utf8'));
const body = preparedBody || JSON.parse((await readBody(req, 20 * 1024 * 1024)).toString('utf8'));
// A client-supplied channel must never change H5 guest retention behavior.
const channel = miniProgram ? 'taobao' : 'h5';
persist = ['wechat', 'taobao'].includes(owner.kind);
// 三个 image 字段先做类型检查:非字符串视为缺失(左/右图缺失时仅发正脸,接口自动降级为单视图)
const front = stripDataPrefix(typeof body.front_image === 'string' ? body.front_image : '');
const leftB64 = stripDataPrefix(typeof body.left_image === 'string' ? body.left_image : '');
@@ -457,6 +460,7 @@ async function handleAnalyze(req, res) {
gender: profile.gender,
region: profile.region,
daily_skin_type: profile.daily_skin_type,
channel,
img_front: photos.front || null,
img_left: photos.left || null,
img_right: photos.right || null,
@@ -544,7 +548,7 @@ async function handleAnalyze(req, res) {
}
}));
} catch (e) {
if (rec && persist) { // 仅微信检测失败记录落库;游客不保留异常记录。
if (rec && persist) { // H5 游客不保留异常记录;已登录账号保存检测记录。
fillFailure(rec, e.message === 'TOO_LARGE' ? 'IMAGE_FILE_TOO_LARGE' : 'NETWORK_ERROR');
rec.updated_at = nowIso();
updateRecord(rec);
@@ -592,6 +596,7 @@ function listItem(rec) {
record_id: rec.record_id,
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',
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,
@@ -620,6 +625,7 @@ function matchQuery(rec, q) {
if (!Number.isFinite(timestamp) || timestamp < q.timeRange.start || timestamp >= q.timeRange.end) return false;
}
if (q.detect_status && rec.detect_status !== q.detect_status) return false;
if (q.channel && (rec.channel || 'h5') !== q.channel) return false;
if (q.fail_code && rec.fail_code !== q.fail_code) return false;
if (q.group && rec.group !== q.group) return false;
if (q.q) {
@@ -642,6 +648,7 @@ function handleRecordsQuery(req, res, url) {
from: sp.get('from') || null,
to: sp.get('to') || null,
detect_status: sp.get('detect_status') || null,
channel: sp.get('channel') || null,
fail_code: sp.get('fail_code') || null,
group: sp.get('group') || null,
dim: sp.get('dim') || null,
@@ -746,9 +753,21 @@ function handlePhoto(req, res, id, which) {
});
}
function handleStats(req, res) {
const all = readAllRecords().map(displayRecommendationRecord);
function handleStats(req, res, url) {
const channel = url.searchParams.get('channel') || null; // 传 channel 时以下指标仅统计该渠道
const now = Date.now(), today = BeijingTime.day(now);
const allRaw = readAllRecords().map(displayRecommendationRecord);
// 按渠道拆分概览(始终基于全量,历史无 channel 字段的记录按 'h5' 计)
const by_channel = {};
for (const r of allRaw) {
const ch = r.channel || 'h5';
const b = by_channel[ch] || (by_channel[ch] = { total: 0, today_total: 0, success_total: 0, fail_total: 0 });
b.total += 1;
if (BeijingTime.day(r.created_at) === today) b.today_total += 1;
if (r.detect_status === '成功') b.success_total += 1;
if (r.detect_status === '失败') b.fail_total += 1;
}
const all = channel ? allRaw.filter(r => (r.channel || 'h5') === channel) : allRaw;
const finished = all.filter(r => r.detect_status === '成功' || r.detect_status === '失败');
const succeeded = finished.filter(r => r.detect_status === '成功');
const failed = finished.filter(r => r.detect_status === '失败');
@@ -798,6 +817,7 @@ function handleStats(req, res) {
success_rate: finished.length ? Math.round(succeeded.length * 1000 / finished.length) / 10 : 0,
avg_cost_ms: avgCost,
capture_funnel: captureFunnel.stats(now),
by_channel,
fail_dist,
dim_dist,
group_dist,
@@ -823,6 +843,7 @@ async function handleRetry(req, res, id) {
created_at: nowIso(),
updated_at: nowIso(),
real_age: rec.real_age, gender: rec.gender, region: rec.region, daily_skin_type: rec.daily_skin_type,
channel: rec.channel || 'h5', // 重试记录沿用原渠道
img_front: photos.front || null,
img_left: photos.left || null,
img_right: photos.right || null,
@@ -858,7 +879,8 @@ function csvCell(v) {
function handleExport(req, res, url) {
const q = {
from: url.searchParams.get('from') || null,
to: url.searchParams.get('to') || null
to: url.searchParams.get('to') || null,
channel: url.searchParams.get('channel') || null
};
try { q.timeRange = BeijingTime.range(q.from, q.to); }
catch (e) { return sendJson(res, 400, { ok: false, message: e.message }); }
@@ -870,7 +892,7 @@ function handleExport(req, res, url) {
const header = ['记录ID', '创建时间(北京时间)', '年龄', '性别', '地区', '日常肤质', '拍摄状态', '上传状态', '检测状态',
'失败环节', '失败码', '失败信息', '综合得分(4:3:3)', '原始肌龄', '前端展示肌龄(推荐依据)',
'紧塑力P', '紧塑力S', '紧塑力档', '透亮感P', '透亮感S', '透亮感档', '稳韧力P', '稳韧力S', '稳韧力档',
'均分S', '产品组别', '推荐精华', '触发判据', '耗时ms', '推荐规则版本', '推荐原因', '侧脸质量状态', '前端拍摄参数版本'];
'均分S', '产品组别', '推荐精华', '触发判据', '耗时ms', '推荐规则版本', '推荐原因', '侧脸质量状态', '前端拍摄参数版本', '渠道'];
const lines = [header.join(',')];
for (const r of filtered) {
lines.push([
@@ -885,7 +907,8 @@ function handleExport(req, res, url) {
r.recommendation ? r.recommendation.version : '旧版规则(历史记录)',
r.recommendation ? r.recommendation.reason : null,
r.capture_quality ? r.capture_quality.status : null,
r.capture_diagnostics ? r.capture_diagnostics.version : null
r.capture_diagnostics ? r.capture_diagnostics.version : null,
r.channel || 'h5'
].map(csvCell).join(','));
}
const fromTag = (q.from || 'all').replace(/[^\d-]/g, '');
@@ -909,7 +932,7 @@ async function handleAdmin(req, res, pathname, url) {
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' && pathname === '/api/admin/records') return handleRecordsQuery(req, res, url);
if (req.method === 'GET' && pathname === '/api/admin/stats') return handleStats(req, res);
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);
return sendJson(res, 404, { ok: false, message: '接口不存在' });
} catch (e) {
@@ -920,6 +943,7 @@ async function handleAdmin(req, res, pathname, url) {
http.createServer(async (req, res) => {
const pathname = req.url.split('?')[0];
if (pathname.startsWith('/api/taobao/')) return handleTaobao(req, res, pathname);
if (await accounts.route(req, res, pathname)) return;
if (req.method==='POST' && pathname==='/api/wrinkle-views') {
const owner=accounts.session(req);