Files
juzi/h5/lib/taobao-session.js
xsl 9eec4369da 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.
2026-10-19 20:14:31 +08:00

99 lines
5.1 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();
const lifetime = 5 * 60000, maxChunk = 64 * 1024, maxPhoto = 4 * 1024 * 1024;
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);
}
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;
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;
}
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 takePhotos(req, refs) {
const current = row(req);
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('')]));
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) {
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 };
jobs.set(id, job);
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); });
return id;
}
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;
}
const timer = setInterval(sweep, 60000); timer.unref();
return { challenge, consume, upload, takePhotos, finish, revoke, start, result };
};