trustbase-seller-backend 0.2.6 → 0.2.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/routes/onboard.html +133 -85
- package/routes/onboard.js +7 -5
- package/test-onboard-applyment.js +7 -0
- package/test-onboard-backup.js +18 -8
package/package.json
CHANGED
package/routes/onboard.html
CHANGED
|
@@ -70,6 +70,16 @@ h1{font-size:22px;margin-bottom:4px}
|
|
|
70
70
|
#apply-form div[id^="a_up_"] label.sbtn::after{content:'支持 jpg/png, 本机压缩后上传';font-size:11px;color:#BDB5A6;font-weight:400;border:none;background:none;padding:0}
|
|
71
71
|
#apply-form div[id^="a_up_"] label.sbtn:hover{border-color:#C4633C;background:#FBF1EA}
|
|
72
72
|
#apply-form div[id^="a_up_"] span[id^="st_"]{display:block;font-size:12px;margin-top:6px}
|
|
73
|
+
/* 上传槽已传态 (2026-10-03 二重框修复): 缩略图占满卡片 + 文件名 + 替换/删除 */
|
|
74
|
+
#apply-form .upfilled .auprev{display:block;width:100%;max-height:150px;object-fit:cover;border-radius:8px;border:1px solid #EAE3D6;margin-bottom:6px}
|
|
75
|
+
#apply-form .upph{background:#F0FDF4;border:1px solid #BBF7D0;color:#166534;border-radius:8px;padding:20px 10px;font-size:14px;font-weight:600;text-align:center;margin-bottom:6px}
|
|
76
|
+
#apply-form .upph .upph-sub{font-size:11px;font-weight:400;color:#4D7C5F;margin-top:2px}
|
|
77
|
+
#apply-form .upstat{display:block;font-size:12px;margin:4px 0 6px}
|
|
78
|
+
#apply-form .upops{display:flex;gap:8px}
|
|
79
|
+
#apply-form .upop{flex:1;text-align:center;font-size:12px;color:#C4633C;border:1px solid #C4633C;border-radius:6px;padding:5px 8px;background:#fff;cursor:pointer}
|
|
80
|
+
#apply-form .upop:hover{background:#F7EDE6}
|
|
81
|
+
#apply-form .upop.updel{color:#DC2626;border-color:#FCA5A5}
|
|
82
|
+
#apply-form .upop.updel:hover{background:#FEF2F2}
|
|
73
83
|
img.auprev{display:none;width:100%;max-height:140px;object-fit:cover;border-radius:8px;margin-bottom:8px;border:1px solid #EAE3D6}
|
|
74
84
|
.auname{font-size:11px;color:#8A857B;margin:-2px 0 6px;word-break:break-all}
|
|
75
85
|
/* 提交按钮 + 隐私行 */
|
|
@@ -148,9 +158,8 @@ img.auprev{display:none;width:100%;max-height:140px;object-fit:cover;border-radi
|
|
|
148
158
|
<div id="restore-msg" style="font-size:13px;margin-top:6px"></div>
|
|
149
159
|
</div>
|
|
150
160
|
<div id="backup-quiz" style="display:none;margin-top:10px;background:#FCFAF4;border:1px solid #EAE3D6;border-radius:12px;padding:14px 16px">
|
|
151
|
-
<div class="desc" style="margin-bottom:4px">确认你真的抄好了 —
|
|
161
|
+
<div class="desc" style="margin-bottom:4px">确认你真的抄好了 — 分 3 步, 一次填一个词 (答错不会提示正确答案):</div>
|
|
152
162
|
<div id="backup-questions"></div>
|
|
153
|
-
<button class="sbtn primary" style="margin-top:10px" onclick="checkBackupVerify()">提交验证</button>
|
|
154
163
|
</div>
|
|
155
164
|
<div id="backup-result"></div>
|
|
156
165
|
</div>
|
|
@@ -416,47 +425,74 @@ function renderProgress(s){
|
|
|
416
425
|
// 当前可做项高亮 (备份验证已过 → 持久打勾 (后端 tb_backup_ack 台账); 否则标当前可做)
|
|
417
426
|
if(s.backup_acked){ markStepDone('st-backup'); } else { document.getElementById('st-backup').className='step current'; }
|
|
418
427
|
}
|
|
419
|
-
// ----
|
|
420
|
-
//
|
|
421
|
-
//
|
|
422
|
-
|
|
428
|
+
// ---- 店铺安全卡回填验证 (2026-10-03, MetaMask 式抽词; 原则: 安全词不出本机) ----
|
|
429
|
+
// 分步式 (xiaomu 拍板): 抽 3 词位, 一次只问一个词, 每步单独验证 (即时反馈, 答错不累积)。
|
|
430
|
+
// 首次创建 (安全词还在页面内存 _mn) → 纯前端比对, 不调网络;
|
|
431
|
+
// 页面刷新后 → POST /api/onboard/verify-backup 单元素 positions (端点 ≤5 支持), 本机后端比对。
|
|
432
|
+
var _bq={positions:[],idx:0,wrong:0};
|
|
423
433
|
function startBackupVerify(){
|
|
424
434
|
// 随机抽 3 个词位 (1-24 不重复, 升序展示)
|
|
425
435
|
var pool=[]; for(var i=1;i<=24;i++) pool.push(i);
|
|
426
436
|
var pos=[];
|
|
427
437
|
while(pos.length<3){ pos.push(pool.splice(Math.floor(Math.random()*pool.length),1)[0]); }
|
|
428
438
|
pos.sort(function(a,b){return a-b;});
|
|
429
|
-
|
|
430
|
-
q.innerHTML=pos.map(function(p){
|
|
431
|
-
return '<label style="display:block;font-size:13px;color:#6B655B;margin:8px 0 4px">请按安全卡上抄写的填写第 '+p+' 个词</label>'
|
|
432
|
-
+'<input class="bq-word" data-pos="'+p+'" placeholder="第 '+p+' 个词" autocapitalize="off" autocomplete="off" spellcheck="false" style="width:100%;padding:10px 12px;border:1px solid #D4CDBF;border-radius:8px;font-size:15px;background:#fff">';
|
|
433
|
-
}).join('');
|
|
439
|
+
_bq={positions:pos,idx:0,wrong:0};
|
|
434
440
|
document.getElementById('backup-result').innerHTML='';
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
box.scrollIntoView({behavior:'smooth',block:'center'});
|
|
441
|
+
document.getElementById('backup-quiz').style.display='block';
|
|
442
|
+
renderBqStep();
|
|
438
443
|
}
|
|
439
|
-
|
|
440
|
-
var
|
|
441
|
-
var
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
444
|
+
function renderBqStep(){
|
|
445
|
+
var p=_bq.positions[_bq.idx];
|
|
446
|
+
var q=document.getElementById('backup-questions');
|
|
447
|
+
q.innerHTML='<div style="font-size:12px;color:#8A857B;margin-bottom:6px">第 '+(_bq.idx+1)+'/3 步</div>'
|
|
448
|
+
+'<label style="display:block;font-size:13px;color:#6B655B;margin:0 0 4px">请按安全卡上抄写的填写第 '+p+' 个词</label>'
|
|
449
|
+
+'<input class="bq-word" id="bq-input" data-pos="'+p+'" placeholder="第 '+p+' 个词" autocapitalize="off" autocomplete="off" spellcheck="false" style="width:100%;padding:12px;font-size:16px;border:1px solid #D4CDBF;border-radius:8px;background:#fff">'
|
|
450
|
+
+'<div style="display:flex;gap:8px;margin-top:10px">'
|
|
451
|
+
+'<button class="sbtn primary" style="flex:1;padding:12px;font-size:15px" onclick="checkBqStep()">'+(_bq.idx<2?'下一步':'完成验证')+'</button>'
|
|
452
|
+
+'<button class="sbtn" onclick="startBackupVerify()">返回重抽</button></div>'
|
|
453
|
+
+(_bq.wrong>=3?'<div style="margin-top:8px"><button class="sbtn" onclick="swapBqPosition()">这个词位填不对? 换一个词位</button></div>':'');
|
|
454
|
+
var inp=document.getElementById('bq-input');
|
|
455
|
+
inp.focus();
|
|
456
|
+
inp.onkeydown=function(e){ if(e.key==='Enter') checkBqStep(); };
|
|
457
|
+
}
|
|
458
|
+
async function checkBqStep(){
|
|
459
|
+
var inp=document.getElementById('bq-input');
|
|
460
|
+
var w=(inp.value||'').trim();
|
|
461
|
+
if(!w) return backupResult(false,'请先填写这个词');
|
|
462
|
+
var p=Number(inp.getAttribute('data-pos'));
|
|
463
|
+
var correct=false;
|
|
464
|
+
// ① 内存路径: 安全词还在 (_mn) → 纯前端比对 (更快, 零网络)
|
|
445
465
|
if(_mn){
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
466
|
+
correct = _mn.split(/\s+/)[p-1].toLowerCase()===w.toLowerCase();
|
|
467
|
+
}else{
|
|
468
|
+
// ② 后端路径: 刷新后内存没有 → 本机后端比对 (安全词不出本机, 只送位置+词)
|
|
469
|
+
try{
|
|
470
|
+
var j=await readJson(await fetch('/api/onboard/verify-backup',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({positions:[p],words:[w]})}));
|
|
471
|
+
if(j.ok) correct=true;
|
|
472
|
+
else if(j.error==='rate_limited') return backupResult(false, j.message||'操作太频繁, 请稍后再试');
|
|
473
|
+
else if(j.wrong_positions&&j.wrong_positions.length) correct=false;
|
|
474
|
+
else return backupResult(false, j.message||'验证失败, 请稍后再试');
|
|
475
|
+
}catch(e){ return backupResult(false,'服务暂时不可用,请刷新重试 ('+e.message+')'); }
|
|
476
|
+
}
|
|
477
|
+
if(correct){
|
|
478
|
+
_bq.idx++; _bq.wrong=0;
|
|
479
|
+
document.getElementById('backup-result').innerHTML='';
|
|
480
|
+
if(_bq.idx>=3){ await persistBackupAck(); return backupOk(); }
|
|
481
|
+
renderBqStep();
|
|
482
|
+
}else{
|
|
483
|
+
_bq.wrong++;
|
|
484
|
+
backupResult(false,'⚠️ 这个词不对 (不会提示正确答案), 请对照安全卡重新填');
|
|
485
|
+
if(_bq.wrong>=3) renderBqStep(); // 同一词位错 3 次 → 出现"换一个词位"
|
|
486
|
+
else { inp.value=''; inp.focus(); }
|
|
451
487
|
}
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
488
|
+
}
|
|
489
|
+
function swapBqPosition(){
|
|
490
|
+
// 换掉当前词位 (与其他 2 个不重复), 错次清零
|
|
491
|
+
var cand;
|
|
492
|
+
do{ cand=1+Math.floor(Math.random()*24); }while(_bq.positions.indexOf(cand)>=0);
|
|
493
|
+
_bq.positions[_bq.idx]=cand; _bq.wrong=0;
|
|
494
|
+
document.getElementById('backup-result').innerHTML='';
|
|
495
|
+
renderBqStep();
|
|
460
496
|
}
|
|
461
497
|
async function persistBackupAck(){
|
|
462
498
|
try{ await fetch('/api/onboard/backup-ack',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'}); }catch(e){}
|
|
@@ -465,14 +501,7 @@ function backupOk(){
|
|
|
465
501
|
document.getElementById('backup-quiz').style.display='none';
|
|
466
502
|
markStepDone('st-backup'); // 步骤②打勾 (后端台账已持久化, 换浏览器仍绿)
|
|
467
503
|
backupResult(true,'✅ 备份已确认 — 你的店铺安全卡只有这一份, 请收好');
|
|
468
|
-
|
|
469
|
-
}
|
|
470
|
-
function backupWrong(wrong){
|
|
471
|
-
_bqTries++;
|
|
472
|
-
var tip='⚠️ 第 '+wrong.join('、')+' 个词不对 — 请对照安全卡检查 (不会提示正确答案), 已重新抽题';
|
|
473
|
-
if(_bqTries>=3) tip='⚠️ 第 '+wrong.join('、')+' 个词不对; 已错 3 次, 建议点上方"重新打印安全卡"逐词对照后再试';
|
|
474
|
-
startBackupVerify(); // 重新抽题
|
|
475
|
-
backupResult(false, tip);
|
|
504
|
+
_bq={positions:[],idx:0,wrong:0};
|
|
476
505
|
}
|
|
477
506
|
function backupResult(ok, text){
|
|
478
507
|
document.getElementById('backup-result').innerHTML=
|
|
@@ -516,27 +545,67 @@ function shrinkImage(file,cb){
|
|
|
516
545
|
rd.onerror=function(){cb(new Error('文件读取失败'))};
|
|
517
546
|
rd.readAsDataURL(file);
|
|
518
547
|
}
|
|
519
|
-
//
|
|
548
|
+
// 照片上传位 (2026-10-03 二重框 bug 修复): 槽位双态收进本函数统一管理 —
|
|
549
|
+
// 空态: 只有虚线"拍照/选图"卡; 已传态: 缩略图(占满卡片)+文件名+状态行+替换/删除小按钮,
|
|
550
|
+
// 空态卡整体隐藏 (旧实现上传后虚线框不隐藏 + 增强脚本另插预览 → 二重框)。
|
|
551
|
+
// 预填路径 (media_id 复用): 无本地预览 → "已上传 ✓"占位 + 同款替换/删除 (不显示空框)。
|
|
552
|
+
var _aSlots={}; // field → {prefill, clear} (预填/场景切换用)
|
|
520
553
|
function mountApplyUpload(slotId,field,label){
|
|
521
554
|
var slot=document.getElementById(slotId);
|
|
522
555
|
slot.innerHTML='<label>'+label+'</label>'
|
|
523
|
-
+'<label class="sbtn" style="cursor:pointer">拍照/选图<input type="file" accept="image/*" style="display:none"></label>'
|
|
524
|
-
+'
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
556
|
+
+'<label class="sbtn upzone" style="cursor:pointer">拍照/选图<input type="file" accept="image/*" style="display:none"></label>'
|
|
557
|
+
+'<div class="upfilled" style="display:none">'
|
|
558
|
+
+'<img class="auprev" alt="预览" style="display:none">'
|
|
559
|
+
+'<div class="upph" style="display:none">已上传 ✓<div class="upph-sub">照片已传过, 无需重传</div></div>'
|
|
560
|
+
+'<div class="auname"></div>'
|
|
561
|
+
+'<div class="upstat" id="st_'+field+'"></div>'
|
|
562
|
+
+'<div class="upops">'
|
|
563
|
+
+'<label class="upop" style="cursor:pointer">替换<input type="file" accept="image/*" style="display:none"></label>'
|
|
564
|
+
+'<button type="button" class="upop updel">删除</button>'
|
|
565
|
+
+'</div></div>';
|
|
566
|
+
var zone=slot.querySelector('.upzone');
|
|
567
|
+
var filled=slot.querySelector('.upfilled');
|
|
568
|
+
var img=slot.querySelector('.auprev');
|
|
569
|
+
var ph=slot.querySelector('.upph');
|
|
570
|
+
var uname=slot.querySelector('.auname');
|
|
571
|
+
var stat=slot.querySelector('.upstat');
|
|
572
|
+
function toEmpty(){
|
|
573
|
+
zone.style.display=''; filled.style.display='none';
|
|
574
|
+
img.style.display='none'; img.removeAttribute('src');
|
|
575
|
+
ph.style.display='none'; uname.textContent=''; stat.textContent=''; stat.style.color='';
|
|
576
|
+
slot.querySelectorAll('input[type=file]').forEach(function(i){i.value='';});
|
|
577
|
+
delete _aMedia[field]; // 删除必须清 media_id, 否则删除后提交还带旧值
|
|
578
|
+
}
|
|
579
|
+
function toFilled(o){
|
|
580
|
+
zone.style.display='none'; filled.style.display='block'; // 空态卡整体隐藏 — 无二重框
|
|
581
|
+
if(o.previewUrl){ img.src=o.previewUrl; img.style.display='block'; ph.style.display='none'; }
|
|
582
|
+
else { img.style.display='none'; img.removeAttribute('src'); ph.style.display='block'; }
|
|
583
|
+
uname.textContent=o.name||'';
|
|
584
|
+
stat.style.color=o.err?'#DC2626':(o.stat&&o.stat.indexOf('✓')===0?'#16A34A':'');
|
|
585
|
+
stat.textContent=o.stat||'';
|
|
586
|
+
}
|
|
587
|
+
_aSlots[field]={
|
|
588
|
+
prefill:function(mediaId){ _aMedia[field]=mediaId; toFilled({stat:'✓ 已上传过, 无需重传'}); },
|
|
589
|
+
clear:toEmpty
|
|
590
|
+
};
|
|
591
|
+
function onFile(input){
|
|
528
592
|
if(!input.files||!input.files[0]) return;
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
593
|
+
var file=input.files[0];
|
|
594
|
+
var previewUrl=URL.createObjectURL(file); // 本地即时预览 (不依赖上传结果)
|
|
595
|
+
toFilled({previewUrl:previewUrl,name:file.name,stat:'压缩中...'});
|
|
596
|
+
shrinkImage(file,function(err,payload){
|
|
597
|
+
if(err){ toFilled({previewUrl:previewUrl,name:file.name,stat:err.message,err:true}); return; }
|
|
598
|
+
toFilled({previewUrl:previewUrl,name:file.name,stat:'上传中...'});
|
|
533
599
|
fetch('/api/onboard/applyment/upload',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)})
|
|
534
600
|
.then(readJson).then(function(j){
|
|
535
|
-
if(j.ok&&j.media_id){_aMedia[field]=j.media_id;
|
|
536
|
-
else{
|
|
537
|
-
}).catch(function(e){
|
|
601
|
+
if(j.ok&&j.media_id){ _aMedia[field]=j.media_id; toFilled({previewUrl:previewUrl,name:file.name,stat:'✓ 已上传'}); }
|
|
602
|
+
else toFilled({previewUrl:previewUrl,name:file.name,stat:'上传失败: '+(j.message||j.error||'未知错误'),err:true});
|
|
603
|
+
}).catch(function(e){ toFilled({previewUrl:previewUrl,name:file.name,stat:'网络错误: '+e.message,err:true}); });
|
|
538
604
|
});
|
|
539
|
-
}
|
|
605
|
+
}
|
|
606
|
+
zone.querySelector('input[type=file]').addEventListener('change',function(){ onFile(this); });
|
|
607
|
+
filled.querySelector('.upops input[type=file]').addEventListener('change',function(){ onFile(this); });
|
|
608
|
+
filled.querySelector('.updel').addEventListener('click',toEmpty);
|
|
540
609
|
}
|
|
541
610
|
mountApplyUpload('a_up_license','license_media_id','营业执照照片 (必传)');
|
|
542
611
|
mountApplyUpload('a_up_idfront','legal_id_front_media_id','身份证人像面 (必传)');
|
|
@@ -740,13 +809,13 @@ function prefillApplyForm(snap){
|
|
|
740
809
|
var e=document.getElementById(PREFILL_MAP[k]);
|
|
741
810
|
if(e&&!e.value) e.value=v;
|
|
742
811
|
}
|
|
743
|
-
// 照片复用: media_id
|
|
812
|
+
// 照片复用: media_id 还在微信侧有效 → 槽位走"已上传"双态 (占位图+替换/删除, 无第二个空框)
|
|
744
813
|
var reused=0;
|
|
745
814
|
PREFILL_MEDIA.forEach(function(k){
|
|
746
815
|
if(!snap[k]) return;
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
816
|
+
reused++;
|
|
817
|
+
if(_aSlots[k]) _aSlots[k].prefill(snap[k]);
|
|
818
|
+
else _aMedia[k]=snap[k]; // 槽未挂载兜底 (不应发生)
|
|
750
819
|
});
|
|
751
820
|
var msg=document.getElementById('apply-msg');
|
|
752
821
|
msg.style.display='block';
|
|
@@ -859,8 +928,8 @@ async function restoreIdentity(){
|
|
|
859
928
|
</script>
|
|
860
929
|
<script>
|
|
861
930
|
/* ===== ③ 进件表单 美化增强 (2026-10-03) —— 纯追加脚本: 不修改/不覆盖任何现有函数,
|
|
862
|
-
|
|
863
|
-
|
|
931
|
+
① 编码字段下拉+联动 ② 状态卡片着色 + 签约二维码占位。
|
|
932
|
+
(证件缩略图预览已于二重框修复时并入主脚本 mountApplyUpload 槽位双态) ===== */
|
|
864
933
|
(function(){
|
|
865
934
|
// ⓪ 编码字段下拉 (2026-10-03 xiaomu: 手填编码易被微信秒拒 → 下拉)
|
|
866
935
|
// 经营类目 ↔ 主体类型联动 (企业默认 716 / 个体户默认 719, 可手改)
|
|
@@ -916,30 +985,9 @@ async function restoreIdentity(){
|
|
|
916
985
|
linkSelectManual('a_bank_address_code','a_bankcode_manual','__MANUAL__');
|
|
917
986
|
linkSelectManual('a_store_address_code','a_storecode_manual','__MANUAL__');
|
|
918
987
|
|
|
919
|
-
// ①
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
form.addEventListener('change',function(ev){
|
|
923
|
-
var input=ev.target;
|
|
924
|
-
if(!input||input.type!=='file'||!input.files||!input.files[0]) return;
|
|
925
|
-
var slot=input.closest('div[id^="a_up_"]');
|
|
926
|
-
if(!slot) return;
|
|
927
|
-
var rd=new FileReader();
|
|
928
|
-
rd.onload=function(){
|
|
929
|
-
var img=slot.querySelector('img.auprev');
|
|
930
|
-
if(!img){
|
|
931
|
-
img=document.createElement('img');
|
|
932
|
-
img.className='auprev';img.alt='预览';
|
|
933
|
-
slot.insertBefore(img,slot.children[1]||null); // 标题label之后
|
|
934
|
-
}
|
|
935
|
-
img.src=rd.result;img.style.display='block';
|
|
936
|
-
var nm=slot.querySelector('.auname');
|
|
937
|
-
if(!nm){nm=document.createElement('div');nm.className='auname';img.insertAdjacentElement('afterend',nm);}
|
|
938
|
-
nm.textContent=input.files[0].name+' · 点卡片可重新选择';
|
|
939
|
-
};
|
|
940
|
-
rd.readAsDataURL(input.files[0]);
|
|
941
|
-
},true);
|
|
942
|
-
}
|
|
988
|
+
// ① 证件缩略图预览已并入 mountApplyUpload 槽位双态 (2026-10-03 二重框 bug 修复:
|
|
989
|
+
// 旧补丁在槽位里另插 img/文件名, 与上传后未隐藏的虚线框并存 → 二重框。本段已删除,
|
|
990
|
+
// 预览/替换/删除由主脚本 mountApplyUpload 统一渲染)。
|
|
943
991
|
// ② 状态卡片着色 + 签约二维码占位 (监听现有状态机的 DOM 更新, 不改其内容逻辑)
|
|
944
992
|
var box=document.getElementById('apply-status');
|
|
945
993
|
if(box){
|
package/routes/onboard.js
CHANGED
|
@@ -478,7 +478,7 @@ function createOnboardRouter(deps) {
|
|
|
478
478
|
db.prepare("INSERT INTO tb_backup_ack (id, acked_at) VALUES (1, ?) ON CONFLICT(id) DO UPDATE SET acked_at=excluded.acked_at")
|
|
479
479
|
.run(Math.floor(Date.now() / 1000));
|
|
480
480
|
}
|
|
481
|
-
const backupVerifyRate = new Map(); // ip →
|
|
481
|
+
const backupVerifyRate = new Map(); // ip → 30s 窗口内的尝试时间戳[] (分步式验证一步一调, 6 次/30s 足够 3 步 + 重试)
|
|
482
482
|
router.post("/verify-backup", (req, res) => {
|
|
483
483
|
try {
|
|
484
484
|
const b = req.body || {};
|
|
@@ -492,11 +492,13 @@ function createOnboardRouter(deps) {
|
|
|
492
492
|
return res.status(400).json({ ok: false, error: "invalid_param", message: "positions 必须是 1-24 的整数" });
|
|
493
493
|
}
|
|
494
494
|
const ip = req.ip || "local";
|
|
495
|
-
const
|
|
496
|
-
|
|
497
|
-
|
|
495
|
+
const nowMs = Date.now();
|
|
496
|
+
const arr = (backupVerifyRate.get(ip) || []).filter((t) => nowMs - t < 30000);
|
|
497
|
+
if (arr.length >= 6) {
|
|
498
|
+
return res.status(429).json({ ok: false, error: "rate_limited", message: "验证太频繁 (30 秒内最多 6 次), 请对照安全卡稍后再试" });
|
|
498
499
|
}
|
|
499
|
-
|
|
500
|
+
arr.push(nowMs);
|
|
501
|
+
backupVerifyRate.set(ip, arr);
|
|
500
502
|
let realWords;
|
|
501
503
|
try {
|
|
502
504
|
realWords = fs.readFileSync(basePaths.mnemonicFile(), "utf8").trim().split(/\s+/);
|
|
@@ -279,6 +279,13 @@ test("onboard 页面含进件表单 (btn-apply-submit) 且第③步状态机元
|
|
|
279
279
|
assert.ok(html.includes('name="sales_scene"'), "场景单选");
|
|
280
280
|
assert.ok(html.includes("SALES_SCENES_WEB") && html.includes("SALES_SCENES_MP"), "WEB/MP 场景");
|
|
281
281
|
assert.ok(html.includes("apply-err"), "错误红卡容器");
|
|
282
|
+
// 2026-10-03 二重框修复: 上传槽双态 (替换/删除按钮 + _aSlots 注册 + 删除清 media_id)
|
|
283
|
+
assert.ok(html.includes("upops") && html.includes("updel"), "替换/删除按钮结构");
|
|
284
|
+
assert.ok(html.includes("替换") && html.includes("删除"), "替换/删除文案");
|
|
285
|
+
assert.ok(html.includes("_aSlots"), "槽位双态注册表");
|
|
286
|
+
assert.ok(html.includes("delete _aMedia[field]"), "删除清 media_id");
|
|
287
|
+
assert.ok(!html.includes("slot.insertBefore(img"), "旧增强脚本预览补丁已删 (二重框根因)");
|
|
288
|
+
assert.ok(html.includes("prefill:function(mediaId)"), "预填走双态 (无第二个空框)");
|
|
282
289
|
assert.ok(html.includes("maybeAutoKyc"), "M2: 进件受理后自动 KYC 提交");
|
|
283
290
|
assert.ok(!html.includes("btn-verify"), "手动实名按钮已删 (自动触发)");
|
|
284
291
|
} finally { srv.close(); }
|
package/test-onboard-backup.js
CHANGED
|
@@ -83,15 +83,18 @@ test("verify-backup: 错词 → wrong_positions (不泄正确答案) + 大小写
|
|
|
83
83
|
} finally { srv.close(); }
|
|
84
84
|
});
|
|
85
85
|
|
|
86
|
-
test("verify-backup:
|
|
86
|
+
test("verify-backup: 限流滑动窗口 6 次/30s (分步验证一步一调, 3 步+重试够用)", async () => {
|
|
87
87
|
const { srv, base } = await startServer();
|
|
88
88
|
try {
|
|
89
|
-
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
89
|
+
// 单元素 positions (分步式每步一词)
|
|
90
|
+
const body = JSON.stringify({ positions: [1], words: ["economy"] });
|
|
91
|
+
for (let i = 0; i < 6; i++) {
|
|
92
|
+
const r = await fetch(base + "/api/onboard/verify-backup", { method: "POST", headers: H, body });
|
|
93
|
+
assert.strictEqual(r.status, 200, "第 " + (i + 1) + " 次放行");
|
|
94
|
+
}
|
|
95
|
+
const r7 = await fetch(base + "/api/onboard/verify-backup", { method: "POST", headers: H, body });
|
|
96
|
+
assert.strictEqual(r7.status, 429, "第 7 次 (30s 窗口内) 拒");
|
|
97
|
+
assert.strictEqual((await r7.json()).error, "rate_limited");
|
|
95
98
|
} finally { srv.close(); }
|
|
96
99
|
});
|
|
97
100
|
|
|
@@ -120,7 +123,7 @@ test("backup-ack: 纯前端比对路径落账 (不带密钥材料) + status done
|
|
|
120
123
|
} finally { srv.close(); }
|
|
121
124
|
});
|
|
122
125
|
|
|
123
|
-
test("页面含备份验证 UI (
|
|
126
|
+
test("页面含备份验证 UI (分步式: 单词位输入框/下一步/进度提示/换词位) + 内存路径分支", async () => {
|
|
124
127
|
const { srv, base } = await startServer();
|
|
125
128
|
try {
|
|
126
129
|
const html = await (await fetch(base + "/onboard/")).text();
|
|
@@ -129,6 +132,13 @@ test("页面含备份验证 UI (抽题/提交/结果显示) + 内存路径分支
|
|
|
129
132
|
assert.ok(html.includes("/api/onboard/verify-backup"), "后端比对路径");
|
|
130
133
|
assert.ok(html.includes("persistBackupAck"), "内存路径 ack 落账");
|
|
131
134
|
assert.ok(html.includes("我已抄好"), "大白话文案");
|
|
135
|
+
// 分步式 (2026-10-03): 一次一词 + 下一步 + 进度 + 重抽/换词位
|
|
136
|
+
assert.ok(html.includes("checkBqStep"), "单步验证函数");
|
|
137
|
+
assert.ok(html.includes("下一步"), "下一步按钮");
|
|
138
|
+
assert.ok(html.includes("/3 步"), "进度提示");
|
|
139
|
+
assert.ok(html.includes("返回重抽"), "返回重抽");
|
|
140
|
+
assert.ok(html.includes("swapBqPosition"), "换一个词位");
|
|
141
|
+
assert.ok(!html.includes("checkBackupVerify"), "旧一次性提交函数已移除");
|
|
132
142
|
} finally { srv.close(); }
|
|
133
143
|
});
|
|
134
144
|
|