trustbase-seller-backend 0.3.1 → 0.3.2
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 +67 -7
- package/routes/onboard.js +49 -0
- package/test-onboard-applyment.js +52 -0
package/package.json
CHANGED
package/routes/onboard.html
CHANGED
|
@@ -590,7 +590,7 @@ function mountApplyUpload(slotId,field,label){
|
|
|
590
590
|
stat.textContent=o.stat||'';
|
|
591
591
|
}
|
|
592
592
|
_aSlots[field]={
|
|
593
|
-
prefill:function(mediaId){ _aMedia[field]=mediaId; toFilled({stat:'✓ 已上传过, 无需重传'}); },
|
|
593
|
+
prefill:function(mediaId){ _aMedia[field]=mediaId; toFilled({stat:'✓ 已上传过, 无需重传'}); saveApplyDraft(); },
|
|
594
594
|
clear:toEmpty
|
|
595
595
|
};
|
|
596
596
|
function onFile(input){
|
|
@@ -603,14 +603,14 @@ function mountApplyUpload(slotId,field,label){
|
|
|
603
603
|
toFilled({previewUrl:previewUrl,name:file.name,stat:'上传中...'});
|
|
604
604
|
fetch('/api/onboard/applyment/upload',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)})
|
|
605
605
|
.then(readJson).then(function(j){
|
|
606
|
-
if(j.ok&&j.media_id){ _aMedia[field]=j.media_id; toFilled({previewUrl:previewUrl,name:file.name,stat:'✓ 已上传'}); }
|
|
606
|
+
if(j.ok&&j.media_id){ _aMedia[field]=j.media_id; toFilled({previewUrl:previewUrl,name:file.name,stat:'✓ 已上传'}); saveApplyDraft(); }
|
|
607
607
|
else toFilled({previewUrl:previewUrl,name:file.name,stat:'上传失败: '+(j.message||j.error||'未知错误'),err:true});
|
|
608
608
|
}).catch(function(e){ toFilled({previewUrl:previewUrl,name:file.name,stat:'网络错误: '+e.message,err:true}); });
|
|
609
609
|
});
|
|
610
610
|
}
|
|
611
611
|
zone.querySelector('input[type=file]').addEventListener('change',function(){ onFile(this); });
|
|
612
612
|
filled.querySelector('.upops input[type=file]').addEventListener('change',function(){ onFile(this); });
|
|
613
|
-
filled.querySelector('.updel').addEventListener('click',toEmpty);
|
|
613
|
+
filled.querySelector('.updel').addEventListener('click',function(){ toEmpty(); saveApplyDraft(); });
|
|
614
614
|
}
|
|
615
615
|
mountApplyUpload('a_up_license','license_media_id','营业执照照片 (必传)');
|
|
616
616
|
mountApplyUpload('a_up_idfront','legal_id_front_media_id','身份证人像面 (必传)');
|
|
@@ -753,7 +753,7 @@ async function submitApplyment(){
|
|
|
753
753
|
clearTimeout(timer);
|
|
754
754
|
if(j.ok){
|
|
755
755
|
msg.style.display='block';
|
|
756
|
-
msg.textContent='✅ 已提交微信审核 (敏感字段已在本机加密)'+(j.idempotent?' — 此前已提交过, 幂等忽略':'');
|
|
756
|
+
msg.textContent='✅ 已提交微信审核 (敏感字段已在本机加密)'+(j.idempotent?' — 此前已提交过, 幂等忽略':'')+'; 填写内容已保留在本机, 修改后可直接重提';
|
|
757
757
|
document.getElementById('apply-form').style.display='none';
|
|
758
758
|
queryApplyment();
|
|
759
759
|
} else {
|
|
@@ -781,15 +781,75 @@ async function queryApplyment(){
|
|
|
781
781
|
try{
|
|
782
782
|
var j=await readJson(await fetch('/api/onboard/applyment/status'));
|
|
783
783
|
renderApplyment(j);
|
|
784
|
-
|
|
784
|
+
// 草稿生命周期: FINISHED (业务完成) → 清草稿, 不再恢复
|
|
785
|
+
if(j&&j.status==='APPLYMENT_STATE_FINISHED'){ clearApplyDraft(); return; }
|
|
786
|
+
// 恢复优先级: 本机草稿 (未提交编辑态, 含敏感明文) > form_snapshot (已提交脱敏快照) > 空表单
|
|
787
|
+
var d=await readJson(await fetch('/api/onboard/applyment/draft'));
|
|
788
|
+
if(d&&d.ok&&d.fields){ restoreApplyDraft(d.fields); return; }
|
|
789
|
+
if(j&&j.form_snapshot) prefillApplyForm(j.form_snapshot);
|
|
785
790
|
}catch(e){}
|
|
786
791
|
}
|
|
792
|
+
// ---- 本机自动草稿 (2026-10-03 xiaomu: 重进重填敏感字段) ----
|
|
793
|
+
// 隐私口径: 明文只存本机 sqlite (证件原件本来就在本机); 发出时照旧本机加密 (草稿不直接外发)。
|
|
794
|
+
var _draftRestored=false, _draftTimer=null;
|
|
795
|
+
function saveApplyDraft(){
|
|
796
|
+
clearTimeout(_draftTimer);
|
|
797
|
+
_draftTimer=setTimeout(async function(){ // 防抖 800ms
|
|
798
|
+
try{
|
|
799
|
+
await fetch('/api/onboard/applyment/draft',{method:'POST',headers:{'Content-Type':'application/json'},
|
|
800
|
+
body:JSON.stringify({fields:applyFormBody()})}); // 场景切换不丢: 按当前场景存全量字段
|
|
801
|
+
}catch(e){ /* 草稿失败静默, 不挡表单 */ }
|
|
802
|
+
},800);
|
|
803
|
+
}
|
|
804
|
+
async function clearApplyDraft(){
|
|
805
|
+
try{ await fetch('/api/onboard/applyment/draft',{method:'DELETE'}); }catch(e){}
|
|
806
|
+
}
|
|
807
|
+
function restoreApplyDraft(f){
|
|
808
|
+
if(_draftRestored||!f||typeof f!=='object') return;
|
|
809
|
+
_draftRestored=true;
|
|
810
|
+
// 场景先切 (setScene 重挂场景字段/上传槽)
|
|
811
|
+
if(f.sales_scene){
|
|
812
|
+
var r=document.querySelector('input[name="sales_scene"][value="'+f.sales_scene+'"]');
|
|
813
|
+
if(r){r.checked=true;setScene(f.sales_scene);}
|
|
814
|
+
}
|
|
815
|
+
// 主体联动先触发 (居住地址/UBO 显示 + 结算规则/类目默认), 再写草稿值
|
|
816
|
+
var subj=document.getElementById('a_subject_type');
|
|
817
|
+
if(subj&&f.subject_type){subj.value=f.subject_type;subj.dispatchEvent(new Event('change'));}
|
|
818
|
+
for(var k in PREFILL_MAP){
|
|
819
|
+
if(k==='subject_type') continue;
|
|
820
|
+
var v=f[k];
|
|
821
|
+
if(!v||String(v).indexOf('***')===0) continue;
|
|
822
|
+
var e=document.getElementById(PREFILL_MAP[k]);
|
|
823
|
+
if(e&&!e.value) e.value=v;
|
|
824
|
+
}
|
|
825
|
+
// 照片 media_id 复用 → 槽位已传双态
|
|
826
|
+
PREFILL_MEDIA.forEach(function(k){
|
|
827
|
+
if(f[k]&&_aSlots[k]) _aSlots[k].prefill(f[k]);
|
|
828
|
+
else if(f[k]) _aMedia[k]=f[k];
|
|
829
|
+
});
|
|
830
|
+
var msg=document.getElementById('apply-msg');
|
|
831
|
+
msg.style.display='block';
|
|
832
|
+
msg.textContent='ℹ️ 已恢复上次填写 (自动保存于本机)';
|
|
833
|
+
// 草稿恢复了编辑态 → 展开表单
|
|
834
|
+
document.getElementById('apply-form').style.display='block';
|
|
835
|
+
}
|
|
836
|
+
// 任何字段 input/change → 防抖自动保存 (含场景切换时的字段集; file 选择也走 change)
|
|
837
|
+
(function(){
|
|
838
|
+
var form=document.getElementById('apply-form');
|
|
839
|
+
if(form){
|
|
840
|
+
form.addEventListener('input',saveApplyDraft);
|
|
841
|
+
form.addEventListener('change',saveApplyDraft);
|
|
842
|
+
}
|
|
843
|
+
})();
|
|
787
844
|
// F1 预填: 台账快照回填非敏感字段 + 照片 media_id 复用 (敏感字段是密文/脱敏, 需重填)
|
|
788
845
|
var PREFILL_MAP={subject_type:'a_subject_type',merchant_shortname:'a_shortname',merchant_name:'a_merchant_name',
|
|
789
846
|
license_number:'a_license_number',service_phone:'a_service_phone',
|
|
790
|
-
|
|
847
|
+
contact_name:'a_contact_name',contact_mobile:'a_contact_mobile',contact_email:'a_contact_email',
|
|
848
|
+
legal_name:'a_legal_name',legal_id_number:'a_legal_id_number',legal_id_address:'a_legal_id_address',
|
|
791
849
|
legal_id_period_begin:'a_period_begin',legal_id_period_end:'a_period_end',
|
|
792
|
-
account_type:'a_account_type',
|
|
850
|
+
account_type:'a_account_type',account_name:'a_account_name',account_number:'a_account_number',
|
|
851
|
+
account_bank:'a_account_bank',bank_name:'a_bank_name',bank_address_code:'a_bank_address_code',
|
|
852
|
+
qualification_type:'a_settlement_id', // 行业类目 MCC (draft 路径用; snapshot 路径走 mcc 兼容段)
|
|
793
853
|
store_name:'a_store_name',store_address_code:'a_store_address_code',store_address:'a_store_address',
|
|
794
854
|
website_url:'a_website_url',appid:'a_appid',appid_type:'a_appid_type'};
|
|
795
855
|
var PREFILL_MEDIA=['license_media_id','legal_id_front_media_id','legal_id_back_media_id',
|
package/routes/onboard.js
CHANGED
|
@@ -699,6 +699,55 @@ CREATE TABLE IF NOT EXISTS onboard_applyments (
|
|
|
699
699
|
}
|
|
700
700
|
});
|
|
701
701
|
|
|
702
|
+
// ---------- 进件表单本机草稿 (2026-10-03 xiaomu 实测: 每次重进重填敏感字段) ----------
|
|
703
|
+
// 隐私口径: 商户资料只存本机 (证件原件本来就在本机 materials 目录), 本机明文存草稿合理;
|
|
704
|
+
// 发出时照旧本机加密 (加密边界不变, 草稿明文永不直接外发)。
|
|
705
|
+
db.exec(`
|
|
706
|
+
CREATE TABLE IF NOT EXISTS onboard_draft (
|
|
707
|
+
id INTEGER PRIMARY KEY CHECK(id=1), -- 单行 (一台机器一个卖家)
|
|
708
|
+
fields TEXT NOT NULL DEFAULT '{}', -- 整表单字段 JSON (含敏感字段明文, 刻意)
|
|
709
|
+
updated_at INTEGER NOT NULL
|
|
710
|
+
);
|
|
711
|
+
`);
|
|
712
|
+
/** POST /applyment/draft {fields:{...}} — 保存草稿 (单行 upsert; 敏感字段明文是本机刻意口径) */
|
|
713
|
+
router.post("/applyment/draft", (req, res) => {
|
|
714
|
+
try {
|
|
715
|
+
const b = req.body || {};
|
|
716
|
+
if (!b.fields || typeof b.fields !== "object" || Array.isArray(b.fields)) {
|
|
717
|
+
return res.status(400).json({ ok: false, error: "invalid_param", message: "fields 必填且为对象" });
|
|
718
|
+
}
|
|
719
|
+
const raw = JSON.stringify(b.fields);
|
|
720
|
+
if (raw.length > 200 * 1024) {
|
|
721
|
+
return res.status(413).json({ ok: false, error: "draft_too_large", message: "草稿超过 200KB" });
|
|
722
|
+
}
|
|
723
|
+
const now = Math.floor(Date.now() / 1000);
|
|
724
|
+
db.prepare("INSERT INTO onboard_draft (id, fields, updated_at) VALUES (1, ?, ?) ON CONFLICT(id) DO UPDATE SET fields=excluded.fields, updated_at=excluded.updated_at")
|
|
725
|
+
.run(raw, now);
|
|
726
|
+
res.json({ ok: true, updated_at: now });
|
|
727
|
+
} catch (e) {
|
|
728
|
+
console.error("[onboard] draft save error:", e && e.message);
|
|
729
|
+
res.status(500).json({ ok: false, error: "internal", message: String(e && e.message).slice(0, 200) });
|
|
730
|
+
}
|
|
731
|
+
});
|
|
732
|
+
/** GET /applyment/draft — 读草稿 (无缓存; 没有草稿返回 fields:null) */
|
|
733
|
+
router.get("/applyment/draft", (req, res) => {
|
|
734
|
+
try {
|
|
735
|
+
const row = db.prepare("SELECT fields, updated_at FROM onboard_draft WHERE id=1").get();
|
|
736
|
+
res.json({ ok: true, fields: row ? JSON.parse(row.fields) : null, updated_at: row ? row.updated_at : null });
|
|
737
|
+
} catch (e) {
|
|
738
|
+
res.status(500).json({ ok: false, error: "internal", message: String(e && e.message).slice(0, 200) });
|
|
739
|
+
}
|
|
740
|
+
});
|
|
741
|
+
/** DELETE /applyment/draft — 清除草稿 (FINISHED 后业务完成; 或商户想重来) */
|
|
742
|
+
router.delete("/applyment/draft", (req, res) => {
|
|
743
|
+
try {
|
|
744
|
+
db.prepare("DELETE FROM onboard_draft WHERE id=1").run();
|
|
745
|
+
res.json({ ok: true });
|
|
746
|
+
} catch (e) {
|
|
747
|
+
res.status(500).json({ ok: false, error: "internal", message: String(e && e.message).slice(0, 200) });
|
|
748
|
+
}
|
|
749
|
+
});
|
|
750
|
+
|
|
702
751
|
/** POST /applyment/upload — 证件照片转发 SP 网关 media/upload (base64 JSON 同款) */
|
|
703
752
|
router.post("/applyment/upload", async (req, res) => {
|
|
704
753
|
try {
|
|
@@ -619,3 +619,55 @@ test("sp-info 代理: 全新实例 SP 不可达 → 200 降级 offline (不报
|
|
|
619
619
|
assert.ok(html.includes("可随时更换"), "迁移保障卖点文案");
|
|
620
620
|
} finally { srv.close(); process.env.SP_GATEWAY_URL = spBase; }
|
|
621
621
|
});
|
|
622
|
+
|
|
623
|
+
test("草稿: POST 保存 → GET 读取 (含敏感字段明文, 本机刻意口径) → DELETE 清除", async () => {
|
|
624
|
+
const { srv, base } = await startSeller();
|
|
625
|
+
try {
|
|
626
|
+
// 空草稿
|
|
627
|
+
let j = await (await fetch(base + "/api/onboard/applyment/draft")).json();
|
|
628
|
+
assert.strictEqual(j.fields, null);
|
|
629
|
+
// 保存 (含敏感字段明文 — 本机=商户自己电脑, 刻意)
|
|
630
|
+
const fields = { contact_name: "张三", legal_id_number: "440300199001011234", account_number: "6225880212345678", qualification_type: "5311", sales_scene: "SALES_SCENES_STORE" };
|
|
631
|
+
let r = await fetch(base + "/api/onboard/applyment/draft", {
|
|
632
|
+
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ fields }),
|
|
633
|
+
});
|
|
634
|
+
assert.strictEqual(r.status, 200);
|
|
635
|
+
// 读回 (无缓存, upsert 覆盖)
|
|
636
|
+
j = await (await fetch(base + "/api/onboard/applyment/draft")).json();
|
|
637
|
+
assert.deepStrictEqual(j.fields.legal_id_number, "440300199001011234");
|
|
638
|
+
assert.ok(j.updated_at > 0);
|
|
639
|
+
// upsert 覆盖 (单行)
|
|
640
|
+
await fetch(base + "/api/onboard/applyment/draft", {
|
|
641
|
+
method: "POST", headers: { "Content-Type": "application/json" },
|
|
642
|
+
body: JSON.stringify({ fields: { contact_name: "李四" } }),
|
|
643
|
+
});
|
|
644
|
+
j = await (await fetch(base + "/api/onboard/applyment/draft")).json();
|
|
645
|
+
assert.strictEqual(j.fields.contact_name, "李四");
|
|
646
|
+
assert.strictEqual(j.fields.legal_id_number, undefined, "upsert 整行覆盖");
|
|
647
|
+
// 删除
|
|
648
|
+
r = await fetch(base + "/api/onboard/applyment/draft", { method: "DELETE" });
|
|
649
|
+
assert.strictEqual((await r.json()).ok, true);
|
|
650
|
+
j = await (await fetch(base + "/api/onboard/applyment/draft")).json();
|
|
651
|
+
assert.strictEqual(j.fields, null, "删除后为空");
|
|
652
|
+
// 参数校验
|
|
653
|
+
r = await fetch(base + "/api/onboard/applyment/draft", {
|
|
654
|
+
method: "POST", headers: { "Content-Type": "application/json" }, body: "{}",
|
|
655
|
+
});
|
|
656
|
+
assert.strictEqual(r.status, 400);
|
|
657
|
+
} finally { srv.close(); }
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
test("草稿页面逻辑: 自动保存/恢复优先级/FINISHED 清除/提交保留 元素齐", async () => {
|
|
661
|
+
const { srv, base } = await startSeller();
|
|
662
|
+
try {
|
|
663
|
+
const html = await (await fetch(base + "/onboard/")).text();
|
|
664
|
+
assert.ok(html.includes("/api/onboard/applyment/draft"), "草稿端点调用");
|
|
665
|
+
assert.ok(html.includes("saveApplyDraft") && html.includes("800"), "防抖 800ms 自动保存");
|
|
666
|
+
assert.ok(html.includes("restoreApplyDraft"), "草稿恢复函数");
|
|
667
|
+
assert.ok(html.includes("clearApplyDraft"), "FINISHED 清除草稿");
|
|
668
|
+
assert.ok(html.includes("已恢复上次填写"), "恢复提示文案");
|
|
669
|
+
assert.ok(html.includes("修改后可直接重提"), "提交后保留草稿提示");
|
|
670
|
+
// 恢复优先级注释: 草稿 > form_snapshot > 空表单
|
|
671
|
+
assert.ok(html.indexOf("applyment/draft") < html.indexOf("form_snapshot") || html.includes("恢复优先级"), "草稿优先于快照");
|
|
672
|
+
} finally { srv.close(); }
|
|
673
|
+
});
|