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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trustbase-seller-backend",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
4
4
  "description": "TrustBase Seller Backend (merchant edition) — 商品上链/订单/支付/质押 write API for TrustBase 保障链生态. 单卖家自托管形态: 签名 key 即卖家身份, 私钥不出门.",
5
5
  "main": "run-unified.js",
6
6
  "bin": {
@@ -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">确认你真的抄好了 — 请按安全卡上抄写的填写 (答错不会提示正确答案, 可重新抽题):</div>
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
- // ---- 纸密钥备份回填验证 (2026-10-03, MetaMask 式抽词; 原则: mnemonic 不出本机) ----
420
- // 首次创建 (助记词还在页面内存 _mn) → 纯前端比对, 不调网络;
421
- // 页面刷新后 → POST /api/onboard/verify-backup 本机后端比对 (只送位置+词, 后端永不返回正确词)。
422
- var _bqTries=0;
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
- var q=document.getElementById('backup-questions');
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
- var box=document.getElementById('backup-quiz');
436
- box.style.display='block';
437
- box.scrollIntoView({behavior:'smooth',block:'center'});
441
+ document.getElementById('backup-quiz').style.display='block';
442
+ renderBqStep();
438
443
  }
439
- async function checkBackupVerify(){
440
- var inputs=document.querySelectorAll('.bq-word');
441
- var positions=[], words=[];
442
- inputs.forEach(function(el){ positions.push(Number(el.getAttribute('data-pos'))); words.push(el.value); });
443
- if(words.some(function(w){return !w.trim();})) return backupResult(false,'三个词都要填');
444
- // ① 内存路径: 助记词还在 (_mn) → 纯前端比对 (更快, 零网络)
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
- var real=_mn.split(/\s+/);
447
- var wrong=[];
448
- positions.forEach(function(p,i){ if(words[i].trim().toLowerCase()!==real[p-1].toLowerCase()) wrong.push(p); });
449
- if(!wrong.length){ await persistBackupAck(); return backupOk(); }
450
- return backupWrong(wrong);
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
- // ② 后端路径: 刷新后内存没有 → 本机后端比对 (mnemonic 不出本机)
453
- try{
454
- var j=await readJson(await fetch('/api/onboard/verify-backup',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({positions:positions,words:words})}));
455
- if(j.ok) return backupOk();
456
- if(j.error==='rate_limited') return backupResult(false, j.message||'30 秒内只能验证一次, 请稍后再试');
457
- if(j.wrong_positions&&j.wrong_positions.length) return backupWrong(j.wrong_positions);
458
- backupResult(false, j.message||'验证失败, 请稍后再试');
459
- }catch(e){ backupResult(false,'服务暂时不可用,请刷新重试 ('+e.message+')'); }
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
- _bqTries=0;
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
- // 照片上传位: 选图 → 本机压缩 → 卖家后端转发 SP 网关 → media_id
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
- +' <span id="st_'+field+'" style="font-size:12px"></span>';
525
- var input=slot.querySelector('input[type=file]');
526
- var stat=document.getElementById('st_'+field);
527
- input.addEventListener('change',function(){
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
- stat.style.color='';stat.textContent='压缩中...';
530
- shrinkImage(input.files[0],function(err,payload){
531
- if(err){stat.style.color='#DC2626';stat.textContent=err.message;return;}
532
- stat.textContent='上传中...';
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;stat.style.color='#16A34A';stat.textContent='✓ 已上传';}
536
- else{stat.style.color='#DC2626';stat.textContent='上传失败: '+(j.message||j.error||'未知错误');}
537
- }).catch(function(e){stat.style.color='#DC2626';stat.textContent='网络错误: '+e.message;});
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
- _aMedia[k]=snap[k]; reused++;
748
- var st=document.getElementById('st_'+k);
749
- if(st){st.style.color='#16A34A';st.textContent='✓ 已上传过, 无需重传';}
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
- 所有字段 id / 上传提交函数 / 状态机 DOM 目标均沿用现有逻辑。 ===== */
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
- // ① 证件缩略图预览 (事件委托, 与现有上传 handler 并存, 只读文件不干预流程)
920
- var form=document.getElementById('apply-form');
921
- if(form){
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 → last attempt ms (单商户本机场景, 内存即可)
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 last = backupVerifyRate.get(ip) || 0;
496
- if (Date.now() - last < 30000) {
497
- return res.status(429).json({ ok: false, error: "rate_limited", message: "30 秒内只能验证一次, 请对照纸密钥稍后再试" });
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
- backupVerifyRate.set(ip, Date.now());
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(); }
@@ -83,15 +83,18 @@ test("verify-backup: 错词 → wrong_positions (不泄正确答案) + 大小写
83
83
  } finally { srv.close(); }
84
84
  });
85
85
 
86
- test("verify-backup: 限流 30s (同 IP 连续两次 → 第二次 429)", async () => {
86
+ test("verify-backup: 限流滑动窗口 6 次/30s (分步验证一步一调, 3 步+重试够用)", async () => {
87
87
  const { srv, base } = await startServer();
88
88
  try {
89
- const body = JSON.stringify({ positions: [1, 2, 3], words: ["economy", "stock", "theory"] });
90
- const r1 = await fetch(base + "/api/onboard/verify-backup", { method: "POST", headers: H, body });
91
- assert.strictEqual(r1.status, 200);
92
- const r2 = await fetch(base + "/api/onboard/verify-backup", { method: "POST", headers: H, body });
93
- assert.strictEqual(r2.status, 429);
94
- assert.strictEqual((await r2.json()).error, "rate_limited");
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 (抽题/提交/结果显示) + 内存路径分支", async () => {
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