This commit is contained in:
浅念
2026-08-27 16:12:37 +08:00
commit 7f4f3be8d8
35 changed files with 8388 additions and 0 deletions
+47
View File
@@ -0,0 +1,47 @@
# 插件 → 后端 端到端验证
## 自动化验证(push 模块 → 后端链路,已验证 ✅)
前置:Plan 1 后端在跑(`cd d:/alibb-server/backend && docker compose up -d`,`/api/health` ok)。
```bash
cd d:/alibb && node scripts/e2e-push.mjs
# 预期:ingest status 200, inserted 1
# 然后查:
JWT=$(curl -s -XPOST http://localhost:8080/api/auth/login -H 'Content-Type: application/json' -d '{"username":"admin","password":"admin-password-1234"}' | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>console.log(JSON.parse(s).token))")
curl -s "http://localhost:8080/api/inquiries/E2E-TEST2" -H "Authorization: Bearer $JWT"
# 预期:看到 inquiry + customer + _count.messages=1
```
这验证 `push/payload.js` 组装的 payload 能被 Plan 1 后端 `/api/ingest` 正确接收入库(询盘+客户+消息)。
## 完整插件 e2e(需手动,Chrome 加载扩展)
1. **起后端**:`cd d:/alibb-server/backend && docker compose up -d --build`,等 `curl http://localhost:8080/api/health` → `{"ok":true}`
2. **加载扩展**:Chrome 打开 `chrome://extensions` → 开启「开发者模式」→「加载已解压的扩展程序」→ 选 `d:/alibb`
3. **配置推送**:点插件图标 →「后端设置」区填:
- 服务器 URL:`http://localhost:8080`
- Token:`dev-ingest-token-1234`
- 勾「启用推送」
- 点「授权域名」→ Chrome 弹框点「允许」
- 点「保存设置」→ 看到「推送设置已保存(已启用)」
4. **抓取**:打开 `https://message.alibaba.com/message/default.htm`(登录阿里主账号)→ 点插件「开始抓取」(detail 模式,测试 10 条)
5. **观察**:popup 顶部状态条「推送:已推 N / 待推 M / 失败 K」随抓取增长
6. **验证后端有数据**:
```bash
JWT=$(curl -s -XPOST http://localhost:8080/api/auth/login -H 'Content-Type: application/json' -d '{"username":"admin","password":"admin-password-1234"}' | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>console.log(JSON.parse(s).token))")
curl -s "http://localhost:8080/api/inquiries?size=5" -H "Authorization: Bearer $JWT" | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{const d=JSON.parse(s);console.log('total:',d.total);d.items.forEach(i=>console.log(' ',i.inquiryId,i.customer?.country,'msgs:',i.messagesCount))})"
```
预期:看到刚抓的询盘,带 country + messagesCount
7. **验证幂等**:再抓同一询盘 → 后端 `messages_count` 不变(早返回生效)
## 断网/重试验证(防丢)
1. 停后端:`docker compose down`
2. 抓一条 → popup 状态「待推 1」(入 IndexedDB 队列,未丢)
3. 起后端:`docker compose up -d`
4. 等 ~1 分钟(chrome.alarms 触发 worker)→ popup「已推 1」(自动补推)
## 收尾
`cd d:/alibb-server/backend && docker compose down`(volume 保留)
+26
View File
@@ -0,0 +1,26 @@
// 模拟插件推送:用 push/payload.js 组装 payload,POST 到后端 /api/ingest
// 用法: node scripts/e2e-push.mjs (可选 SERVER/TOKEN 环境变量)
import { buildIngestPayload } from '../push/payload.js';
const record = {
inquiryId: 'E2E-TEST2', manager: 'tester',
createTimeFromApi: '2026-07-14T10:00:00Z', updateTimeFromApi: '2026-07-14T10:00:00Z',
status: 'new', customerId: 'c-e2e', detailCountry: 'US', detailBuyerName: 'Test Buyer',
behavior: { validRfq: '5' },
};
const messages = [{
messageId: 'e2e-test-m1', sendTime: '2026-07-14T10:00:00Z',
sender: 'tester', original: 'hello e2e', direction: 'outgoing',
}];
const payload = buildIngestPayload(record, messages);
const SERVER = process.env.SERVER || 'http://localhost:8080';
const TOKEN = process.env.TOKEN || 'dev-ingest-token-1234';
const res = await fetch(`${SERVER}/api/ingest`, {
method: 'POST',
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
console.log('ingest status:', res.status);
console.log('ingest body:', JSON.stringify(await res.json()));
+35
View File
@@ -0,0 +1,35 @@
// 根因验证:后端 customers.customer_id 是 VARCHAR(64)(spec §6.1),
// 而插件对无真 customerId 的询盘用 sec_ + secTradeId 兜底(75 字符)。
// 本脚本推两条测试数据对比:超长 sec_ vs 短 id,看后端 ingest 的实际反应。
// 用法: node scripts/test-cid-length.mjs
import { buildIngestPayload } from '../push/payload.js';
const SERVER = process.env.SERVER || 'http://219.157.255.213:23010';
const TOKEN = process.env.TOKEN || '688cbb4b6e880b340b2360559c4ad29d01a39ccbb3c31c4d';
async function push(label, customerId) {
const record = {
inquiryId: `TEST-CID-${label}`,
customerId,
detailBuyerName: 'Test Buyer', detailCountry: 'US', company: 'Test Co',
createTimeFromApi: '2026-07-20T00:00:00Z', updateTimeFromApi: '2026-07-20T00:00:00Z',
behavior: { productViews: '1' },
};
const payload = buildIngestPayload(record, []);
const res = await fetch(`${SERVER}/api/ingest`, {
method: 'POST',
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
let body = '';
try { body = await res.text(); } catch (e) { body = '(no body)'; }
console.log(`[${label}] customerId(len=${customerId.length}): status=${res.status}`);
console.log(` body=${body.slice(0, 400)}`);
console.log(` payload.customer.customerId = ${JSON.stringify(payload.customer && payload.customer.customerId)}`);
}
const longCid = 'sec_MC1IDX1yqSSuFKld-CG7npDfPBic6Iv1S_ZJecxnw8b7kTejq_GNIDRuGTmXE-sjnZMUpze';
console.log('=== longCid length:', longCid.length, '===');
await push('LONG75', longCid);
await push('SHORT5', '12345');
console.log('\n说明:若 LONG75 失败(4xx)而 SHORT5 成功(200) → 证实后端因长度拒绝 sec_ customerId');
+156
View File
@@ -0,0 +1,156 @@
// 验证聊天时间范围判定逻辑(纯函数基准实现)。
// content.js/popup.js 为 IIFE/非 module,无法被 vitest import —— 本文件是判定逻辑的
// 基准实现+用例锁定;实现侧照搬函数体。⚠️ 改任一侧必须同步另一侧。
let pass = 0, fail = 0;
const ok = (name, cond) => { console.log((cond ? 'PASS' : 'FAIL') + ' | ' + name); cond ? pass++ : fail++; };
// ---- 基准实现(实现照搬区)----
// 自然月回退:直接 setMonth(-n) 月末溢出(3-31−1月→"2-31"→3-02),先退月初再钳制天数
function minusMonths(base, n) {
const d = new Date(base.getFullYear(), base.getMonth(), 1,
base.getHours(), base.getMinutes(), base.getSeconds(), base.getMilliseconds());
d.setMonth(d.getMonth() - n);
const lastDay = new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate();
d.setDate(Math.min(base.getDate(), lastDay));
return d;
}
// popup 范围计算:mode=all|1m|2m|3m|custom → {since,until}(ISO|null) 或 {error}
function computeChatRange(mode, sinceStr, untilStr) {
if (mode === 'all') return { since: null, until: null };
if (mode === '1m' || mode === '2m' || mode === '3m') {
// 整天边界:since=起始日 00:00:00(完整自然月天数),until=今日 23:59:59.999("至今"取今天整天,与 custom 止=今天一致)
const dayStart = new Date(); dayStart.setHours(0, 0, 0, 0);
const dayEnd = new Date(); dayEnd.setHours(23, 59, 59, 999);
return { since: minusMonths(dayStart, Number(mode[0])).toISOString(), until: dayEnd.toISOString() };
}
if (mode === 'custom') {
if (!sinceStr || !untilStr) return { error: '请填写自定义起止日期' };
const s = new Date(sinceStr + 'T00:00:00.000'); // ⚠️ 必须拼时间:裸 date-only 按 UTC 解析差 8h
const u = new Date(untilStr + 'T23:59:59.999');
if (isNaN(s.getTime()) || isNaN(u.getTime())) return { error: '日期格式错误' };
if (s.getTime() > u.getTime()) return { error: '起始日期不能晚于截止日期' };
const uDay = new Date(untilStr + 'T00:00:00.000');
const t0 = new Date(); t0.setHours(0, 0, 0, 0);
if (uDay.getTime() > t0.getTime()) return { error: '截止日期不能晚于今天' };
return { since: s.toISOString(), until: u.toISOString() };
}
return { error: '未知时间范围模式' };
}
// 范围过滤(null sendTime 防御性保留 —— 宁多勿漏)
function inChatRange(sendTime, sinceMs, untilMs) {
if (sendTime == null) return true;
if (sinceMs != null && sendTime < sinceMs) return false;
if (untilMs != null && sendTime > untilMs) return false;
return true;
}
// 水位读取兼容:旧=纯 string(sendTime 未知) / 新={id,sendTime}
function parseWatermark(v) {
if (v == null || v === '') return null;
try {
const o = JSON.parse(v);
if (o && typeof o === 'object' && o.id != null) return { id: String(o.id), sendTime: Number(o.sendTime) || null };
} catch (e) { /* 旧格式继续 */ }
return { id: String(v), sendTime: null };
}
// 水位只前进写入判定
function shouldWriteWatermark(oldWm, candId, candSendTime) {
if (candId == null || candSendTime == null) return false;
if (oldWm == null) return true;
if (oldWm.sendTime == null) return true; // legacy 升级,允许覆盖一次
return candSendTime > oldWm.sendTime;
}
// probe 轮断言分类:'fail'(非对象/code≠200/data=null) | 'empty'(空list) | null(正常)。
// non-JSON/HTTP/关键词由 postForm 抛 RISK,不经此函数(真风控照抛,不降级)
function probeFailKind(json) {
if (!json || typeof json !== 'object') return 'fail';
if (json.code !== undefined && Number(json.code) !== 200) return 'fail';
if (json.data == null) return 'fail';
const list = json.data && json.data.list;
if (!Array.isArray(list) || list.length === 0) return 'empty';
return null;
}
// ISO → 毫秒,parse 容错(spec §5:非法/空 → null = 降级不限)
function toMs(v) { const n = v ? Date.parse(v) : NaN; return Number.isFinite(n) ? n : null; }
// ---- 用例 ----
ok('3-31 − 1月 = 2-28(月末钳制,setMonth 溢出防护)', (() => {
const r = minusMonths(new Date(2026, 2, 31, 15, 30), 1);
return r.getMonth() === 1 && r.getDate() === 28 && r.getHours() === 15;
})());
ok('8-25 − 1月 = 7-25(普通日期)', (() => {
const r = minusMonths(new Date(2026, 7, 25), 1);
return r.getMonth() === 6 && r.getDate() === 25;
})());
ok('12-31 − 2月 = 10-31(同年跨月)', (() => {
const r = minusMonths(new Date(2026, 11, 31), 2);
return r.getMonth() === 9 && r.getDate() === 31;
})());
ok('1-31 − 2月 = 上年 11-30(跨年+钳制)', (() => {
const r = minusMonths(new Date(2026, 0, 31), 2);
return r.getFullYear() === 2025 && r.getMonth() === 10 && r.getDate() === 30;
})());
ok('闰年:2028-03-31 − 1月 = 2028-02-29', (() => {
const r = minusMonths(new Date(2028, 2, 31), 1);
return r.getMonth() === 1 && r.getDate() === 29;
})());
ok('all → 双 null(完全同现状)', computeChatRange('all').since === null && computeChatRange('all').until === null);
ok('1m → since=起始日 00:00:00.000(25~32天前),until=今日 23:59:59.999(整天边界)', (() => {
const r = computeChatRange('1m');
const s = new Date(r.since), u = new Date(r.until);
const ageDays = (Date.now() - s.getTime()) / 86400e3;
const t0 = new Date(); t0.setHours(0, 0, 0, 0);
return ageDays > 25 && ageDays < 32
&& s.getHours() === 0 && s.getMinutes() === 0 && s.getSeconds() === 0 && s.getMilliseconds() === 0
&& u.getTime() - t0.getTime() === 86399999; // = 今日 0 点 + 全天末毫秒
})());
ok('custom 本地时区(since=本地0点,非 UTC)', (() => {
const r = computeChatRange('custom', '2026-08-25', '2026-08-25');
const s = new Date(r.since);
return s.getMonth() === 7 && s.getDate() === 25 && s.getHours() === 0 && s.getMinutes() === 0;
})());
ok('custom until=止日 23:59:59.999 本地(含全天)', (() => {
const r = computeChatRange('custom', '2026-08-01', '2026-08-25');
const u = new Date(r.until);
return u.getDate() === 25 && u.getHours() === 23 && u.getMinutes() === 59 && u.getMilliseconds() === 999;
})());
ok('custom 起>止 → error', !!computeChatRange('custom', '2026-08-10', '2026-08-01').error);
ok('custom 起为空 → error', !!computeChatRange('custom', '', '2026-08-01').error);
ok('custom 止>今天 → error', !!computeChatRange('custom', '2026-01-01', '2099-01-01').error);
ok('custom 垃圾日期 → error', !!computeChatRange('custom', 'not-a-date', '2026-08-01').error);
ok('未知模式 → error', !!computeChatRange('6m').error);
const SINCE = Date.parse('2026-07-25T00:00:00'), UNTIL = Date.parse('2026-08-25T23:59:59');
ok('since 当天消息收(含边界)', inChatRange(SINCE, SINCE, null));
ok('since 前一天不收', !inChatRange(SINCE - 86400e3, SINCE, null));
ok('until 末毫秒收(含边界)', inChatRange(UNTIL, null, UNTIL));
ok('until 次日不收', !inChatRange(UNTIL + 1, null, UNTIL));
ok('sendTime=null 防御性保留', inChatRange(null, SINCE, UNTIL));
ok('范围不限(null)全收', inChatRange(1, null, null));
ok('新格式水位解析', (() => { const w = parseWatermark('{"id":"a1","sendTime":1787626659384}'); return w.id === 'a1' && w.sendTime === 1787626659384; })());
ok('旧格式(纯string)→ legacy,sendTime 未知', (() => { const w = parseWatermark('abc123'); return w.id === 'abc123' && w.sendTime === null; })());
ok('空值(null/undefined/空串)→ null(首次抓取必走)', parseWatermark(null) === null && parseWatermark(undefined) === null && parseWatermark('') === null);
ok('只前进:候选6-30 不覆盖 7-28', !shouldWriteWatermark({ id: 'o', sendTime: Date.parse('2026-07-28') }, 'n', Date.parse('2026-06-30')));
ok('只前进:候选8-25 覆盖 7-28', shouldWriteWatermark({ id: 'o', sendTime: Date.parse('2026-07-28') }, 'n', Date.parse('2026-08-25')));
ok('legacy 允许覆盖一次(升级)', shouldWriteWatermark({ id: 'o', sendTime: null }, 'n', Date.parse('2026-06-30')));
ok('无旧水位 → 写入', shouldWriteWatermark(null, 'n', 1));
ok('候选缺 sendTime → 不写', !shouldWriteWatermark({ id: 'o', sendTime: 5 }, 'n', null));
ok('probe 正常(200+非空) → null', probeFailKind({ code: '200', data: { list: [{}] } }) === null);
ok('probe code≠200 → fail 降级', probeFailKind({ code: '500' }) === 'fail');
ok('probe data=null → fail 降级', probeFailKind({ code: '200', data: null }) === 'fail');
ok('probe 非对象 → fail 降级', probeFailKind('ok') === 'fail');
ok('probe 空 list → empty 降级(实测形态)', probeFailKind({ code: '200', data: { list: [] } }) === 'empty');
ok('probe list 缺失 → empty 降级', probeFailKind({ code: '200', data: {} }) === 'empty');
ok('toMs 非法/空 → null,合法 → 毫秒', toMs('garbage') === null && toMs(null) === null && toMs('2026-08-25T00:00:00Z') === Date.parse('2026-08-25T00:00:00Z'));
// (文档性说明,非行为锁定)until 游标 = untilMs+1:附录B实测语义=返回 sendTime < cursor,恰好覆盖 <= untilMs;真实验证靠 Task 8 #4 SW Network
ok('文档性:until 游标 +1ms 覆盖含止日末毫秒(真实验证靠 Task 8 #4)', (() => {
const untilMs = Date.parse('2026-06-30T23:59:59.999');
const cursor = untilMs + 1;
return (untilMs < cursor) && !(untilMs + 1 < cursor);
})());
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);
+96
View File
@@ -0,0 +1,96 @@
// 验证 content.js postForm 的风控检测逻辑(纯函数复刻版,须与 content.js 内 postForm 保持一致)。
// content.js 是 IIFE 闭包 + content_script 不能 ES module import,内部函数无法被 vitest 直接测,
// 故在此复刻逻辑、跑关键 case 佐证正确性。改 content.js 的检测逻辑时务必同步本文件。
const RISK_KEYWORDS = ['验证码', 'captcha', '滑动验证', '安全验证', 'blocked', '访问频繁', '限制访问', '风控'];
const RISK_TOP_FIELDS = new Set(['msg', 'message', 'errmsg', 'statue', 'errormessage', 'err']);
const hitsRiskKeyword = s => {
const lower = String(s == null ? '' : s).toLowerCase();
return RISK_KEYWORDS.find(kw => lower.includes(String(kw).toLowerCase()));
};
// 复刻 content.js 的账号注销/管控跳过检测(与 hitsSkipKeyword 保持一致)
const SKIP_KEYWORDS = ['对方账号已自行注销', '违反平台规则被管控'];
const hitsSkipKeyword = s => {
const lower = String(s == null ? '' : s).toLowerCase();
return SKIP_KEYWORDS.find(kw => lower.includes(String(kw).toLowerCase()));
};
// 复刻 postForm 的检测分支(去掉 fetch,直接吃响应 text)。返回 {risk} 或 {ok, json}。
function detect(text) {
let j;
try { j = JSON.parse(text); }
catch (e) {
const kw = hitsRiskKeyword(text);
if (kw) return { risk: 'RISK:关键词(' + kw + ')' };
return { risk: 'RISK:结构异常(non-JSON)' };
}
if (j && typeof j === 'object') {
for (const f of Object.keys(j)) {
if (RISK_TOP_FIELDS.has(f.toLowerCase()) && typeof j[f] === 'string') {
const kw = hitsRiskKeyword(j[f]);
if (kw) return { risk: 'RISK:关键词(' + kw + '@' + f + ')' };
}
}
}
return { ok: true, json: j };
}
const cases = [
{
name: '正常 getOpMessages:正文含 captcha/blocked/验证码 不应误报(核心回归)',
text: JSON.stringify({ code: '200', data: { list: [
{ messageId: '1', content: 'are you blocked? the captcha 验证码 is wrong', sendTime: 1700000000000 },
] } }),
expectRisk: false,
},
{
name: 'HTML 滑块验证页(非JSON,含关键词)→ 应报风控',
text: '<html><body>请完成滑动验证 security check captcha required</body></html>',
expectRisk: true,
},
{
name: 'JSON 顶层 msg="访问频繁" → 应报风控',
text: JSON.stringify({ code: '200', msg: '您访问频繁,请稍后再试' }),
expectRisk: true,
},
{
name: 'JSON 顶层 errMsg 含 captcha → 应风控(字段名大小写无关)',
text: JSON.stringify({ errMsg: 'captcha verification failed' }),
expectRisk: true,
},
{
name: '非JSON 无关键词(登录页)→ 结构异常,应暂停',
text: '<html><body>please login</body></html>',
expectRisk: true,
},
{
name: '正常 querySummary → 不报',
text: JSON.stringify({ code: 200, data: { owner: { accountIdEncrypt: 'x' } } }),
expectRisk: false,
},
];
let pass = 0, fail = 0;
for (const c of cases) {
const r = detect(c.text);
const got = !!r.risk;
const ok = got === c.expectRisk;
console.log((ok ? 'PASS' : 'FAIL') + ' | ' + c.name + (r.risk ? ' -> ' + r.risk : ' -> ok'));
ok ? pass++ : fail++;
}
console.log(`\n--- SKIP 检测(账号注销/管控) ---`);
const skipCases = [
{ name: '账号注销完整提示 → 应命中 SKIP', s: '对方账号已自行注销或者因违反平台规则被管控,当前无法回复。', expect: true },
{ name: '仅“违反平台规则被管控”片段 → 应命中 SKIP', s: '该成员因违反平台规则被管控', expect: true },
{ name: '正常聊天 → 不应命中 SKIP', s: 'hello, please send price', expect: false },
{ name: '含“管控”但非违规语境(管控措施) → 不应误命中', s: '我们公司有严格的管控措施', expect: false },
];
for (const c of skipCases) {
const got = !!hitsSkipKeyword(c.s);
const ok = got === c.expect;
console.log((ok ? 'PASS' : 'FAIL') + ' | ' + c.name + ' -> ' + (got ? 'SKIP' : 'ok'));
ok ? pass++ : fail++;
}
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);