Files
alibaba/docs/superpowers/specs/2026-07-13-alibaba-inquiry-backend-design.md
T

311 lines
21 KiB
Markdown
Raw Normal View History

2026-08-27 16:12:37 +08:00
# 阿里询盘管理后台 设计文档
- 日期: 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 进度持久化,中断后重开继续,而非重头来)。日常增量抓取不受此影响。