插件
This commit is contained in:
@@ -0,0 +1,310 @@
|
||||
# 阿里询盘管理后台 设计文档
|
||||
|
||||
- 日期: 2026-07-13(修订 v3:纳入二次评审发现)
|
||||
- 状态: 修订完成,待用户审查
|
||||
- 关联: 现有 Chrome 插件位于 `d:\alibb\`
|
||||
- 修订说明: v2 修正了 spec review 发现的 4 个致命问题(host_permissions 运行时不可改 / messageId 丢失 / 队列存储布局 / 淘汰丢数据)与 8 个重要问题(询盘时间源、批量写入、alarms 周期、时区、索引、HTTP 推送、behavior 类型、外键可空)。
|
||||
- v3 修正二次评审发现:① **早返回逻辑反转**(失败批次永不重试 → inquiries 表新增 `has_failed_batches` 列 + `messages_count` 改存实际插入数);② **IndexedDB 写入上下文**(content script 的 IDB 属页面源,须由 SW 落盘)+ 双存储一致性写入顺序;③ **MySQL 8 默认 max_allowed_packet 是 64MB 非 4MB**;④ 批量行数 1000/500 统一为 **500**;⑤ direction ENUM 补 `'system'`;⑥ §8.6 补 behavior null 强制转换 / country 来源替换 / translation 双重 JSON.parse。
|
||||
|
||||
## 1. 概述
|
||||
|
||||
### 1.1 目标
|
||||
把现有 Chrome 插件(抓取阿里国际站询盘/客户/聊天)的「本地 CSV 导出」升级为完整链路:
|
||||
|
||||
> 插件自动推送 → 自建后端 → MySQL → 管理后台(查看 / 筛选 / 导出 / 统计)
|
||||
|
||||
### 1.2 使用模式(已确认)
|
||||
- **主账号统一抓取**:登录阿里主管理员账号(可看到所有业务员的询盘),**一个人操作插件**一次性抓取全部询盘。
|
||||
- **后台单管理员**:现阶段就一个管理员账号,统览全部数据。
|
||||
- 询盘记录里的 `manager`(负责人)字段天然标识业务员,后台按此字段统计/筛选,**不需要业务员各自登录或各自推送**。
|
||||
- **数据时效 = 上次手动抓取的快照**:插件靠用户在阿里页点「开始抓取」触发,**不是实时同步**;后台看到的是最近一次抓取的数据。后续可考虑定时自动抓(需浏览器保持阿里页打开)。
|
||||
|
||||
### 1.3 MVP 范围
|
||||
**纳入**:插件自动推送;后端入库(三实体幂等);管理后台(列表/详情/导出/统计)。
|
||||
**不纳入(后续)**:多业务员登录/权限;跟进管理/CRM;客户合并/标签;聊天全文搜索;复杂报表;增量推送;**后台数据编辑/删除(MVP 只读,错误数据需重抓或直接改库)**。
|
||||
|
||||
## 2. 背景:现有插件能力
|
||||
|
||||
`d:\alibb\` 的 Chrome MV3 插件已实现并验证:列表/详情/行为数据抓取;聊天 API 直拉(`querySummary.htm` → `getOpMessages.htm` 游标循环,单询盘实测 8000+ 条);chrome.storage 状态持久化。
|
||||
|
||||
**本方案对抓取主流程增量扩展,但必须改动消息映射**:
|
||||
- 现有 `apiMsgToChat()` 只产出 `{sender, time, original, translation, direction}`,**丢弃了 messageId 和毫秒时间戳**。
|
||||
- 本方案要求扩展为:`{messageId, sendTime(ISO UTC), sender, content, direction, translation}`,并从 `querySummary.htm` 响应顺带提取询盘权威时间 `gmtCreate`/`gmtModified`、买家国家等。
|
||||
- 因此 §8 的插件改造**不是纯增量**,包含对 `apiMsgToChat` 与 `fetchChatByApi` 的字段扩展。
|
||||
|
||||
## 3. 需求
|
||||
|
||||
### 3.1 功能需求
|
||||
| ID | 需求 |
|
||||
|---|---|
|
||||
| F1 | 插件每抓完一个询盘(主体 + 全量聊天)→ 自动 POST 到后端 |
|
||||
| F2 | 后端按 客户/询盘/聊天 三表存储,重复推送幂等无副作用 |
|
||||
| F3 | 后台列表:分页 + 筛选(国家/业务员/状态/时间范围)+ 关键词搜索(询价单号/买家名/公司)+ 排序 |
|
||||
| F4 | 后台详情:客户基本信息 + 行为数据卡片 + 完整聊天(虚拟滚动,左右气泡按方向) |
|
||||
| F5 | 导出:按当前筛选导出 CSV(**仅列表字段,不含聊天**;聊天在详情页单独导出) |
|
||||
| F6 | 统计:总数、按国家 Top10、按业务员分布、按月趋势(基于询盘 gmtCreate) |
|
||||
|
||||
### 3.2 非功能需求
|
||||
| ID | 需求 |
|
||||
|---|---|
|
||||
| NF1 可靠 | 推送不丢(队列持久化 + 重试 + 淘汰策略不丢 pending);入库幂等(唯一键 upsert) |
|
||||
| NF2 简单好维护 | TypeScript;Prisma schema 单一数据定义源;前后端共享类型 |
|
||||
| NF3 稳定 | 单操作者低并发;Docker 部署;MySQL 每日备份 |
|
||||
| NF4 安全 | 正式 HTTPS;插件 Bearer token;管理员 JWT(带过期 + 刷新) |
|
||||
|
||||
## 4. 技术栈
|
||||
|
||||
| 层 | 选型 | 理由 |
|
||||
|---|---|---|
|
||||
| 后端 | **Node.js 18 + TypeScript + Express + Prisma** | 和插件同语言;Prisma schema 直观;TS 类型安全 |
|
||||
| 数据库 | **MySQL 8** | 事务 + 唯一约束保证幂等 |
|
||||
| 前端 | **Vue3 + TS + Element Plus + Vite + Pinia + Vue Router** | 管理后台组件成熟 |
|
||||
| 部署 | **Docker Compose + Nginx** | 一键起 |
|
||||
| 插件 | 现有 MV3 + 推送模块(chrome.alarms + 队列 + optional permissions) | 见 §8 |
|
||||
|
||||
**决策记录:为何 Node 不选 Python**:用户优先级是"简单 + 好维护 + 稳定 + 好实现(主导搭建)",Node 全栈与插件同语言、可复用数据转换逻辑、搭建排错最快;Python 数据生态优势对 MVP 简单统计是过度设计,且引入前后端两语言复杂度。后续复杂分析可加 Python 微服务。
|
||||
|
||||
## 5. 架构与数据流
|
||||
|
||||
### 5.1 组件拓扑
|
||||
```
|
||||
[操作者电脑 · 登录阿里主账号]
|
||||
Chrome 插件
|
||||
① 抓取(保留) → 扩展消息映射(messageId/sendTime ISO/gmtCreate)
|
||||
② 每抓完一条 → 经 SW 写入【待推队列】(payload 存扩展 IndexedDB,queue_index 存 chrome.storage.local;content script 不能直接写扩展 IDB,见 §8.2)
|
||||
③ chrome.alarms(1 分钟)唤醒 SW → 消费队列 → POST /api/ingest
|
||||
↓ 失败指数退避重试,成功才出队
|
||||
[Linux 服务器 · Docker Compose]
|
||||
Nginx(client_max_body_size 50m)─┬─ /api/* → Backend
|
||||
└─ /* → Frontend 静态
|
||||
MySQL 8(数据卷 + 每日 mysqldump)
|
||||
↓ 幂等写入(唯一键: inquiry_id / customer_id / message_id)
|
||||
[管理员浏览器] 登录(JWT)→ 列表/详情/导出/统计
|
||||
```
|
||||
|
||||
### 5.2 数据流时序(/api/ingest 处理)
|
||||
1. zod 校验 payload 结构。
|
||||
2. **幂等早返回**:若 `inquiry_id` 已存在 **且 `has_failed_batches=false`** 且 `messages_count` + `last_send_time` 均未变 → 直接 200 返回,跳过消息批写(避免无效重写几千条)。`has_failed_batches` 必须参与判断,否则上次失败的批次会被早返回永远跳过(详见 R6)。
|
||||
3. 一个事务 upsert customer + inquiry(customer_id 可空时跳过 customer)。
|
||||
4. 聊天消息用**原生批量** `INSERT INTO chat_messages (...) VALUES (...),(...)... ON DUPLICATE KEY UPDATE content=VALUES(content), send_time=VALUES(send_time), ...`,**分批**(每批 500 行,见 §10)执行,每批独立事务;失败批次记入失败列表,不回滚整条询盘。累计 `actualInserted` = 各批实际落盘行数之和。
|
||||
5. 更新 inquiry:`messages_count = actualInserted`(实际插入数,**非 payload 长度**)、`last_send_time = max(sendTime)`、`has_failed_batches = (失败批次>0)`。用实际插入数才能让"失败重推时 count 不一致 → 不早返回 → 失败批次自然补写"(v2 用 payload 长度是逻辑反转)。
|
||||
6. 返回 `{ok, inserted: actualInserted, failedBatches}`。
|
||||
|
||||
## 6. 数据模型
|
||||
|
||||
四张表。关系:customers 1→多 inquiries 1→多 chat_messages。
|
||||
|
||||
### 6.1 customers(客户)
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| id | BIGINT PK auto | |
|
||||
| customer_id | VARCHAR(64) **UNIQUE** | 阿里 customerId(加密串) |
|
||||
| name | VARCHAR(128) | |
|
||||
| country | VARCHAR(64) **INDEX** | 国家(统计/筛选用) |
|
||||
| email / company | VARCHAR | |
|
||||
| register_time | DATETIME NULL | UTC,可空 |
|
||||
| avatar_url | VARCHAR(512) | |
|
||||
| buyer_tags / buyer_features | VARCHAR(255) | 逗号分隔 |
|
||||
| behavior_product_views / valid_inquiries / valid_rfq / login_days / spam_inquiries | INT **NULL** | 可空(抓不到时 null,不存 0 以区分"无数据"与"真为 0") |
|
||||
| behavior_top_industry | VARCHAR(255) | |
|
||||
| created_at / updated_at | DATETIME | UTC |
|
||||
|
||||
### 6.2 inquiries(询盘)
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| id | BIGINT PK auto | |
|
||||
| inquiry_id | VARCHAR(32) **UNIQUE** | 询价单号 |
|
||||
| customer_id | VARCHAR(64) **NULL** INDEX | FK→customers.customer_id,**可空**(抓不到 customerId 时留空) |
|
||||
| manager | VARCHAR(128) **INDEX** | 业务员名 |
|
||||
| create_time | DATETIME | UTC,源自 API `gmtCreate` |
|
||||
| update_time | DATETIME | UTC,源自 API `gmtModified`(缺失回落 gmtCreate) |
|
||||
| status | VARCHAR(64) | |
|
||||
| msg_preview | VARCHAR(512) | |
|
||||
| is_flagged | BOOLEAN | |
|
||||
| messages_count | INT DEFAULT 0 | 冗余,供幂等早返回 |
|
||||
| last_send_time | DATETIME NULL | 冗余,供幂等早返回 |
|
||||
| has_failed_batches | BOOLEAN DEFAULT FALSE | 上次推送是否有失败批次;为真则关闭早返回、强制重试(v3 新增) |
|
||||
| created_at / updated_at | DATETIME | UTC |
|
||||
|
||||
### 6.3 chat_messages(聊天明细)
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| id | BIGINT PK auto | |
|
||||
| message_id | VARCHAR(64) **UNIQUE** | 阿里 messageId(幂等键,全局唯一) |
|
||||
| inquiry_id | VARCHAR(32) **INDEX** FK→inquiries.inquiry_id | |
|
||||
| sender | VARCHAR(128) | |
|
||||
| direction | ENUM('incoming','outgoing','system') | 插件 apiMsgToChat 在 messageType 非 send/rec 时兜底产出 'system'(样本暂未出现,保留以防插入失败) |
|
||||
| send_time | DATETIME | UTC,源自毫秒戳 |
|
||||
| content | **MEDIUMTEXT** | 原文(已解码实体)。卡片消息体量待实测确认(样本中为 URL ~500 字符);MEDIUMTEXT 上限 16MB 留足余量 |
|
||||
| translation | TEXT | 翻译,从 `extInfo.icbuData.extend.extParams.inputTranslateInfo.translate` 提取(可空) |
|
||||
| created_at | DATETIME | UTC |
|
||||
|
||||
> **索引**:`UNIQUE(message_id)`、`INDEX(inquiry_id, send_time, message_id)`(游标分页复合键)。
|
||||
|
||||
### 6.4 users(管理员账号)
|
||||
| 字段 | 类型 |
|
||||
|---|---|
|
||||
| id PK / username UNIQUE / password_hash (bcrypt) / created_at |
|
||||
|
||||
> **bootstrap**:后端启动时若 users 表空,根据 `ADMIN_USERNAME` / `ADMIN_PASSWORD` 环境变量 seed 一条记录。推送 token 写 `.env`(`INGEST_TOKEN`),不建表。
|
||||
|
||||
### 6.5 时间来源与时区(修订)
|
||||
- **询盘 create_time / update_time**:用 `querySummary.htm` 返回的 `gmtCreate` / `gmtModified` 毫秒戳 → `new Date(ms).toISOString()`。**不再用"最早消息兜底"**(语义错:询盘创建 ≠ 首条消息)。列表页那个只有时分的文本时间不入库,仅必要时回退展示。
|
||||
- **消息 send_time**:用 API 毫秒戳 → ISO UTC。
|
||||
- 所有 DATETIME 存 UTC;前端用 dayjs 转本地时区展示。
|
||||
|
||||
## 7. API 设计
|
||||
|
||||
基地址 `/api`,JSON。
|
||||
|
||||
| 方法 | 路径 | 鉴权 | 说明 |
|
||||
|---|---|---|---|
|
||||
| POST | `/api/ingest` | Bearer token | 插件推送。zod 校验。限流 60/min/token。幂等早返回(见 §5.2) |
|
||||
| POST | `/api/auth/login` | - | `{username,password}` → `{token, expiresIn}` |
|
||||
| POST | `/api/auth/refresh` | JWT | 刷新 token |
|
||||
| GET | `/api/inquiries` | JWT | 列表,query: page,size,country,manager,status,dateFrom,dateTo,q(搜 inquiry_id/msg_preview/customer.name),sort |
|
||||
| GET | `/api/inquiries/:id` | JWT | 详情 + 客户 + 行为 |
|
||||
| GET | `/api/inquiries/:id/messages` | JWT | **游标分页**:query `afterSendTime` + `afterMessageId`(默认空=从最早)、size;`WHERE inquiry_id=? AND (send_time, message_id) > (?, ?) ORDER BY send_time, message_id LIMIT ?`(复合游标,避免同时间戳消息丢失) |
|
||||
| GET | `/api/inquiries/:id/messages/export` | JWT | 单询盘聊天导出(stream) |
|
||||
| GET | `/api/stats/overview` | JWT | 总数/国家 Top10/业务员/月趋势 |
|
||||
| GET | `/api/export` | JWT | **仅列表字段**(不含聊天),按筛选 stream 导出 |
|
||||
|
||||
### 7.1 `/api/ingest` payload 结构
|
||||
```jsonc
|
||||
{
|
||||
"inquiry": {
|
||||
"inquiryId": "13776617920",
|
||||
"manager": "cara Hu",
|
||||
"createTime": "2026-03-13T10:11:50Z", // 来自 gmtCreate, ISO UTC
|
||||
"updateTime": "2026-07-13T10:11:50Z", // 来自 gmtModified
|
||||
"status": "洽谈中", "msgPreview": "...", "isFlagged": false
|
||||
},
|
||||
"customer": { // customerId 为空时整个 customer 省略
|
||||
"customerId": "259b4e6f...", "name": "Carlos Loayza Marin",
|
||||
"country": "PE", "email": null, "company": "World Shop",
|
||||
"registerTime": "2025-06-23T00:00:00Z",
|
||||
"avatarUrl": "...", "buyerTags": "TM沟通", "buyerFeatures": "...",
|
||||
"behavior": { "productViews": null, "validInquiries": null, "validRfq": 39 } // 捕不到为 null
|
||||
},
|
||||
"messages": [
|
||||
{ "messageId": "4198482017852", "sendTime": "2026-07-13T02:11:50Z",
|
||||
"sender": "cara Hu", "direction": "outgoing", "content": "hello...",
|
||||
"translation": "你好..." }
|
||||
]
|
||||
}
|
||||
```
|
||||
- 空字符串数字字段在插件侧 coerce 为 null;后端 zod 接受 `number | null`。
|
||||
|
||||
## 8. 插件改造(在 `d:\alibb\` 基础上)
|
||||
|
||||
### 8.1 配置页
|
||||
popup 增「后端设置」:服务器 URL、Token、启用开关。存 chrome.storage.local。
|
||||
**首次保存 URL 时**,调用 `chrome.permissions.request({origins:[serverUrl]})`(需在用户点击手势中触发,会弹 Chrome 授权框)——因为 host_permissions 运行时不可改,见 §8.5。
|
||||
|
||||
### 8.2 待推队列(修订:IndexedDB 存 payload + 不丢 pending)
|
||||
- **payload 默认存扩展 IndexedDB**(`idb` 库),每条一个 record(key=id);chrome.storage.local 只存轻量 `queue_index`(有序 id 列表)+ 每项元数据 `{id, retries, nextRetry, status}`。避免 chrome.storage.local 在 GB 级的读写性能问题。
|
||||
- **写入上下文(关键,v3)**:content script 的 `indexedDB` 是**页面源**(`message.alibaba.com`)的,不是扩展的;只有 service worker / popup / options 才能访问扩展自己的 IndexedDB。因此正确链路是:**content.js 抓完一条 → `chrome.runtime.sendMessage` 把 payload 传给 SW → SW 落盘到扩展 IndexedDB**。绝不能让 content.js 直接写 IDB(会写到页面的库里)。
|
||||
- **双存储一致性(v3)**:IndexedDB 与 chrome.storage 之间无跨库事务。写入顺序固定为**先写 IDB payload,再追加 queue_index**(保证 index 永不引用不存在的 payload);删除反之(先标 success → 删 IDB → 移 index)。SW 启动时跑一次 GC,清理 IDB 中无 index 引用的孤儿 payload。
|
||||
- manifest 声明 `"unlimited_storage"`(IndexedDB 容量也受其约束)。
|
||||
- **不丢未推数据**:推送成功 → 立即从 IndexedDB + queue_index 删除该队列项;`pending/failed` 永不淘汰。触顶(默认 500 条 pending)→ **暂停抓取**(content.js 每抓一条前向 SW 查队列深度)+ popup 红字提示"队列拥堵,请检查后端"。
|
||||
|
||||
### 8.3 推送 worker(修订:alarms 1 分钟)
|
||||
- 用 **`chrome.alarms`**(`periodInMinutes: 1`),**不用 setInterval**(MV3 SW 会休眠)。
|
||||
- 注:打包/正式版 alarms 最短 1 分钟(开发版可临时 30s)。
|
||||
- alarm 触发 → 取最多 N(默认 3)条 pending 且到 nextRetry 的项 → POST → 成功则删除该队列项 / 失败 `retries++`、`nextRetry = now + min(2^retries, 3600)s`,超 8 次标 failed 保留。
|
||||
- **吞吐量(v3)**:1 分钟 × 3 条 = 180 条/小时 ≈ 4320 条/天。全量几千条要好几天才推完,这是异步后蹲、与"慢抓"主题一致;**抓完 ≠ 入库**,popup 的"已推/待推"计数要让使用者意识到。瓶颈在 worker 的 3/min,服务器限流 60/min(§7)永远用不到;日后想加速可提高每 tick 的 N。
|
||||
|
||||
### 8.4 状态展示
|
||||
popup 显示「已推 N / 待推 M / 失败 K」,失败可查看/重试。
|
||||
|
||||
### 8.5 manifest 改动(修订:optional permissions)
|
||||
```jsonc
|
||||
{
|
||||
"permissions": ["storage", "activeTab", "alarms", "unlimitedStorage"],
|
||||
"optional_host_permissions": ["http://*/*", "https://*/*"],
|
||||
"host_permissions": [
|
||||
"*://message.alibaba.com/*", "*://alicrm.alibaba.com/*", "*://onetalk.alibaba.com/*"
|
||||
]
|
||||
}
|
||||
```
|
||||
- 后端服务器地址**不写死在 host_permissions**(运行时不可改,且占位符非法会拒载)。
|
||||
- 用 `optional_host_permissions: ["http://*/*","https://*/*"]` + 用户在配置页通过 `chrome.permissions.request({origins:[serverUrl]})` **运行时授权**具体后端地址。
|
||||
- 替代:若插件私有/不上架,可直接 `host_permissions: ["*://*/*"]` 一劳永逸(但上架审核会问)。
|
||||
|
||||
### 8.6 抓取侧字段扩展(修订)
|
||||
- `fetchChatByApi()` 在调 `querySummary.htm` 后,**提取 `gmtCreate`/`gmtModified`/`country` 等字段**并随 payload 返回。**country 改用 `querySummary.contact.countryCode`**(样本中 `"countryCode":"PE"`,比 DOM 的 `detailCountry` 可靠),是**替换**而非新增字段。
|
||||
- `apiMsgToChat()` 扩展产出 `{messageId: String(m.messageId), sendTime: new Date(m.sendTime).toISOString(), sender, content, direction, translation}`,其中 translation 从 `m.extInfo.icbuData.extend.extParams.inputTranslateInfo.translate` 解析(失败留空)。**注意双重 JSON.parse(v3)**:`extend` 本身是 JSON 字符串,`extParams.inputTranslateInfo` 又是嵌套 JSON 字符串,需 `JSON.parse(JSON.parse(extend).extParams.inputTranslateInfo).translate` 两层解析,直接对象访问会拿到 undefined。
|
||||
- **behavior 数字字段强制转 null(v3)**:`productViews/validInquiries/validRfq/loginDays/spamInquiries` 在 content.js 现为字符串('' 或数字串),payload 序列化时空串与抓不到的值都要 coerce 为 `null`,匹配后端 zod 的 `number | null`。
|
||||
|
||||
## 9. 后台前端
|
||||
|
||||
| 路由 | 页面 | 要点 |
|
||||
|---|---|---|
|
||||
| `/login` | 登录 | |
|
||||
| `/inquiries` | 列表 | Element Plus 表格 + 筛选栏(国家/业务员/状态/时间)+ 搜索 + 排序 + 分页 + 导出按钮 |
|
||||
| `/inquiries/:id` | 详情 | 客户信息+行为卡片 + 完整聊天(虚拟滚动,游标加载) |
|
||||
| `/stats` | 统计 | 数字卡片 + 国家 Top10 饼图 + 业务员柱状图 + 月趋势折线(ECharts) |
|
||||
|
||||
性能:聊天用虚拟滚动(vue-virtual-scroller);列表分页默认 20/页。
|
||||
|
||||
## 10. 可靠性与安全
|
||||
|
||||
| # | 措施 |
|
||||
|---|---|
|
||||
| 幂等 | 唯一键 upsert + 早返回(inquiry 存在 **且 has_failed_batches=false** 且 count/lastSendTime 未变则跳过) |
|
||||
| 队列不丢 | 独立 key 持久化 + 只淘汰 success + 触顶暂停抓取 |
|
||||
| 批量写入 | 原生 `INSERT ON DUPLICATE KEY` 分批 500 行/批(见 §5.2),独立事务,失败批次隔离 |
|
||||
| 鉴权 | 插件 Bearer token(env);管理员 JWT(access 短期 + refresh) |
|
||||
| 密码 | bcrypt |
|
||||
| CORS | `/api/ingest` 用 token 鉴权,`Access-Control-Allow-Origin: *` 不做 origin 校验;后台 API 限前端域名 |
|
||||
| XSS | Vue 默认转义,禁用 v-html 渲染用户数据 |
|
||||
| body limit | Express `limit:50mb`;**nginx `client_max_body_size 50m`** |
|
||||
| MySQL | `max_allowed_packet` 设为 128MB(**MySQL 8 服务端默认 64MB,非 4MB**;4MB 是老版本/客户端库默认值,易误传);部署前 `SHOW VARIABLES LIKE 'max_allowed_packet'` 实测确认。单批 500 行(含 MEDIUMTEXT 时 500 行 × 极端 64KB ≈ 32MB,留余量) |
|
||||
| 备份 | 每日 mysqldump → gzip,保留 14 天,异地复制 |
|
||||
|
||||
## 11. 部署
|
||||
|
||||
### 11.1 docker-compose(结构)
|
||||
```yaml
|
||||
services:
|
||||
mysql: # mysql:8, 卷, healthcheck, max_allowed_packet 调大
|
||||
backend: # Node API, depends_on mysql, .env
|
||||
nginx: # 反代 + 静态 + client_max_body_size 50m; 正式加 HTTPS
|
||||
cron: # 备份 sidecar(或宿主 crontab),每日 mysqldump
|
||||
```
|
||||
|
||||
### 11.2 测试环境(无域名)
|
||||
- 公网 IP + 端口,后端 HTTP。
|
||||
- **扩展 SW 的 fetch 起源于 `chrome-extension://` 上下文,不受网页 mixed-content 规则约束**,只要 optional_host_permissions 授权了该 HTTP URL,推送正常。
|
||||
- **私网 IP(192.168/10.x)注意**:Chrome 的 Private Network Access 会要求预检;优先用公网 IP 测试。
|
||||
|
||||
### 11.3 正式环境(有域名)
|
||||
- 域名 + Nginx + Let's Encrypt(自动续期)+ 全程 HTTPS。
|
||||
|
||||
## 12. 项目结构
|
||||
|
||||
```
|
||||
d:\alibb\ # 现有插件(增量改造:推送模块 + 字段扩展)
|
||||
d:\alibb-server\
|
||||
backend\ prisma\schema.prisma + src\{routes,services,middleware,utils} + Dockerfile
|
||||
frontend\ Vue3 工程 + Dockerfile
|
||||
docker-compose.yml
|
||||
.env.example (DATABASE_URL / INGEST_TOKEN / JWT_SECRET / JWT_REFRESH_SECRET / ADMIN_USERNAME / ADMIN_PASSWORD)
|
||||
```
|
||||
|
||||
## 13. 分期
|
||||
|
||||
**MVP**:后端骨架(Express+Prisma+MySQL)+ `/api/ingest`(校验+幂等+批量)+ auth/JWT + 列表/详情/消息/统计/导出 API + 前端四件套 + 插件推送模块 + Docker + 测试部署 + 备份。
|
||||
**后续**:增量推送、聊天全文搜索、跟进管理、客户合并/标签、复杂报表、多管理员、存量 CSV 导入。
|
||||
|
||||
## 14. 风险与待确认
|
||||
|
||||
- **R1 payload 体量**:单询盘几千条约 2-5MB;body limit 50MB + nginx 50m + max_allowed_packet 64MB。>1万条极端询盘 MVP 后改分批推送。
|
||||
- **R2 增量**:MVP 全量重推 + 早返回优化(未变则跳过);日抓量大后做真增量(插件记 last sendTime)。
|
||||
- **R3 manager 重名**:字符串统计,同名合并;后续加 employees 表。
|
||||
- **R4 测试环境网络**:公网 IP+HTTP 推送无混合内容问题(扩展 fetch 独立上下文);私网 IP 需注意 Private Network Access 预检。已删除"自签 HTTPS"复杂方案。
|
||||
- **R5 customerId 缺失**:customer_id 可空,无 customerId 的询盘只存 inquiry+messages,customer 留空。
|
||||
- **R6 早返回的边界**:幂等早返回条件为 `has_failed_batches=false` 且 `messages_count + last_send_time` 未变。
|
||||
- **失败批次重试(v3 修正)**:早返回已加入 `has_failed_batches`——上次有批次失败时强制不走早返回,确保失败批次被补写。inquiries 表新增 `has_failed_batches` 列;`messages_count` 存实际插入数(payload 长度会让重推 count 恒定一致,反而触发早返回——这是 v2 的逻辑反转)。
|
||||
- **内容修订**:同 messageId 内容(如 translation)被上游修订时,早返回仍会跳过更新。MVP 接受(重推主场景是新增消息)。
|
||||
- **R7 首次全量回填耗时**:首次把现有几千询盘全抓入库,按"每询盘几千条聊天 × API 拉取 + 推送"估算需 **数小时**,且**浏览器必须一直打开阿里页**不能关。要求插件支持**分批抓取 + 可中断续抓**(基于 queue/inquiryId 进度持久化,中断后重开继续,而非重头来)。日常增量抓取不受此影响。
|
||||
@@ -0,0 +1,231 @@
|
||||
# 聊天记录按时间范围抓取 设计文档
|
||||
|
||||
- 日期: 2026-08-25
|
||||
- 状态: 待评审
|
||||
- 关联: `d:\alibb\` Chrome 扩展(content.js / popup.html / popup.js)
|
||||
- 前置讨论: 吸收三轮审查 —— ①自评 2 漏洞(§4.5 水位倒退、§4.3 until 跳游标未实测);②subagent 评审 4 建议;③深度复查 6 项(P1×3:空 list 静默 0 条 / fallback 与 RISK 抛出次序冲突 / date-only UTC 解析陷阱;P2×2:未来止日、水位检测顺序;优化:停止原因日志)及复查细化 2 项(§4.5 更新条件简化、§4.3 任务级降级标志)
|
||||
|
||||
## 1. 概述
|
||||
|
||||
### 1.1 目标
|
||||
给聊天记录抓取增加**时间范围**控制:近 1 个月 / 2 个月 / 3 个月 / 自定义起止日期。选定范围后,翻页**到范围起点即停**(不只是抓完再滤),从而大幅减少对 `getOpMessages` 的请求量 —— 时间范围同时是**风控减压手段**。
|
||||
|
||||
### 1.2 动机
|
||||
现有唯一限制是条数(`chatLimit`,换算成翻页轮数上限)。条数与"我要最近 N 个月"的真实意图不匹配;且全量翻页对老客户动辄几百轮请求(每轮 2-5s 慢速),风控暴露面大。
|
||||
|
||||
## 2. 已确认决策(用户拍板)
|
||||
|
||||
| # | 决策点 | 结论 |
|
||||
|---|---|---|
|
||||
| D1 | 时间范围 vs 条数 | **时间替代条数**:选时间模式后条数输入忽略、置灰 |
|
||||
| D2 | 与增量机制关系 | **增量仍然生效**:增量命中 / 时间边界到,任一先到先停 |
|
||||
| D3 | UI 形态 | 下拉:全部(默认,同现状)/ 近1月 / 近2月 / 近3月 / 自定义;自定义显示起止日期框 |
|
||||
| D4 | 抓取路径 | **API 慢速 + 时间边界停**(否决 DOM 滚动为主 / CDP,理由见 §3) |
|
||||
|
||||
## 3. 方案选型(为什么是 API + 时间边界)
|
||||
|
||||
- **DOM 滚动为主被否决**:① DOM 滚动触发的底层请求仍是 `getOpMessages`(页面自己发),并非"不调 API";② 驱动事件是合成 `WheelEvent`,`isTrusted=false` 是**阿里已确认检测的指纹**(现有代码注释记载:折叠面板只接受真实点击);③ 慢 10 倍以上、图片懒加载丢数据、平台期误判截断。
|
||||
- **CDP(chrome.debugger)可信事件被否决**:常驻"正在调试此浏览器"提示条、实现复杂、速度仍慢。
|
||||
- **后端过滤被否决**:不省请求、白传数据、旧数据管不了。
|
||||
- **API 慢速路径实测战绩**:7-15 全量 4576 询盘 + 68058 条消息零风控触发。
|
||||
|
||||
## 4. 详细设计
|
||||
|
||||
### 4.1 UI(popup.html / popup.js)
|
||||
|
||||
options 区"每询盘聊天上限"行下方新增一行 **聊天时间范围**:
|
||||
|
||||
```html
|
||||
<select id="chatTimeRange">
|
||||
<option value="all" selected>全部(不限时间)</option>
|
||||
<option value="1m">近 1 个月</option>
|
||||
<option value="2m">近 2 个月</option>
|
||||
<option value="3m">近 3 个月</option>
|
||||
<option value="custom">自定义日期…</option>
|
||||
</select>
|
||||
<!-- custom 时显示 -->
|
||||
<input type="date" id="chatSinceDate"> ~ <input type="date" id="chatUntilDate">
|
||||
```
|
||||
|
||||
- 选 `custom` → 显示起止框,**止默认今天**;
|
||||
- 选任何时间模式(非 all)→ `chatLimitInput` 置灰 + 旁注"(时间模式下忽略)";切回 all → 恢复可用。两模式互斥。
|
||||
- popup.js 在 start 时计算并传参(**本地时区**):
|
||||
- `all` → `chatSince: null, chatUntil: null`(完全等同现状,回归安全);
|
||||
- `1m/2m/3m` → `chatSince = minusMonths(今日0点, N)`(**自然月整天边界**,起始日 00:00:00.000,不保留当前时分秒 —— 否则起始日当天早间消息被切),`chatUntil = 今日 23:59:59.999`("至今"取今天整天,与 custom 止=今天同一路径);
|
||||
- `custom` → `chatSince = 起日 T00:00:00.000`,`chatUntil = 止日 T23:59:59.999`(含止日全天);
|
||||
- **校验(popup 报错不发 start)**:起 > 止 / 起或止为空 / **止日 > 今天**(未来止日会造出未来 cursor,属未实测形态);
|
||||
- ⚠️ **实现陷阱(date-only UTC)**:`new Date('2026-08-25')` 按 **UTC** 解析,中国时区差 8 小时,止日凌晨的消息会被静默滤掉。必须写 `new Date(v + 'T00:00:00')`(带时间部分 → 本地解析)或 `new Date(y, m-1, d)`。verify 脚本有用例锁定。
|
||||
|
||||
### 4.2 参数传递与状态(content.js)
|
||||
|
||||
- start 消息新增 `chatSince` / `chatUntil`(ISO 字符串或 null),存入 `state` 并随 `saveState()` 持久化 —— 复用现有跨导航/暂停恢复/扩展重载的状态链路,零额外机制。注意:现有 start 分支是**全量重建 state 对象**,需把这两个字段显式加入重建清单(漏写会变 undefined,虽按 null 容错可用,但显式更清晰)。
|
||||
- 进入抓取时派生一次毫秒值:`sinceMs = chatSince ? Date.parse(chatSince) : null`(untilMs 同理),后续循环只用毫秒比较。
|
||||
- **时间模式下 popup 一律发 `chatLimit: 0`**:使 API 路径(MAX_ROUNDS→2000)与 DOM 回退路径(→300)两条现有公式都自然解析为全量轮数,无需对称修改换算逻辑。
|
||||
|
||||
### 4.3 API 主路径(fetchChatByApi)改动
|
||||
|
||||
翻页循环(现有停止条件:增量命中 hitLastTop / list.length < 20 到顶 / MAX_ROUNDS)增加:
|
||||
|
||||
1. **初始游标**:`cursor = (untilMs != null) ? untilMs + 1 : Date.now()`
|
||||
- +1ms 缓冲:宁可多取一轮,由批内过滤兜底,确保 until 邻近消息不漏(§6.2);
|
||||
- 自定义过去截止日时直接从截止日开始往回翻,**省掉 now→until 之间的全部请求**。
|
||||
2. **收集过滤**(批内跨边界):`raw.push(m)` 仅当
|
||||
`m.sendTime == null || ((!sinceMs || m.sendTime >= sinceMs) && (!untilMs || m.sendTime <= untilMs))`
|
||||
—— sendTime 为空的消息**防御性保留**(与 §5 约定一致;若不显式加此 guard,`undefined >= sinceMs` 为 false 会把无时间消息错误丢弃)。
|
||||
**顺序约定:水位命中检测遍历未过滤的原始 list(现有口径),命中即停;未命中的消息再套范围过滤决定 push。** 禁止先 `list.filter(范围)` 再查水位 —— 水位消息落在范围外时行为会漂移。
|
||||
3. **翻页停止**:每轮游标更新后,`if (sinceMs && minT < sinceMs) → break`,日志记 `时间边界提前停止(早于 <since ISO>)`。
|
||||
4. MAX_ROUNDS 在时间模式下固定 2000(安全阀防死循环;正常由时间/增量/到顶停)。
|
||||
5. 日志:范围模式下每询盘开始抓聊天时输出当前范围(`聊天范围: 2026-07-25 ~ 至今` / `~ 2026-08-31`;all 模式无此日志);完成日志统一带**停止原因**:`聊天 API 完成: N 条(停:时间边界/增量命中/到顶/空列表/轮数上限)` —— 手动验证一眼分辨是哪种停。
|
||||
|
||||
**until 跳游标的保守 fallback(首轮专项)**:**2026-08-25 已实测**(附录 B):过去时间戳 cursor 完全受支持,语义 = 返回 sendTime **严格小于** timeStamp 的 N 条(方向与现网一致);`untilMs+1` 缓冲恰好覆盖含止日全天。fallback 保留为**保险**(实测样本单一:1 账号 1 会话,阿里行为可能变化),并按实测精化:
|
||||
|
||||
- **触发条件**(仅 `untilMs != null` 时的**首轮**,且仅首轮):
|
||||
1. `PROBE_FAIL` 形态:非对象 / code≠200 / data=null —— 参数异常;
|
||||
2. **返回空 list** —— 实测(附录 B 组)表明这大概率是"该会话在 until 前确无消息"(真空,如新询盘),但单次响应无法与"不支持"区分,仍降级以保正确(空 list 会被现有"正常到顶"分支 [content.js:816](content.js#L816) 静默吞掉,是真静默丢数据形态)。
|
||||
- **实现次序(P1-2)**:首轮结构断言单独 try 包住,抛**独立前缀** `PROBE_FAIL`(勿复用 RISK 前缀,避免误吞 `RISK:结构异常(non-JSON)`);降级集 = {PROBE_FAIL, 空 list};postForm 自身抛的 RISK(HTTP 非 200 / 关键词 / non-JSON)在 try 之外,照常冒泡暂停,不得吞掉。
|
||||
- **降级动作**:cursor 重置 `Date.now()`,**同循环 continue** 从现在往回翻到 since 停(批内过滤兜 until 上界);重试首批仍空 list = 真空(正常到顶),不递归降级。
|
||||
- **任务级标志(final review 再收紧)**:仅当**降级原因为 'fail'(结构异常)且降级重试首批非空**才设 `state.cursorJumpUnsupported = true`。'empty'(空 list)多为"会话在 until 前本无消息"的真空(附录 B 组形态)—— 据此设标志会把跳游标误禁一整个任务(所有询盘退化为 now 全量回翻,风控暴露面回升),故 empty 降级保正确性但不设标志。实现以局部变量 `downgradeKind` 记录原因。
|
||||
|
||||
### 4.4 DOM 回退路径(loadAllChatHistory,API 失败才走)
|
||||
|
||||
- **全量分支**:滚动循环每轮**轻量探测**——只读**第一个** `.message-item-wrapper` 的时间 span(聊天 DOM 顶部=最旧;不要全量提取,几千条时每轮全提性能差),若其 sendTime 早于 sinceMs → 停止往上滚(再滚加载的全是范围外)。
|
||||
- 滚动结束后 `extractDomChatMessages()` 提取,再按 since/until 过滤后才挂到 `state._incDomMessages` / chatMessages。
|
||||
- **增量分支**:现有 `sendTime > lastDomTime` 过滤后,**再叠加** since/until 过滤(两条件都满足才收)。
|
||||
- **预期降级(非 bug)**:增量分支只取页面已显示的最新 N 条、从不向过去滚动 —— 自定义**过去**截止日时,叠加过滤后可能产出 0 条。这是回退路径的可接受退化(API 路径才是主路径),勿当 bug 上报。
|
||||
- `lastDomTime` 水位计算沿用**未过滤**集合的 max(水位反映"DOM 已见到的最新",过滤只影响推送)—— 现有 max 逻辑天然单调前进,无需改动。
|
||||
|
||||
### 4.5 增量水位只前进修复(自评漏洞 #1)
|
||||
|
||||
**问题**:自定义过去截止日(如 6-01~6-30)抓完后,`lastTopMsgId` 会被覆盖成本轮翻到的最新(≈6-30),把原 7-28 的水位**倒退** → 下次增量要多翻整月已抓过的消息(数据不脏,后端 skipDuplicates,但请求白费、风控暴露面白增)。
|
||||
|
||||
**修复**:`lastTopMsg_<inquiryId>` 存储结构改为 JSON `{ id, sendTime }`:
|
||||
- 读取兼容旧格式(纯 string 视为 `sendTime` 未知 → 允许覆盖一次,升级到新格式);
|
||||
- 更新条件简化为:`候选水位.sendTime > 旧水位.sendTime` 才写入(**水位只前进不倒退**)。原实现的 `raw.length > 0` 附加条件**移除** —— 它在旧格式(id 不可比时间)下防误清,新格式的只前进比较本身已安全;保留反而导致"本轮全被 until 滤空、但确实见到更新消息"时水位保守不前,下次多翻;
|
||||
- 候选水位取**各轮见过的最大 sendTime**(未过滤 list 口径)。注意与现状的差异:现有实现只在首批设一次(`if (newestMsgIdThisRound === null && ...)`,[content.js:818-823](content.js#L818-L823)),fallback 降级重翻后首批(更新侧)无法再上调候选 → 水位保守停在 probe 轮的旧值,下次多翻。改为逐轮取 max 后此问题消除。
|
||||
|
||||
注:DOM 侧 `lastDomTime` 是毫秒时间戳比较,天然单调,无需此修复。
|
||||
|
||||
### 4.6 不改动的部分
|
||||
|
||||
- 同客户聊天去重(`seenChatCustomer`)、`chatSkipped` 标记、后端归代表:不受影响;
|
||||
- API 返回空数组 `[]` = 成功无新增(不触发 DOM 回退)的现有语义:不变;
|
||||
- CSV 导出、enqueue 推送、payload.js 映射、后端/前端:**零改动**(过滤在扩展侧完成,messages 照常流转);
|
||||
- 询盘列表抓取、详情页客户资料/行为数据:**不受时间范围影响**(时间范围只作用于聊天)。
|
||||
|
||||
## 5. 边界语义与容错
|
||||
|
||||
| 项 | 约定 |
|
||||
|---|---|
|
||||
| 含边界 | `sendTime >= since` 且 `<= until`;自定义含止日全天(23:59:59.999) |
|
||||
| 近 N 月 | 自然月,非 30 天,**整天边界**:since=起始日 00:00:00.000、until=今日 23:59:59.999(需求变更 2026-08-25:不保留当前时分秒)。⚠️ 不能直接 `setMonth(-N)`:月末日期溢出(3-31 − 1月 → "2-31" 溢出成 3-02,since 晚一个月**漏抓整月**)。须用 `minusMonths`(退到月初再钳制到目标月最后一天);verify 脚本有用例锁定 |
|
||||
| 时区 | since/until 按用户**本地时区**构造(popup `new Date()`),与 API 毫秒时间戳、DOM 本地时间解析一致 |
|
||||
| parse 失败 | chatSince/chatUntil 非法 → 视为 null(降级为不限),日志提示,**不中断抓取** |
|
||||
| 消息无 sendTime | 防御性保留(宁多勿漏);实际 API 消息均带 sendTime |
|
||||
| 起止校验 | custom 起>止 / 空 / 止日>今天 → popup 报错不发 start |
|
||||
|
||||
## 6. 语义澄清(用户预期管理)
|
||||
|
||||
1. **范围 ≠ 删除**:时间范围只控制"这次抓什么",**不会删除后端已有历史**。库内数据 = 历次抓取的并集(后端按 messageId skipDuplicates)。改选更短范围重抓,旧数据仍在。
|
||||
2. **增量水位对"过去区间"天然无效**:水位只优化"追加新消息"场景;自定义过去区间翻不到水位属正常,由 since 边界停。
|
||||
3. **抓取正确性承诺**:范围内一条不漏(边界含)、范围外一条不进(批内过滤)、已抓过的不重复翻(水位只前进 + 增量命中停)。
|
||||
|
||||
## 7. 验证方案
|
||||
|
||||
### 7.1 自动验证(content.js 为 IIFE,无法被 vitest import → 沿用复刻脚本模式)
|
||||
新增 `scripts/verify-chat-range.mjs`(与 `verify-risk-detect.mjs` 同模式,文件头注明与 content.js 同步义务),覆盖:
|
||||
- 自然月计算(跨年:3 月 31 日 - 1 月 = 2 月 28/29?边界行为锁定);
|
||||
- 边界含/不含(since 当天消息收、since 前一天不收);
|
||||
- 跨边界批过滤(一页 20 条横跨 since);
|
||||
- 水位只前进(旧 {id,sendTime=7-28} 不被新 sendTime=6-30 覆盖;旧 string 格式兼容);
|
||||
- cursor 缓冲(untilMs+1)与 parse 容错(null → 不限);
|
||||
- date 输入**本地时区**构造(`new Date(v+'T00:00:00')` 非 UTC 解析,锁定 8-25T00:00 本地毫秒);
|
||||
- fallback 触发判定(空 list / 结构异常 code、data=null 两形态 → 降级;HTTP/关键词/non-JSON 真风控形态不降级);
|
||||
- 水位更新:候选**逐轮取 max**(覆盖 fallback 降级重翻场景)+ 只前进写入(sendTime 更大才写;raw 为空也可前进)。
|
||||
|
||||
### 7.2 手动验证清单
|
||||
1. 老客户询盘选"近 1 个月" → 日志出现 `时间边界提前停止`,条数远小于全量;
|
||||
2. 自定义 起>止 → popup 报错不发;
|
||||
3. 选"全部" → 行为与当前版本完全一致(回归);
|
||||
4. **custom 含过去截止日 → 首轮盯 SW Network**(附录 B 已实测通过,此项为实现上线后的复测):`getOpMessages` 请求体 timeStamp 是否为 untilMs+1、返回是否正常;
|
||||
5. 同询盘先"近3月"再"近1月"重抓 → 第二次条数显著少(增量)且无重复推送。
|
||||
|
||||
## 8. 风险与开放问题
|
||||
|
||||
| 风险 | 缓解 |
|
||||
|---|---|
|
||||
| ~~cursor 传过去时间戳的 API 行为未实测~~ → **已实测(2026-08-25,附录 B)**:受支持,语义=严格小于,+1ms 缓冲正确;残余风险 = 样本单一(1 账号/1 会话) | 保留首轮 fallback 保险(PROBE_FAIL / 空 list → 降级 now)+ 标志仅在"降级重试首批非空"时设置 + 实现后手动清单 #4 复测 |
|
||||
| 复刻验证脚本与 content.js 漂移 | 脚本头注明同步义务(同 verify-risk-detect 现状,可接受的架构权衡) |
|
||||
| 时间模式 + 很老的自定义起点 | MAX_ROUNDS=2000 安全阀兜底;用户自选范围,预期内 |
|
||||
|
||||
## 9. 涉及文件
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| popup.html | 时间范围下拉 + 自定义日期框 + 条数置灰逻辑的 UI |
|
||||
| popup.js | 计算 since/until(自然月/含全天/校验)、置灰切换、start 传参 |
|
||||
| content.js | state 字段、fetchChatByApi(游标/过滤/停止/水位)、loadAllChatHistory(探测/过滤)、lastTopMsg 存储结构 |
|
||||
| scripts/verify-chat-range.mjs | 新增,复刻判定逻辑的自动验证 |
|
||||
|
||||
## 附录 A:§4.3 修订后翻页循环伪代码(实现基准)
|
||||
|
||||
§4.3 的 7 处改动(游标/探测降级/过滤/顺序/停止/日志/水位)在此串成完整循环,实现以此为准:
|
||||
|
||||
```text
|
||||
// 前置:sinceMs/untilMs 已从 state.chatSince/chatUntil 派生(§4.2);lastTop = 读水位(§4.5)
|
||||
jump = untilMs != null && !state.cursorJumpUnsupported
|
||||
cursor = jump ? untilMs + 1 : Date.now()
|
||||
probeRound = jump // 仅跳游标首轮为探测轮
|
||||
candidate = null // 候选水位 {id, sendTime},逐轮取 max(§4.5)
|
||||
stopReason = '轮数上限'
|
||||
for i in 0..MAX_ROUNDS-1:
|
||||
if state.phase == 'paused': return null
|
||||
await waitUntilWorkTime()
|
||||
json = postForm(getOpMessages, { timeStamp: cursor, forward: false, pageSize: 20, ... })
|
||||
if probeRound:
|
||||
probeRound = false
|
||||
try:
|
||||
assertStructure(json) // 非对象 / code≠200 / data=null → throw PROBE_FAIL(独立前缀,勿复用 RISK 前缀)
|
||||
if emptyList(json): throw PROBE_FAIL
|
||||
catch e:
|
||||
if e 是 PROBE_FAIL: // 仅降级集;postForm 的 RISK(non-JSON/HTTP/关键词)在 try 外照常冒泡
|
||||
// ⚠️ 勘误(实现以 §4.3 收紧版为准):此处不立即设 cursorJumpUnsupported;
|
||||
// 实现记 downgradeKind,仅 kind='fail' 且降级重试首批非空才设(empty 多为真空,勿误禁全任务)
|
||||
downgradeKind = kind
|
||||
log('until 跳游标不受支持,降级 cursor=now')
|
||||
cursor = Date.now()
|
||||
await randSleep(2000, 5000)
|
||||
continue // 重试按常规逻辑,不再探测;仍空 list = 正常到顶,不递归降级
|
||||
throw e // 真风控(HTTP/关键词/non-JSON)照常抛出暂停
|
||||
else:
|
||||
assertStructure(json) // 常规轮:结构异常直接 RISK
|
||||
list = json.data?.list || []
|
||||
if list 为空: stopReason = '到顶(空列表)'; break
|
||||
for m of list: // 候选水位:逐轮最大 sendTime,未过滤口径(§4.5)
|
||||
if m.sendTime != null && (candidate == null || m.sendTime > candidate.sendTime):
|
||||
candidate = { id: String(m.messageId), sendTime: m.sendTime }
|
||||
hit = false
|
||||
for m of list: // 顺序:水位命中查未过滤 list,再范围过滤收集(§4.3.2)
|
||||
if lastTop && String(m.messageId) == lastTop.id: hit = true; break
|
||||
if m.sendTime == null || (!sinceMs || m.sendTime >= sinceMs) && (!untilMs || m.sendTime <= untilMs):
|
||||
raw.push(m)
|
||||
cursor = min(list 中非 null 的 sendTime) // null 必须跳过(与现状 [content.js:830-831] 一致);Math.min 含 null 得 0 → 游标跳 1970
|
||||
if hit: stopReason = '增量命中'; break
|
||||
if sinceMs && cursor < sinceMs: stopReason = '时间边界'; break
|
||||
if list.length < 20: stopReason = '到顶'; break
|
||||
await randSleep(2000, 5000)
|
||||
log(`聊天 API 完成: ${raw.length} 条(停:${stopReason})`)
|
||||
if candidate && (旧水位为旧格式string || candidate.sendTime > 旧水位.sendTime):
|
||||
写入 candidate // 水位只前进(§4.5)
|
||||
```
|
||||
|
||||
## 附录 B:过去时间戳 cursor 实测记录(2026-08-25)
|
||||
|
||||
**环境**:用户登录态,询盘详情页(imInquiryId=13908460211,会话共 25 条消息),页面上下文复刻 content.js 的 postForm(**无签名头**),请求间隔 2.5s,共 4 次请求,未触发任何风控。
|
||||
|
||||
| 组 | cursor | HTTP/code | 返回 | 结论 |
|
||||
|---|---|---|---|---|
|
||||
| A 对照 | `now` | 200/"200" | 20 条(最新 20 条,oldest=8-24T13:06:27.788Z) | 现网行为正常 |
|
||||
| B 过去30天 | `now−30d` | 200/"200" | **空 list,完全静默** | 该会话是新询盘,30 天前确无消息 → 空是**正确语义的空**;同时证明空 list 形态真实存在且与"不支持"无法从单次响应区分(P1-1 降级必要性成立) |
|
||||
| C 边界 | =A 批最旧消息 sendTime | 200/"200" | 5 条,全部**严格小于** cursor(不含 cursor 那条),与 A 批无缝衔接 | ① 语义 = `sendTime < timeStamp`(严格小于,向前翻);② **过去时间戳受支持、方向正确**;③ 翻页连续(cursor=minT 下批不重不漏);④ `untilMs+1` 恰好覆盖 `<= untilMs` 全部消息 |
|
||||
|
||||
**衍生结论**:① until 跳游标可用(省 now→until 全部请求);② +1ms 缓冲正确且必要;③ 空 list 二义性真实 → fallback 保留,但任务级标志仅在"降级重试首批非空"时设置(§4.3 实测精化);④ 复刻 postForm 无签名头直接可用,佐证 §3 选型。
|
||||
Reference in New Issue
Block a user