Files
alibaba/docs/superpowers/specs/2026-07-13-alibaba-inquiry-backend-design.md
T
2026-08-27 16:12:37 +08:00

21 KiB
Raw Blame History

阿里询盘管理后台 设计文档

  • 日期: 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 结构

{
  "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 接受(重推主场景是新增消息)。
  • R7 首次全量回填耗时:首次把现有几千询盘全抓入库,按"每询盘几千条聊天 × API 拉取 + 推送"估算需 数小时,且浏览器必须一直打开阿里页不能关。要求插件支持分批抓取 + 可中断续抓(基于 queue/inquiryId 进度持久化,中断后重开继续,而非重头来)。日常增量抓取不受此影响。