fix(camera): add 30-second scan waiting watchdog
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
# 扫描等待页 30 秒兜底
|
||||
|
||||
从三张照片完成、打开扫描等待页开始计时,最多等待 30 秒。超时关闭扫描页、停止相机与播报、清空本轮照片、解锁操作,提示「检测服务开小差了,请重试」,返回档案页,由用户再次开始拍照。
|
||||
|
||||
独立页面看门狗覆盖网络未返回及扫描动画未完成;回调也检查实际截止时间,避免定时器延迟时误显示迟到报告。请求自身超时同步设为 30 秒,无 AbortController 的旧内核也会结束等待。成功、主动取消或页面隐藏时清理定时器并尽可能中止请求;超时先作废流程令牌,迟到结果不能切回报告页。
|
||||
|
||||
超时记入已有拍摄漏斗「明确失败」,不新增自动重试或检测调用。中止浏览器请求不保证已受理的服务端/上游调用停止,后台仍可能完成该次检测,且可能产生原有调用费用;服务端检测预算未更改。相比旧版 150 秒前端请求预算,超过 30 秒的慢检测现在也会提示重拍。
|
||||
|
||||
验证涵盖 29.999 秒成功、30 秒未返回、接口成功但动画不结束、迟到结果、取消、旧内核缺少 AbortController、成功后不再弹超时及定时器清理。
|
||||
+41
-15
@@ -173,6 +173,14 @@
|
||||
|
||||
/* ─── 弹窗管理 ─── */
|
||||
var SCAN_CYCLE_MS = 1000, scanCycle = null, scanMask = null;
|
||||
var SCAN_WAIT_TIMEOUT_MS = 30000, analysisWait = null;
|
||||
function stopAnalysisWait() {
|
||||
var wait = analysisWait;
|
||||
analysisWait = null;
|
||||
if (!wait) return;
|
||||
clearTimeout(wait.timer);
|
||||
if (typeof wait.cancel === 'function') wait.cancel();
|
||||
}
|
||||
function restartScanCycle() {
|
||||
if (!scanCycle || scanCycle.done) return;
|
||||
var overlay = $('#p-analyzing'), cycle = scanCycle;
|
||||
@@ -236,7 +244,7 @@
|
||||
}
|
||||
function closeOverlay(id) {
|
||||
$('#' + id).classList.remove('show');
|
||||
if (id === 'p-analyzing') stopScanCycle();
|
||||
if (id === 'p-analyzing') { stopAnalysisWait(); stopScanCycle(); }
|
||||
}
|
||||
|
||||
// 点遮罩空白处关闭(与各自关闭按钮同行为:隐私→回启动页、拍照引导→留在档案页)
|
||||
@@ -1137,6 +1145,20 @@
|
||||
camStep = 0;
|
||||
photos = photosByView(photos);
|
||||
openOverlay('p-analyzing');
|
||||
var wait = { deadline: Date.now() + SCAN_WAIT_TIMEOUT_MS, timer: null, cancel: null };
|
||||
analysisWait = wait;
|
||||
function expireWait() {
|
||||
if (analysisWait !== wait || seq !== camSeq) return;
|
||||
trackCapture('failed');
|
||||
camSeq++; // Invalidate request/animation callbacks BEFORE resolving/aborting them.
|
||||
closeOverlay('p-analyzing');
|
||||
stopCamera(); voice.stop(); shutterLock = false;
|
||||
photos = []; captureDiagnostics = []; camStep = 0;
|
||||
toast('检测服务开小差了,请重试');
|
||||
showScreen('s-profile');
|
||||
popHistorySilently();
|
||||
}
|
||||
wait.timer = setTimeout(expireWait, SCAN_WAIT_TIMEOUT_MS);
|
||||
voice.play(VOICE.analyzing); // 「正在生成您的肌肤报告」
|
||||
// 档案与检测一起上报(后台记录用):年龄非法值服务端按 null 处理
|
||||
var profile = {
|
||||
@@ -1146,13 +1168,15 @@
|
||||
skinType: SKINS[skinIndex].name
|
||||
};
|
||||
var animationComplete = scanCycle.promise;
|
||||
requestAnalyze(photos, seq, profile, function (errMsg, payload) {
|
||||
wait.cancel = requestAnalyze(photos, seq, profile, function (errMsg, payload) {
|
||||
if (seq !== camSeq) return; // 流程已被重置:丢弃
|
||||
if (Date.now() >= wait.deadline) { expireWait(); return; }
|
||||
trackCapture(errMsg ? 'failed' : 'response');
|
||||
// A fast successful response waits for one actual visible animation cycle;
|
||||
// cancellation/errors never get held hostage by the presentation animation.
|
||||
(errMsg ? Promise.resolve() : animationComplete).then(function () {
|
||||
if (seq !== camSeq) return;
|
||||
if (Date.now() >= wait.deadline) { expireWait(); return; }
|
||||
closeOverlay('p-analyzing');
|
||||
stopCamera();
|
||||
voice.stop(); // 结果已出:排队中的「分析中」等不再补播
|
||||
@@ -1208,11 +1232,11 @@
|
||||
}
|
||||
|
||||
/* ─── 分析请求(mock=1 直返内置样本;有 MOCK_FIXTURE 时用报告页可视化 fixture,便于离线/自动化验证) ─── */
|
||||
// 后端最多请求两次(各 60s),中间等待 4s;另留上传、落盘和响应传输余量。
|
||||
var ANALYZE_TIMEOUT_MS = 150000;
|
||||
// Request timeout complements the independent 30s presentation watchdog.
|
||||
var ANALYZE_TIMEOUT_MS = 30000;
|
||||
function requestAnalyze(ph, seq, profile, done) {
|
||||
if (IS_MOCK && typeof SkinScore !== 'undefined') {
|
||||
setTimeout(function () {
|
||||
var mockTimer = setTimeout(function () {
|
||||
if (seq !== camSeq) return; // mock 同样尊重取消/重置
|
||||
var fx = window.MOCK_FIXTURE; // assets/mock/report-fixture.js(仅 mock 模式注入)
|
||||
if (fx) {
|
||||
@@ -1223,10 +1247,15 @@
|
||||
done(null, { result: SkinScore.MOCK_RESULT, side: SkinScore.MOCK_SIDE });
|
||||
}
|
||||
}, 900);
|
||||
return;
|
||||
return function () { clearTimeout(mockTimer); };
|
||||
}
|
||||
var ctrl = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
||||
var timer = setTimeout(function () { if (ctrl) ctrl.abort(); }, ANALYZE_TIMEOUT_MS);
|
||||
var completed = false;
|
||||
function finish(error, payload) { if (completed) return; completed = true; clearTimeout(timer); if (seq === camSeq) done(error, payload); }
|
||||
var timer = setTimeout(function () {
|
||||
finish('检测服务开小差了,请重试');
|
||||
if (ctrl) ctrl.abort();
|
||||
}, ANALYZE_TIMEOUT_MS);
|
||||
fetch('api/skin-analyze', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -1245,17 +1274,14 @@
|
||||
signal: ctrl ? ctrl.signal : undefined // 老内核无 AbortController:仅靠令牌丢弃迟到响应
|
||||
}).then(function (res) { return res.json(); })
|
||||
.then(function (data) {
|
||||
clearTimeout(timer);
|
||||
if (seq !== camSeq) return; // 流程已被重置,丢弃过期响应
|
||||
if (!data.ok) done(data.message || '检测失败,请重试');
|
||||
else done(null, data);
|
||||
if (!data.ok) finish(data.message || '检测失败,请重试');
|
||||
else finish(null, data);
|
||||
})
|
||||
.catch(function (err) {
|
||||
clearTimeout(timer);
|
||||
if (seq !== camSeq) return;
|
||||
if (err && err.name === 'AbortError') done('检测服务开小差了,请稍候重试');
|
||||
else done('网络异常,请检查网络后重试');
|
||||
if (err && err.name === 'AbortError') finish('检测服务开小差了,请重试');
|
||||
else finish('网络异常,请检查网络后重试');
|
||||
});
|
||||
return function () { completed = true; clearTimeout(timer); if (ctrl) ctrl.abort(); };
|
||||
}
|
||||
|
||||
/* ─── SkinScore viewModel → 报告渲染(评分公式见 js/skin-score.js,不直接向用户展示 P 值) ─── */
|
||||
|
||||
@@ -8,9 +8,9 @@ const source = fs.readFileSync(path.join(__dirname, '../js/app.js'), 'utf8');
|
||||
const start = source.indexOf(' var ANALYZE_TIMEOUT_MS =');
|
||||
const end = source.indexOf(' /* ─── SkinScore viewModel', start);
|
||||
assert(start >= 0 && end > start);
|
||||
function setup() {
|
||||
let timer, complete, signal, result;
|
||||
const ctx = { IS_MOCK: false, camSeq: 1, AbortController, CaptureQuality: require('../js/capture-quality'), captureDiagnostics: [],
|
||||
function setup(withAbort = true) {
|
||||
let timer, complete, signal, result, calls = 0;
|
||||
const ctx = { IS_MOCK: false, camSeq: 1, AbortController: withAbort ? AbortController : undefined, CaptureQuality: require('../js/capture-quality'), captureDiagnostics: [],
|
||||
setTimeout(fn, delay) { timer = { fn, delay }; return 1; },
|
||||
clearTimeout() { timer = null; },
|
||||
fetch(url, options) {
|
||||
@@ -18,21 +18,20 @@ function setup() {
|
||||
signal = options.signal;
|
||||
return new Promise((resolve, reject) => {
|
||||
complete = () => resolve({ json: async () => ({ ok: true, report_saved: true }) });
|
||||
signal.addEventListener('abort', () => { const e = new Error('aborted'); e.name = 'AbortError'; reject(e); });
|
||||
if (signal) signal.addEventListener('abort', () => { const e = new Error('aborted'); e.name = 'AbortError'; reject(e); });
|
||||
});
|
||||
}
|
||||
};
|
||||
vm.createContext(ctx);
|
||||
vm.runInContext(source.slice(start, end), ctx);
|
||||
ctx.requestAnalyze(['front', 'left', 'right'], 1, {}, (...args) => { result = args; });
|
||||
const cancel = ctx.requestAnalyze(['front', 'left', 'right'], 1, {}, (...args) => { result = args; calls++; });
|
||||
return { ctx, get timer() { return timer; }, get signal() { return signal; },
|
||||
get result() { return result; }, finish() { complete(); } };
|
||||
get result() { return result; }, get calls() { return calls; }, cancel, finish() { complete(); } };
|
||||
}
|
||||
const flush = () => new Promise(resolve => setImmediate(resolve));
|
||||
(async () => {
|
||||
const delayed = setup();
|
||||
assert.equal(delayed.timer.delay, 150000);
|
||||
assert(delayed.timer.delay > 60000 * 2 + 4000, 'Allow both backend attempts plus retry wait');
|
||||
assert.equal(delayed.timer.delay, 30000);
|
||||
delayed.finish();
|
||||
await flush();
|
||||
assert.equal(delayed.result[0], null);
|
||||
@@ -44,11 +43,18 @@ const flush = () => new Promise(resolve => setImmediate(resolve));
|
||||
await flush();
|
||||
assert(expired.signal.aborted);
|
||||
assert.match(expired.result[0], /检测服务开小差/);
|
||||
assert.equal(expired.calls, 1);
|
||||
const legacy = setup(false);
|
||||
legacy.timer.fn();
|
||||
assert.equal(legacy.result[0], '检测服务开小差了,请重试');
|
||||
legacy.finish();await flush();assert.equal(legacy.calls,1,'late success never replaces timeout');
|
||||
const aborted = setup();aborted.cancel();await flush();
|
||||
assert(aborted.signal.aborted);assert.equal(aborted.result,undefined);assert.equal(aborted.timer,null);
|
||||
const canceled = setup();
|
||||
canceled.ctx.camSeq++;
|
||||
canceled.finish();
|
||||
await flush();
|
||||
assert.equal(canceled.result, undefined);
|
||||
assert.equal(canceled.timer, null);
|
||||
console.log('PASS: frontend timeout covers backend retry budget; success, timeout, stale response, timer cleanup');
|
||||
console.log('PASS: 30s request timeout, legacy no-AbortController fallback, single completion, cancellation, late response suppression and timer cleanup');
|
||||
})().catch(e => { console.error(e); process.exitCode = 1; });
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
'use strict';
|
||||
const assert=require('node:assert/strict'),fs=require('node:fs'),vm=require('node:vm');
|
||||
const source=fs.readFileSync(require('node:path').join(__dirname,'../js/app.js'),'utf8');
|
||||
const helpers=source.slice(source.indexOf(' var SCAN_WAIT_TIMEOUT_MS'),source.indexOf(' function restartScanCycle('));
|
||||
const submit=source.slice(source.indexOf(' function submitPhotos('),source.indexOf(' function shutter()'));
|
||||
function setup() {
|
||||
let now=0, next=0, done, resolveAnimation, aborted=0, stopped=0, rendered=0;
|
||||
const timers=new Map(),messages=[],events=[];
|
||||
const nodes={'#f-age':{value:30},'#f-gender':{value:'女'},'#p-analyzing':{show:false},'#s-report':{classList:{contains:()=>ctx.currentScreen==='s-report'}}};
|
||||
const ctx={Date:{now:()=>now},Promise,console,camSeq:1,camStep:2,photos:['a','b','c'],captureDiagnostics:[],shutterLock:true,currentScreen:'s-cam',
|
||||
setTimeout(fn,ms){const id=++next;timers.set(id,{fn,ms});return id;},clearTimeout(id){timers.delete(id);},
|
||||
$:s=>nodes[s],photosByView:p=>p,openOverlay(){nodes['#p-analyzing'].show=true;ctx.scanCycle={promise:new Promise(r=>{resolveAnimation=r;})};},
|
||||
closeOverlay(){nodes['#p-analyzing'].show=false;ctx.stopAnalysisWait();resolveAnimation();},
|
||||
voice:{play(){},stop(){}},VOICE:{analyzing:'a',fail:'f'},regionSel:{value:'上海'},SKINS:[{name:'中性'}],skinIndex:0,
|
||||
trackCapture:s=>events.push(s),stopCamera(){stopped++;},toast:s=>messages.push(s),showScreen:s=>{ctx.currentScreen=s;},popHistorySilently(){},
|
||||
requestAnalyze(p,s,profile,cb){done=cb;return ()=>{aborted++;};},
|
||||
SkinScore:{buildViewModel:()=>({})},renderReport(){rendered++;},IS_MOCK:false,AccountClient:{user:()=>({kind:'wechat'})}};
|
||||
vm.createContext(ctx);vm.runInContext(helpers+submit,ctx);ctx.submitPhotos(1);
|
||||
return {ctx,timers,messages,events,advance(ms,fire=true){now=ms;if(fire){const jobs=[...timers.values()];jobs.forEach(j=>j.fn());}},respond(error=null){done(error,{vm:{},report_saved:true});},animation(){resolveAnimation();},get aborted(){return aborted;},get rendered(){return rendered;},get stopped(){return stopped;}};
|
||||
}
|
||||
const flush=()=>new Promise(r=>setImmediate(r));
|
||||
(async()=>{
|
||||
const pending=setup();assert.equal([...pending.timers.values()][0].ms,30000);
|
||||
pending.advance(30000);await flush();assert.equal(pending.ctx.currentScreen,'s-profile');assert.equal(pending.messages[0],'检测服务开小差了,请重试');assert.equal(pending.ctx.shutterLock,false);assert.equal(pending.ctx.photos.length,0);assert.equal(pending.aborted,1);assert.equal(pending.timers.size,0);
|
||||
pending.respond();await flush();assert.equal(pending.rendered,0);assert.equal(pending.messages.length,1);
|
||||
const stuck=setup();stuck.respond();await flush();assert.equal(stuck.ctx.currentScreen,'s-cam');stuck.advance(30000);await flush();assert.equal(stuck.ctx.currentScreen,'s-profile');assert.equal(stuck.rendered,0);
|
||||
const success=setup();success.advance(29999,false);success.respond();success.animation();await flush();assert.equal(success.ctx.currentScreen,'s-report');assert.equal(success.timers.size,0);assert.equal(success.messages.length,0);assert.equal(success.rendered,1);
|
||||
const late=setup();late.advance(31000,false);late.respond();await flush();assert.equal(late.ctx.currentScreen,'s-profile');assert.equal(late.rendered,0,'deadline checked even before delayed timer executes');
|
||||
const cancel=setup();cancel.ctx.camSeq++;cancel.ctx.closeOverlay();cancel.advance(30000);cancel.respond();await flush();assert.equal(cancel.messages.length,0);assert.equal(cancel.timers.size,0);assert.equal(cancel.rendered,0);
|
||||
console.log('PASS: scan page 30s watchdog covers pending network and stuck animation, success cleanup, late callbacks, legacy timing and cancellation');
|
||||
})().catch(e=>{console.error(e);process.exitCode=1;});
|
||||
Reference in New Issue
Block a user