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:
xsl
2026-10-19 20:14:31 +08:00
parent d07f8cc1bb
commit 9eec4369da
61 changed files with 4827 additions and 17 deletions
+33 -3
View File
@@ -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 };
};
+31
View File
@@ -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 };
+69
View File
@@ -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 ? '请求数据异常' : '服务暂不可用,请稍后再试' });
}
};
};
+98
View File
@@ -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 };
};
+3 -3
View File
@@ -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;