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.
99 lines
5.1 KiB
JavaScript
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 };
|
|
};
|