21 KiB
阿里询盘管理后台 设计文档
- 日期: 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 处理)
- zod 校验 payload 结构。
- 幂等早返回:若
inquiry_id已存在 且has_failed_batches=false且messages_count+last_send_time均未变 → 直接 200 返回,跳过消息批写(避免无效重写几千条)。has_failed_batches必须参与判断,否则上次失败的批次会被早返回永远跳过(详见 R6)。 - 一个事务 upsert customer + inquiry(customer_id 可空时跳过 customer)。
- 聊天消息用原生批量
INSERT INTO chat_messages (...) VALUES (...),(...)... ON DUPLICATE KEY UPDATE content=VALUES(content), send_time=VALUES(send_time), ...,分批(每批 500 行,见 §10)执行,每批独立事务;失败批次记入失败列表,不回滚整条询盘。累计actualInserted= 各批实际落盘行数之和。 - 更新 inquiry:
messages_count = actualInserted(实际插入数,非 payload 长度)、last_send_time = max(sendTime)、has_failed_batches = (失败批次>0)。用实际插入数才能让"失败重推时 count 不一致 → 不早返回 → 失败批次自然补写"(v2 用 payload 长度是逻辑反转)。 - 返回
{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 结构
{
"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)
{
"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(结构)
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 接受(重推主场景是新增消息)。
- 失败批次重试(v3 修正):早返回已加入
- R7 首次全量回填耗时:首次把现有几千询盘全抓入库,按"每询盘几千条聊天 × API 拉取 + 推送"估算需 数小时,且浏览器必须一直打开阿里页不能关。要求插件支持分批抓取 + 可中断续抓(基于 queue/inquiryId 进度持久化,中断后重开继续,而非重头来)。日常增量抓取不受此影响。