diff --git a/docs/扫描等待30秒兜底-2026-10-16.md b/docs/扫描等待30秒兜底-2026-10-16.md new file mode 100644 index 0000000..70d7597 --- /dev/null +++ b/docs/扫描等待30秒兜底-2026-10-16.md @@ -0,0 +1,9 @@ +# 扫描等待页 30 秒兜底 + +从三张照片完成、打开扫描等待页开始计时,最多等待 30 秒。超时关闭扫描页、停止相机与播报、清空本轮照片、解锁操作,提示「检测服务开小差了,请重试」,返回档案页,由用户再次开始拍照。 + +独立页面看门狗覆盖网络未返回及扫描动画未完成;回调也检查实际截止时间,避免定时器延迟时误显示迟到报告。请求自身超时同步设为 30 秒,无 AbortController 的旧内核也会结束等待。成功、主动取消或页面隐藏时清理定时器并尽可能中止请求;超时先作废流程令牌,迟到结果不能切回报告页。 + +超时记入已有拍摄漏斗「明确失败」,不新增自动重试或检测调用。中止浏览器请求不保证已受理的服务端/上游调用停止,后台仍可能完成该次检测,且可能产生原有调用费用;服务端检测预算未更改。相比旧版 150 秒前端请求预算,超过 30 秒的慢检测现在也会提示重拍。 + +验证涵盖 29.999 秒成功、30 秒未返回、接口成功但动画不结束、迟到结果、取消、旧内核缺少 AbortController、成功后不再弹超时及定时器清理。 diff --git a/h5/js/app.js b/h5/js/app.js index a8c0a52..d4604df 100644 --- a/h5/js/app.js +++ b/h5/js/app.js @@ -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 值) ─── */ diff --git a/h5/test/analyze-timeout.js b/h5/test/analyze-timeout.js index ecf2e5c..8362569 100644 --- a/h5/test/analyze-timeout.js +++ b/h5/test/analyze-timeout.js @@ -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; }); diff --git a/h5/test/scan-wait-timeout.js b/h5/test/scan-wait-timeout.js new file mode 100644 index 0000000..a44d634 --- /dev/null +++ b/h5/test/scan-wait-timeout.js @@ -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;});