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