Files
juzi/h5/lib/taobao-session.js
T
xsl 774376fa16 feat: 完善淘宝小程序拍照、报告与登录流程
接入本地实时识别和自动三连拍,调整报告裁切、商品跳转、分享标题及居中页眉;完善游客会话、后台渠道隔离、部署脚本与回归测试。
2026-11-04 15:07:51 +08:00

159 lines
8.5 KiB
JavaScript

'use strict';
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(), 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;
row.photos = {};
}
function sweep() {
for (const [id, row] of transfers) if (row.expires <= now() && !row.active) { clearPhotos(row); transfers.delete(id); }
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();
const rate = rates.get(address) || { count: 0, until: now() + 60000 };
if (rates.size >= 10000 && !rates.has(address)) return null;
rates.set(address, rate);
if (++rate.count > 30 || challenges.size >= 1000) return null;
const id = crypto.randomBytes(24).toString('hex');
challenges.set(id, now() + 60000);
return id;
}
function consume(id) {
sweep();
if (!challenges.has(id)) return false;
challenges.delete(id); return true;
}
function row(req, create = false) {
sweep();
const owner = session(req);
if (!owner) return 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);
if (!current) return { status: 401, message: '检测会话已失效,请重新开始' };
if (current.active) return { status: 409, message: '检测进行中,请稍候' };
const { view, upload_id: id, index, total, data } = body || {};
if (!['front', 'left', 'right'].includes(view) || !/^[a-z0-9-]{8,64}$/.test(id || '') ||
!Number.isInteger(total) || total < 1 || total > 64 || !Number.isInteger(index) || index < 0 || index >= total ||
typeof data !== 'string' || !data.length || data.length > maxChunk || !/^[A-Za-z0-9+/]+={0,2}$/.test(data)) {
return { status: 400, message: '照片分片数据异常,请重新拍摄' };
}
let photo = current.photos[view];
if (!photo || photo.id !== id) {
if (index !== 0) return { status: 409, message: '照片上传已失效,请重试' };
if (photo) bytes -= photo.bytes;
photo = current.photos[view] = { id, total, chunks: [], bytes: 0 };
}
if (photo.total !== total || index > photo.chunks.length || (index < photo.chunks.length && photo.chunks[index] !== data)) {
return { status: 409, message: '照片上传顺序异常,请重试' };
}
if (index === photo.chunks.length) {
if (photo.bytes + data.length > maxPhoto || bytes + data.length > 64 * 1024 * 1024) return { status: 413, message: '照片过大或服务繁忙,请稍后重试' };
photo.chunks.push(data); photo.bytes += data.length; bytes += data.length;
}
current.expires = now() + lifetime;
return { status: 200, complete: photo.chunks.length === total, photo_ref: id };
}
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;
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, 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 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, 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 && 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, beginAnalysis, submission, result };
};