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:
+33
-3
@@ -177,7 +177,7 @@ module.exports = function createAccounts(root, env = process.env) {
|
||||
}
|
||||
function storeReport(ownerId, snapshot) {
|
||||
const user = db.users.find(u => u.id === ownerId);
|
||||
if (!user || user.kind !== 'wechat' || !/^R[\w-]+$/.test(snapshot.id)) throw new Error('Invalid report owner');
|
||||
if (!user || !['wechat', 'taobao'].includes(user.kind) || !/^R[\w-]+$/.test(snapshot.id)) throw new Error('Invalid report owner');
|
||||
const dir = path.join(snapshots, user.id); fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
atomic(path.join(dir, snapshot.id + '.json'), snapshot);
|
||||
user.reports = [{ id: snapshot.id, created_at: snapshot.created_at, total: snapshot.vm.total, skinAge: snapshot.vm.skinAgeDisplay }]
|
||||
@@ -193,7 +193,7 @@ module.exports = function createAccounts(root, env = process.env) {
|
||||
catch (_) { return null; }
|
||||
}
|
||||
function reportSnapshot(ownerId,id) {
|
||||
if (!db.users.some(u=>u.id===ownerId&&u.kind==='wechat') || !/^R[\w-]+$/.test(id)) return null;
|
||||
if (!db.users.some(u=>u.id===ownerId&&['wechat','taobao'].includes(u.kind)) || !/^R[\w-]+$/.test(id)) return null;
|
||||
try {return JSON.parse(fs.readFileSync(path.join(snapshots,ownerId,id+'.json'),'utf8'));} catch (_) {return null;}
|
||||
}
|
||||
function updateWrinkleViews(ownerId,id,views) {
|
||||
@@ -203,5 +203,35 @@ module.exports = function createAccounts(root, env = process.env) {
|
||||
if (snapshot.payload.wrinkle_job) snapshot.payload.wrinkle_job.status='complete';
|
||||
atomic(path.join(snapshots,ownerId,id+'.json'),snapshot);
|
||||
}
|
||||
return { route, session, storeReport, reportPresentation, reportSnapshot, updateWrinkleViews, sameOrigin, wechatAvailable: available };
|
||||
function loginTaobao(providerKey) {
|
||||
let user = db.users.find(u => u.kind === 'taobao' && u.provider_key === providerKey);
|
||||
if (!user) { user = { id: crypto.randomUUID(), kind: 'taobao', provider_key: providerKey, reports: [] }; db.users.push(user); }
|
||||
const token = random(), expires = Date.now() + 30 * 86400000;
|
||||
for (const key of Object.keys(db.sessions)) if (db.sessions[key].expires < Date.now()) delete db.sessions[key];
|
||||
db.sessions[hash(token)] = { user: user.id, expires };
|
||||
save();
|
||||
return { token, expires_in: 30 * 86400, user: publicUser(user) };
|
||||
}
|
||||
function taobaoSession(req) {
|
||||
const match = /^Bearer ([a-f0-9]{64})$/.exec(req.headers.authorization || '');
|
||||
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 && taobaoSession(req)) { delete db.sessions[hash(match[1])]; save(); }
|
||||
}
|
||||
function reports(owner) {
|
||||
return db.users.find(u => u.id === owner && u.kind === 'taobao')?.reports || [];
|
||||
}
|
||||
function ownedReport(owner, id) {
|
||||
if (!reports(owner).some(r => r.id === id)) return null;
|
||||
const snapshot = reportSnapshot(owner, id);
|
||||
if (!snapshot) return null;
|
||||
snapshot.vm = SkinScore.withDisplayRecommendation(snapshot.vm, snapshot.payload && snapshot.payload.result);
|
||||
snapshot.presentation = ReportUI.forDisplay(snapshot.vm, snapshot.payload && snapshot.payload.result, snapshot.presentation);
|
||||
return snapshot;
|
||||
}
|
||||
return { route, session, storeReport, reportPresentation, reportSnapshot, updateWrinkleViews, sameOrigin, wechatAvailable: available,
|
||||
loginTaobao, taobaoSession, taobaoLogout, reports, ownedReport };
|
||||
};
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
'use strict';
|
||||
const crypto = require('node:crypto');
|
||||
|
||||
// Cloud gateway query parameters + the exact, unread JSON body. Algorithm:
|
||||
// https://developer.alibaba.com/docs/doc.htm?articleId=121746&docType=1&treeId=832
|
||||
function signature(params, body, secret) {
|
||||
const method = params.sign_method || '';
|
||||
if (!['', 'md5', 'hmac', 'hmac_md5', 'hmac_sha256'].includes(method)) return null;
|
||||
const content = Object.keys(params).filter(key => key !== 'sign' && params[key] !== '').sort()
|
||||
.map(key => key + params[key]).join('') + body;
|
||||
if (method === 'hmac' || method === 'hmac_md5' || method === 'hmac_sha256') {
|
||||
return crypto.createHmac(method === 'hmac_sha256' ? 'sha256' : 'md5', secret).update(content).digest('hex').toUpperCase();
|
||||
}
|
||||
return crypto.createHash('md5').update((method === 'md5' ? secret : '') + content + secret).digest('hex').toUpperCase();
|
||||
}
|
||||
function verify(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 || '') ||
|
||||
!/^[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') };
|
||||
}
|
||||
module.exports = { signature, verify };
|
||||
@@ -0,0 +1,69 @@
|
||||
'use strict';
|
||||
const TaobaoAuth = require('./taobao-auth');
|
||||
module.exports = function ({ accounts, wrinkleJobs, readBody, sendJson, handleAnalyze }) {
|
||||
const taobaoSessions = require('./taobao-session')({ session: accounts.taobaoSession });
|
||||
return async function (req, res, pathname) {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
// The cloud gateway requires its own envelope. H5 responses stay unchanged.
|
||||
const reply = (status, data) => sendJson(res, 200, { success: true, data: { ...data, http_status: status } });
|
||||
if (req.method !== 'POST') return reply(405, { ok: false, message: '请求方法不支持' });
|
||||
if (!accounts.sameOrigin(req)) return reply(403, { ok: false, message: '请求来源不匹配' });
|
||||
try {
|
||||
const raw = (await readBody(req, pathname === '/api/taobao/photo' ? 70 * 1024 : 4096)).toString('utf8');
|
||||
const body = JSON.parse(raw);
|
||||
if (pathname === '/api/taobao/challenge') {
|
||||
const challenge = taobaoSessions.challenge(req.socket.remoteAddress);
|
||||
return reply(challenge ? 200 : 429, challenge ? { ok: true, challenge } : { ok: false, message: '操作频繁,请稍后重试' });
|
||||
}
|
||||
if (pathname === '/api/taobao/session') {
|
||||
if (body.consent !== true) return reply(400, { ok: false, message: '请先阅读并同意隐私协议' });
|
||||
const identity = TaobaoAuth.verify(req.url, raw);
|
||||
if (!identity || !taobaoSessions.consume(body.challenge)) return reply(401, { ok: false, message: '淘宝身份校验失败,请重新授权登录' });
|
||||
return reply(200, { ok: true, ...accounts.loginTaobao(identity.providerKey) });
|
||||
}
|
||||
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 (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 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 });
|
||||
}
|
||||
if (pathname === '/api/taobao/analysis') {
|
||||
const result = taobaoSessions.result(req, body.job);
|
||||
return reply(result ? 200 : 404, result ? { ok: true, ...result } : { ok: false, message: '检测任务已失效,请查看历史报告或重新检测' });
|
||||
}
|
||||
if (pathname === '/api/taobao/reports') {
|
||||
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: '报告不存在' });
|
||||
}
|
||||
if (pathname === '/api/taobao/logout') {
|
||||
taobaoSessions.revoke(req);
|
||||
accounts.taobaoLogout(req);
|
||||
return reply(200, { ok: true });
|
||||
}
|
||||
if (pathname === '/api/taobao/wrinkle-views') {
|
||||
const job = taobaoSessions.start(req, async () => {
|
||||
const views = await wrinkleJobs.run(owner, body.token);
|
||||
return views ? { ok: true, wrinkle_views: views } : { ok: false, message: '标注任务已失效' };
|
||||
});
|
||||
return reply(job ? 202 : 429, job ? { ok: true, job } : { ok: false, message: '服务繁忙,请稍后再试' });
|
||||
}
|
||||
return reply(404, { ok: false, message: '接口不存在' });
|
||||
} catch (e) {
|
||||
const status = e.message === 'TOO_LARGE' ? 413 : e instanceof SyntaxError ? 400 : 500;
|
||||
return reply(status, { ok: false, message: status === 413 ? '请求数据过大' : status === 400 ? '请求数据异常' : '服务暂不可用,请稍后再试' });
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
'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 };
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
'use strict';
|
||||
const fs = require('node:fs'), path = require('node:path'), crypto = require('node:crypto');
|
||||
// Lazy, owner-bound, single-flight tasks. Only WeChat jobs persist. Guest photos
|
||||
// Lazy, owner-bound, single-flight tasks. Account jobs persist. Guest photos
|
||||
// remain in RAM for at most five minutes; no paid work starts until requested.
|
||||
module.exports = function({dir, collect, load, complete, now = Date.now}) {
|
||||
const jobs = new Map();
|
||||
@@ -16,7 +16,7 @@ module.exports = function({dir, collect, load, complete, now = Date.now}) {
|
||||
fs.renameSync(tmp,file);
|
||||
}
|
||||
function register(owner,record,context) {
|
||||
const job={token:crypto.randomBytes(24).toString('hex'),owner:owner.id,record,persist:owner.kind==='wechat',status:'pending',context};
|
||||
const job={token:crypto.randomBytes(24).toString('hex'),owner:owner.id,record,persist:['wechat','taobao'].includes(owner.kind),status:'pending',context};
|
||||
save(job);remember(job);
|
||||
return {token:job.token,status:'pending'};
|
||||
}
|
||||
@@ -26,7 +26,7 @@ module.exports = function({dir, collect, load, complete, now = Date.now}) {
|
||||
let job=jobs.get(token);
|
||||
if (!job) {
|
||||
try {job=JSON.parse(fs.readFileSync(path.join(dir,token+'.json'),'utf8'));} catch (_) {return null;}
|
||||
if (job.owner!==owner.id || owner.kind!=='wechat') return null;
|
||||
if (job.owner!==owner.id || !['wechat','taobao'].includes(owner.kind)) return null;
|
||||
remember(job);
|
||||
}
|
||||
if (job.owner!==owner.id) return null;
|
||||
|
||||
Reference in New Issue
Block a user