feat: 完善淘宝小程序拍照、报告与登录流程

接入本地实时识别和自动三连拍,调整报告裁切、商品跳转、分享标题及居中页眉;完善游客会话、后台渠道隔离、部署脚本与回归测试。
This commit is contained in:
xsl
2026-11-04 15:07:51 +08:00
parent 381da60ae4
commit 774376fa16
147 changed files with 17420 additions and 597 deletions
+142 -29
View File
@@ -1,19 +1,41 @@
var config = require('../config'), transport = require('./transport');
var token = '', user = null, storageKey = 'juzi.taobao.session.v1';
var sessionVersion = 0, authAttempt = 0, restoring = null, pendingAnalysis = null;
var validationEpoch = 0, needsValidation = false;
function assetUrl(path) { return config.assetBase.replace(/\/?$/, '/') + path.replace(/^assets\//, ''); }
function clear() {
token = ''; user = null;
try { my.removeStorageSync({ key: storageKey }); } catch (_) {}
function isGuest() { return !!(user && user.kind === 'taobao_guest'); }
function hasSession() { return !!(token && user); }
function isSignedIn() { return hasSession() && !isGuest() && !needsValidation; }
function clearTransient() {
pendingAnalysis = null;
var app = typeof getApp === 'function' && getApp();
if (app) { app.globalData.profile = null; app.globalData.photos = null; app.globalData.payload = null; }
}
function clear(keepSaved) {
token = ''; user = null; sessionVersion++; authAttempt++; restoring = null;
validationEpoch++; needsValidation = false;
if (!keepSaved) { try { my.removeStorageSync({ key: storageKey }); } catch (_) {} }
clearTransient();
}
function resetForLaunch() { clear(true); }
function suspendSession() {
validationEpoch++; restoring = null;
needsValidation = !!token && !isGuest();
}
function stale() { var err = new Error('本次会话已结束,请重新进入'); err.canceled = true; return err; }
function request(path, body) {
var requestToken = token;
var protectedRequest = !/^(me|challenge|session|guest-session|logout)$/.test(path);
if (protectedRequest && needsValidation && token && !isGuest()) {
return restore().then(function () { return request(path, body); });
}
var requestToken = token, version = sessionVersion, epoch = validationEpoch;
return transport.request('/api/taobao/' + path, body || {}, requestToken).then(function (data) {
if (requestToken !== token || version !== sessionVersion) throw stale();
if (protectedRequest && !isGuest() && epoch !== validationEpoch) throw stale();
if (!data || !data.ok) {
var err = new Error(data && data.message || '服务暂不可用,请稍后重试');
err.status = data && data.http_status; err.code = data && data.code;
if (err.status === 401 && requestToken === token) clear();
if (err.status === 401) clear();
throw err;
}
return data;
@@ -22,30 +44,65 @@ function request(path, body) {
function authorize() {
if (config.transport === 'direct') return Promise.resolve();
return new Promise(function (resolve, reject) {
my.authorize({ scopes: 'scope.userInfo', success: resolve, fail: function () { reject(new Error('请授权淘宝账号后继续')); } });
my.authorize({ scopes: 'scope.userInfo', success: resolve,
fail: function () { reject(new Error('淘宝账号授权未完成,您也可以选择游客体验')); } });
});
}
function login(consent) {
if (!consent) return Promise.reject(new Error('请先阅读并同意隐私协议'));
return authorize().then(function () { return request('challenge'); }).then(function (data) {
return request('session', { consent: true, challenge: data.challenge });
function startSession(consent, guest) {
if (!consent) return Promise.reject(new Error('请先阅读并同意用户协议和隐私政策'));
// A deliberate guest entry ends the previous identity before any async work.
// Old /me success/401 responses then fail the session-version guard, and the
// guest challenge/session never inherit the old Bearer or persisted account.
if (guest) clear();
var ticket = ++authAttempt;
return (guest ? Promise.resolve() : authorize()).then(function () {
if (ticket !== authAttempt) throw stale();
return request('challenge');
}).then(function (data) {
if (ticket !== authAttempt) throw stale();
return request(guest ? 'guest-session' : 'session', { consent: true, challenge: data.challenge });
}).then(function (data) {
if (ticket !== authAttempt) throw stale();
if (!data.token || !data.user || !data.user.id || (guest && data.user.kind !== 'taobao_guest')) throw new Error('未能建立检测会话,请重试');
clearTransient(); sessionVersion++; validationEpoch++; needsValidation = false;
token = data.token; user = data.user;
my.setStorageSync({ key: storageKey, data: { token: token, user: user } });
if (guest) { try { my.removeStorageSync({ key: storageKey }); } catch (_) {} }
else my.setStorageSync({ key: storageKey, data: { token: token, user: user } });
return user;
});
}
function login(consent) { return startSession(consent, false); }
function loginGuest(consent) { return startSession(consent, true); }
function restore() {
try { var saved = my.getStorageSync({ key: storageKey }).data; if (saved) { token = saved.token || ''; user = saved.user || null; } } catch (_) {}
// A product-detail round trip must preserve the current in-memory guest/report.
if (isGuest()) return Promise.resolve(user);
if (restoring) return restoring;
if (!token) {
try {
var saved = my.getStorageSync({ key: storageKey }).data;
if (saved && saved.user && saved.user.kind === 'taobao_guest') { my.removeStorageSync({ key: storageKey }); return Promise.resolve(null); }
if (saved && saved.token) { token = saved.token; user = null; sessionVersion++; needsValidation = true; }
} catch (_) {}
}
if (!token) return Promise.resolve(null);
var restoredToken = token;
return request('me').then(function (data) { if (token === restoredToken) user = data.user; return user; });
needsValidation = true;
var version = sessionVersion, epoch = validationEpoch;
restoring = request('me').then(function (data) {
if (version !== sessionVersion || epoch !== validationEpoch) throw stale();
if (!data.user || !data.user.id || data.user.kind === 'taobao_guest') { clear(); return null; }
user = data.user; needsValidation = false; return user;
});
var result = restoring;
return result.then(function (value) { if (restoring === result) restoring = null; return value; },
function (err) { if (restoring === result) restoring = null; throw err; });
}
function canceled(signal) { if (signal && signal.canceled) { var e = new Error('已取消检测'); e.canceled = true; throw e; } }
function canceled(signal) { if (signal && signal.canceled) { var e = new Error('已停止等待'); e.canceled = true; throw e; } }
function wait(ms, signal) { return new Promise(function (resolve) { setTimeout(resolve, ms); }).then(function () { canceled(signal); }); }
function poll(job, signal, deadline) {
canceled(signal);
if (Date.now() >= deadline) throw new Error('检测仍在处理中,请稍后从历史报告查看');
if (Date.now() >= deadline) throw new Error(isGuest()
? '检测仍在处理中,请留在本次体验中重试查看;游客报告不保存到历史。'
: '检测仍在处理中,请重试查看或稍后从历史报告查看');
return request('analysis', { job: job }).then(function (data) {
canceled(signal);
if (data.status !== 'complete') return wait(1000, signal).then(function () { return poll(job, signal, deadline); });
@@ -63,27 +120,83 @@ function upload(view, photo, signal) {
})(i);
return chain.then(function () { return id; });
}
function analyze(photos, profile, signal) {
function createAnalysisAttempt() {
return { requestId: 'tb-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2),
ownerVersion: sessionVersion, job: '', status: 'ready', photoRefs: {}, photos: null, profile: null };
}
function runAttempt(attempt, signal) {
if (attempt.ownerVersion !== sessionVersion || !hasSession()) return Promise.reject(stale());
canceled(signal);
if (attempt.running) return attempt.running.catch(function () {}).then(function () { return runAttempt(attempt, signal); });
var operation;
if (attempt.job) operation = Promise.resolve();
else if (!attempt.profile) return Promise.reject(new Error('没有可恢复的检测任务,请重新拍摄'));
else {
attempt.status = 'uploading';
operation = ['front', 'left', 'right'].reduce(function (chain, view, i) {
return chain.then(function () {
if (attempt.photoRefs[view]) return;
if (!attempt.photos || !attempt.photos[i]) throw new Error('检测照片不完整,请重新拍摄');
return upload(view, attempt.photos[i], signal).then(function (id) { attempt.photoRefs[view] = id; });
});
}, Promise.resolve()).then(function () {
canceled(signal); attempt.status = 'submitting';
return request('skin-analyze', { photo_refs: attempt.photoRefs, profile: attempt.profile, request_id: attempt.requestId });
}).then(function (data) {
attempt.job = data.job; attempt.photos = null;
if (!attempt.job) throw new Error('未能取得检测任务,请重试');
});
}
var running = operation.then(function () {
canceled(signal); attempt.status = 'polling';
return poll(attempt.job, signal, Date.now() + 90000);
}).then(function (payload) {
attempt.status = 'complete';
if (pendingAnalysis === attempt) pendingAnalysis = null;
return payload;
}, function (err) {
attempt.status = err.canceled ? 'paused' : 'failed';
if (err.status === 404 || err.status === 410) { err.message = '本次检测任务已失效,请重新拍摄;不会自动重复检测'; attempt.status = 'expired'; }
throw err;
});
attempt.running = running;
return running.then(function (value) { if (attempt.running === running) attempt.running = null; return value; },
function (err) { if (attempt.running === running) attempt.running = null; throw err; });
}
function analyze(photos, profile, signal, attempt) {
if (config.mock) {
var SS = require('../vendor/skin-score'), UI = require('../vendor/report-ui');
var vm = SS.buildViewModel(SS.MOCK_RESULT, SS.MOCK_SIDE, { realAge: profile.real_age, skinType: profile.daily_skin_type });
return Promise.resolve({ ok: true, result: SS.MOCK_RESULT, side: SS.MOCK_SIDE, vm: vm, presentation: UI.build(vm, SS.MOCK_RESULT) });
}
var refs = {};
return ['front', 'left', 'right'].reduce(function (chain, view, i) {
return chain.then(function () { return upload(view, photos[i], signal); }).then(function (id) { refs[view] = id; });
}, Promise.resolve()).then(function () {
canceled(signal); return request('skin-analyze', { photo_refs: refs, profile: profile });
}).then(function (data) { return poll(data.job, signal, Date.now() + 90000); });
attempt = attempt || createAnalysisAttempt();
if (!attempt.profile) { attempt.profile = Object.assign({}, profile); attempt.photos = photos.slice(); }
pendingAnalysis = attempt;
return Promise.resolve().then(function () { return runAttempt(attempt, signal); });
}
function resumeAnalysis(attempt, signal) {
attempt = attempt || pendingAnalysis;
if (!attempt || attempt.status === 'expired') return Promise.reject(new Error('本次检测任务已失效,请重新拍摄'));
return Promise.resolve().then(function () { return runAttempt(attempt, signal); });
}
function wrinkleViews(jobToken, signal) {
return request('wrinkle-views', { token: jobToken }).then(function (data) { return poll(data.job, signal, Date.now() + 90000); });
}
function logout() {
return request('logout').catch(function (err) {
// An expired/revoked session is already signed out; network failures still surface.
if (err.status !== 401) throw err;
}).then(clear);
if (!token) { clear(); return Promise.resolve(); }
return request('logout').catch(function (err) { if (err.status !== 401) throw err; }).then(function () { clear(); });
}
module.exports = { assetUrl: assetUrl, analyze: analyze, login: login, restore: restore, request: request,
user: function () { return user; }, logout: logout, wrinkleViews: wrinkleViews };
function endGuest() {
if (!isGuest()) return Promise.resolve();
var oldToken = token;
clear();
try { Promise.resolve(transport.request('/api/taobao/logout', {}, oldToken)).catch(function () {}); } catch (_) {}
return Promise.resolve();
}
module.exports = { assetUrl: assetUrl, analyze: analyze, login: login, loginGuest: loginGuest, restore: restore, request: request,
user: function () { return user; }, hasSession: hasSession, isGuest: isGuest, isSignedIn: isSignedIn,
logout: logout, endGuest: endGuest, resetForLaunch: resetForLaunch, suspendSession: suspendSession, clear: clear,
createAnalysisAttempt: createAnalysisAttempt, resumeAnalysis: resumeAnalysis,
getPendingAnalysis: function () { return pendingAnalysis; },
discardAnalysis: function (attempt) { if (!attempt || pendingAnalysis === attempt) pendingAnalysis = null; },
wrinkleViews: wrinkleViews };