插件
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
# alibb 生产部署文档(1Panel + OpenResty + MySQL 8.4)
|
||||
|
||||
> 基于 2026-08-27 实际生产部署整理(服务器重装后完整恢复验证通过)。
|
||||
> 与 2026-07-15 首次部署的差异见 §8。恢复/迁移专用 runbook 见
|
||||
> [2026-08-25-server-restore-redeploy.md](superpowers/plans/2026-08-25-server-restore-redeploy.md)。
|
||||
|
||||
## 1. 架构
|
||||
|
||||
```
|
||||
浏览器扩展(抓 alibaba 询盘)
|
||||
│ POST /api/ingest(Bearer INGEST_TOKEN)
|
||||
▼
|
||||
http://219.157.255.213:23010 ── OpenResty 站点「alibaba」(1Panel 面板管理,host 网络)
|
||||
├─ / → 前端 dist 静态(/opt/1panel/www/sites/alibaba/index)
|
||||
└─ /api/* → http://127.0.0.1:8090(反代,client_max_body_size 50m)
|
||||
│
|
||||
▼
|
||||
alibb-backend 容器(Express + Prisma,127.0.0.1:8090→8080)
|
||||
│
|
||||
▼
|
||||
1Panel MySQL 8.4(容器 1Panel-mysql-YL0F,库 alibaba)
|
||||
```
|
||||
|
||||
管理后台:浏览器开 http://219.157.255.213:23010 → admin 登录(密码见服务器 `~/alibb-secrets.txt`)。
|
||||
|
||||
## 2. 环境与信息总表
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| 服务器 | 阿里云 ECS `219.157.255.213`,Ubuntu 26.04 LTS |
|
||||
| 登录用户 | `kt`(sudo 免密);SSH 密钥 `C:\Users\30997\.ssh\alibb_deploy` |
|
||||
| SSH(本机) | `/c/Windows/System32/OpenSSH/ssh.exe -i 'C:\Users\30997\.ssh\alibb_deploy' kt@219.157.255.213`(Git Bash 自带 ssh 因私钥权限 644 不可用) |
|
||||
| Docker 网络 | `1panel-network`(bridge,1Panel 安装时自动创建) |
|
||||
| MySQL | 容器 `1Panel-mysql-YL0F`,镜像 mysql:8.4.11,`127.0.0.1:3306`,root 密码见容器 env `MYSQL_ROOT_PASSWORD` |
|
||||
| 业务库/账号 | 库 `alibaba`(utf8mb4),用户 `alibaba@%`,密码 `wecbni5zRRnZrMMh`,授权 `ALL ON alibaba.*` |
|
||||
| 后端 | 容器 `alibb-backend`,代码 `~/alibb-server/backend`,`127.0.0.1:8090→8080`,restart unless-stopped |
|
||||
| OpenResty | 容器 `1Panel-openresty-Seym`,**host 网络模式**,管理 80/443/23010 |
|
||||
| 站点 | 1Panel 面板管理,代号 **alibaba**;目录 `/opt/1panel/www/sites/alibaba/`(index/ 前端、log/ 日志) |
|
||||
| 站点 conf | `/opt/1panel/www/conf.d/alibaba.conf`(面板生成的 server 块 + 手工并入的 SPA fallback 与 /api 反代) |
|
||||
| 对外端口 | 23010(云安全组已放行);8090/3306 仅本机 |
|
||||
| 生产密钥 | JWT_SECRET / INGEST_TOKEN / ADMIN_USERNAME / ADMIN_PASSWORD 内嵌于 `~/alibb-server/backend/docker-compose.yml`;明文副本 `~/alibb-secrets.txt`(600) |
|
||||
| 本机代码 | 扩展 `d:\alibb`(master);后端 `d:\alibb-server\backend`(master,compose 为 **dev 版**);前端 `d:\alibb-server\frontend`(main) |
|
||||
| 备份 | 本机 `D:\alibb-server\backups\`(权威);服务器副本 `~/alibb-backup-20260825/` |
|
||||
|
||||
## 3. 从零部署(完整步骤)
|
||||
|
||||
> 以下命令全部在 2026-08-27 实际验证。分两段:本机(Windows/Git Bash)与服务器(SSH)。
|
||||
|
||||
### 3.1 SSH 准备
|
||||
|
||||
```bash
|
||||
# 首次:云控制台 VNC 或 1Panel 面板「主机→终端」,root 执行(公钥替换成自己的)
|
||||
useradd -m -s /bin/bash kt; echo "kt ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/kt; \
|
||||
mkdir -p /home/kt/.ssh; echo "<本机公钥内容>" > /home/kt/.ssh/authorized_keys; \
|
||||
chown -R kt:kt /home/kt/.ssh; chmod 700 /home/kt/.ssh; chmod 600 /home/kt/.ssh/authorized_keys
|
||||
|
||||
# 本机清除旧 host key(重装后必做,否则报 HOST IDENTIFICATION HAS CHANGED)
|
||||
/c/Windows/System32/OpenSSH/ssh-keygen.exe -R 219.157.255.213
|
||||
```
|
||||
|
||||
### 3.2 系统基础
|
||||
|
||||
```bash
|
||||
# Docker
|
||||
curl -fsSL https://get.docker.com | sudo sh
|
||||
sudo tee /etc/docker/daemon.json <<'EOF'
|
||||
{ "registry-mirrors": ["https://docker.1panel.live"] }
|
||||
EOF
|
||||
sudo systemctl restart docker
|
||||
# 1Panel(官方脚本,装时设面板密码)
|
||||
curl -sSL https://resource.fit2cloud.com/1panel/package/quick_start.sh -o quick_start.sh && sudo bash quick_start.sh
|
||||
```
|
||||
|
||||
### 3.3 1Panel 应用
|
||||
|
||||
面板 → 应用商店安装:
|
||||
|
||||
- **OpenResty**(1.31+,host 网络模式)
|
||||
- **MySQL 8.4**(装完记容器名,形如 `1Panel-mysql-XXXX`,下称 `<MYSQL容器名>`)
|
||||
|
||||
### 3.4 数据库
|
||||
|
||||
方式 A(面板):数据库 → 创建,库名 `alibaba`,用户 `alibaba`,权限 `alibaba.*`(密码设 `wecbni5zRRnZrMMh` 与 compose 一致,或建完后 ALTER)。
|
||||
|
||||
方式 B(命令,推荐):
|
||||
```bash
|
||||
M=<MYSQL容器名>
|
||||
R=$(sudo docker inspect $M --format '{{range .Config.Env}}{{println .}}{{end}}' | grep MYSQL_ROOT_PASSWORD | cut -d= -f2)
|
||||
sudo docker exec -i -e MYSQL_PWD=$R $M mysql -uroot <<'SQL'
|
||||
CREATE DATABASE alibaba CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
|
||||
CREATE USER 'alibaba'@'%' IDENTIFIED BY 'wecbni5zRRnZrMMh';
|
||||
GRANT ALL PRIVILEGES ON `alibaba`.* TO 'alibaba'@'%' WITH GRANT OPTION;
|
||||
FLUSH PRIVILEGES;
|
||||
SQL
|
||||
# 验证
|
||||
sudo docker exec -e MYSQL_PWD=wecbni5zRRnZrMMh $M mysql -ualibaba -e "SELECT 1;"
|
||||
```
|
||||
|
||||
导入存量数据(有备份时):
|
||||
```bash
|
||||
sudo docker exec -i -e MYSQL_PWD=$R $M mysql -uroot < alibaba-db.sql
|
||||
```
|
||||
|
||||
### 3.5 后端
|
||||
|
||||
```bash
|
||||
# 本机推代码(排除 node_modules/dist/.git)
|
||||
cd /d && tar czf /tmp/alibb-server.tgz --exclude='alibb-server/*/node_modules' --exclude='*/dist' --exclude='*/.git' alibb-server
|
||||
/c/Windows/System32/OpenSSH/scp.exe -i 'C:\Users\30997\.ssh\alibb_deploy' /tmp/alibb-server.tgz kt@219.157.255.213:/tmp/
|
||||
# 服务器解包
|
||||
ssh ... 'tar xzf /tmp/alibb-server.tgz -C ~'
|
||||
```
|
||||
|
||||
**compose 关键项**(服务器生产版,本地 dev compose 上线前必须对齐):
|
||||
```yaml
|
||||
services:
|
||||
backend:
|
||||
container_name: alibb-backend
|
||||
build: .
|
||||
ports: ["127.0.0.1:8090:8080"]
|
||||
environment:
|
||||
DATABASE_URL: "mysql://alibaba:wecbni5zRRnZrMMh@<MYSQL容器名>:3306/alibaba?charset=utf8mb4&timezone=Z"
|
||||
INGEST_TOKEN / JWT_SECRET / ADMIN_USERNAME / ADMIN_PASSWORD: 生产值(强随机,首次生成见下)
|
||||
NODE_ENV: production
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
1panel-network:
|
||||
external: true
|
||||
```
|
||||
|
||||
首次生成密钥(已有备份则沿用旧值,扩展/老 JWT 不失效):
|
||||
```bash
|
||||
openssl rand -hex 24 # INGEST_TOKEN
|
||||
openssl rand -hex 32 # JWT_SECRET
|
||||
openssl rand -hex 16 # ADMIN_PASSWORD
|
||||
```
|
||||
|
||||
构建启动(注意 MySQL 容器名替换):
|
||||
```bash
|
||||
cd ~/alibb-server/backend
|
||||
sed -i "s/<旧MYSQL容器名>/<新MYSQL容器名>/g" docker-compose.yml
|
||||
sudo docker compose build backend && sudo docker compose up -d
|
||||
sudo docker compose logs backend | tail # 期望:No pending migrations + listening on 8080
|
||||
```
|
||||
|
||||
### 3.6 站点 + 前端
|
||||
|
||||
**先在 1Panel 面板建站**(网站 → 创建网站):类型**静态网站**,主域名 `219.157.255.213`,端口 `23010`,代号 `alibaba`。
|
||||
> 注意:面板检查端口占用——若 conf.d 里有手工 conf 占着 23010 会报"端口已被占用",先移除再建。
|
||||
|
||||
面板会生成 `/opt/1panel/www/sites/alibaba/` 和 `/opt/1panel/www/conf.d/alibaba.conf`。然后合并前端与反代:
|
||||
|
||||
```bash
|
||||
S=/opt/1panel/www/sites/alibaba
|
||||
# 前端 dist(备包直解最快;index 子目录在面板建站时已建)
|
||||
sudo tar xzf alibb-frontend-dist.tgz -C $S/ && sudo chmod -R a+rX $S/index
|
||||
# 或源码构建:
|
||||
# cd ~/alibb-server/frontend && sudo docker run --rm -v "$PWD":/app -w /app node:22-alpine sh -c 'npm ci && npm run build'
|
||||
# sudo rm -rf $S/index/* && sudo cp -r dist/. $S/index/ && sudo chmod -R a+rX $S/index
|
||||
|
||||
# 在 conf 的 root 行后并入 SPA fallback + /api 反代(host 网络 → 必须宿主端口,不能用容器名)
|
||||
sudo sed -i "/root \/www\/sites\/alibaba\/index;/a\\
|
||||
location / { try_files \$uri \$uri/ /index.html; }\\
|
||||
location ^~ /api/ {\\
|
||||
proxy_pass http://127.0.0.1:8090;\\
|
||||
client_max_body_size 50m;\\
|
||||
proxy_set_header Host \$host;\\
|
||||
proxy_set_header X-Real-IP \$remote_addr;\\
|
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;\\
|
||||
}" /opt/1panel/www/conf.d/alibaba.conf
|
||||
```
|
||||
|
||||
重载 OpenResty:
|
||||
```bash
|
||||
sudo docker exec 1Panel-openresty-Seym openresty -t && sudo docker exec 1Panel-openresty-Seym openresty -s reload
|
||||
```
|
||||
|
||||
### 3.7 安全组
|
||||
|
||||
云控制台放行 **TCP 23010** 入方向(源 0.0.0.0/0 或收窄)。8090/3306 不放行。
|
||||
|
||||
### 3.8 验证
|
||||
|
||||
```bash
|
||||
curl http://219.157.255.213:23010/ # 200 index.html
|
||||
curl http://219.157.255.213:23010/inquiries # 200(SPA fallback)
|
||||
curl http://219.157.255.213:23010/api/health # {"ok":true}
|
||||
```
|
||||
浏览器登录 admin / ADMIN_PASSWORD,核对询盘/客户/消息数据。
|
||||
|
||||
### 3.9 扩展配置
|
||||
|
||||
popup:serverUrl `http://219.157.255.213:23010` + INGEST_TOKEN(~/alibb-secrets.txt)→ 授权 → 启用推送 → 抓取。
|
||||
|
||||
## 4. 增量更新(改代码后)
|
||||
|
||||
| 改动 | 操作 |
|
||||
|---|---|
|
||||
| 后端 | 传变更文件 → `cd ~/alibb-server/backend && sudo docker compose build backend && sudo docker compose up -d`(migrate 自动跑) |
|
||||
| 前端 | 传变更 → 服务器 build(§3.6 方式 B)→ 复制 dist → 无需 reload(静态文件即时生效) |
|
||||
| 扩展 | 本机改 → `chrome://extensions` 刷新扩展(用户操作) |
|
||||
|
||||
## 5. 常用运维
|
||||
|
||||
```bash
|
||||
# 后端日志 / 重启
|
||||
sudo docker compose -f ~/alibb-server/backend/docker-compose.yml logs -f backend
|
||||
sudo docker compose -f ~/alibb-server/backend/docker-compose.yml restart backend
|
||||
|
||||
# 查库
|
||||
sudo docker exec -e MYSQL_PWD=wecbni5zRRnZrMMh 1Panel-mysql-YL0F mysql -ualibaba alibaba
|
||||
|
||||
# 行数快照
|
||||
sudo docker exec -e MYSQL_PWD=wecbni5zRRnZrMMh 1Panel-mysql-YL0F mysql -ualibaba alibaba -e \
|
||||
"SELECT (SELECT COUNT(*) FROM inquiries) i,(SELECT COUNT(*) FROM customers) c,(SELECT COUNT(*) FROM chat_messages) m;"
|
||||
|
||||
# 清询盘重抓(customers 保留,重抓 upsert 更新)
|
||||
... mysql ... -e "SET FOREIGN_KEY_CHECKS=0; TRUNCATE chat_messages; TRUNCATE inquiries; SET FOREIGN_KEY_CHECKS=1;"
|
||||
|
||||
# 重载 OpenResty(改 conf 后)
|
||||
sudo docker exec 1Panel-openresty-Seym openresty -t && sudo docker exec 1Panel-openresty-Seym openresty -s reload
|
||||
```
|
||||
|
||||
## 6. 备份与恢复
|
||||
|
||||
- **备份**(本机执行,产物存 `D:\alibb-server\backups\`):
|
||||
```bash
|
||||
/c/Windows/System32/OpenSSH/ssh.exe -i 'C:\Users\30997\.ssh\alibb_deploy' kt@219.157.255.213 \
|
||||
'sudo docker exec 1Panel-mysql-YL0F mysqldump -ualibaba -pwecbni5zRRnZrMMh --single-transaction --no-tablespaces --routines --triggers --databases alibaba' > D:\alibb-server\backups\alibaba-db-$(date +%Y%m%d).sql
|
||||
```
|
||||
- 完整备份方案:[2026-08-25-server-reinstall-backup.md](superpowers/plans/2026-08-25-server-reinstall-backup.md)
|
||||
- 服务器重装恢复 runbook:[2026-08-25-server-restore-redeploy.md](superpowers/plans/2026-08-25-server-restore-redeploy.md)(含 2026-08-27 实操记录)
|
||||
|
||||
## 7. 故障排查
|
||||
|
||||
| 症状 | 原因 → 处理 |
|
||||
|---|---|
|
||||
| `/api` 502 | ① backend 挂了:`docker ps` / `logs backend`;② 反代写成了 `http://backend:8080`——host 网络模式解析不了容器名,必须 `http://127.0.0.1:8090` |
|
||||
| openresty -t 报 host not found in upstream | 同上,proxy_pass 用容器名导致 |
|
||||
| 后端起不来,连接拒绝 | DATABASE_URL 里 MySQL 容器名不对(重装/重建后容器名后缀会变) |
|
||||
| 管理后台登录 401 | ADMIN_USERNAME/PASSWORD 与 compose 不符;扩展推送 401 → INGEST_TOKEN 不符 |
|
||||
| migrate 报表不存在 | 库导错/导到别的库,重做 §3.4 |
|
||||
| MySQL 8.x 用户连不上(老客户端) | `ALTER USER 'alibaba'@'%' IDENTIFIED WITH caching_sha2_password BY '...'`(Prisma 支持 caching_sha2,一般无需处理) |
|
||||
| 换服务器/IP | 改 3 处:扩展 serverUrl、站点 conf `server_name`、云安全组 |
|
||||
|
||||
## 8. 与 2026-07-15 旧环境差异(重要)
|
||||
|
||||
| 项 | 旧(7 月) | 新(2026-08-27) |
|
||||
|---|---|---|
|
||||
| MySQL | 5.7.44(容器 1Panel-mysql-sLiG,0.0.0.0:3306) | 8.4.11(容器 1Panel-mysql-YL0F,**127.0.0.1:3306 已收紧**) |
|
||||
| OpenResty | bridge 网络,反代可用容器名 `http://backend:8080` | **host 网络,反代必须 `http://127.0.0.1:8090`** |
|
||||
| 1Panel 路径 | /opt/1panel/apps/openresty/openresty/www/sites/... | **/opt/1panel/www/sites/...**(容器内仍挂 /www,旧 conf 兼容) |
|
||||
| 系统 | Ubuntu 24.04(kt-W580G20) | Ubuntu 26.04(xyh-W580G20) |
|
||||
| 站点管理 | 1Panel 面板建站(站点 alibb) | 1Panel 面板建站(代号 **alibaba**,2026-08-27 起面板可见可管理) |
|
||||
|
||||
## 9. 已知限制(业务)
|
||||
|
||||
- **折叠面板字段抓不到**:客户资料折叠面板里的手机/座机/商业类型等,alibaba 校验 `event.isTrusted`(只认真实鼠标点击),扩展无法展开;这些字段多数买家本就是"-"。
|
||||
- **风控**:抓取间隔已随机化(randSleep);大量抓取分批 + 中途暂停。
|
||||
- 公网明文 HTTP(23010):内网/测试用途可接受;若需公网长期使用,建议套域名 + HTTPS(1Panel 可申请 Let's Encrypt)。
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
# 阿里国际站询盘数据采集与管理系统 · 项目介绍
|
||||
|
||||
> 基于 2026-08-27 代码现状整理(扩展 `d:\alibb` + 服务端 `d:\alibb-server`)。
|
||||
> 部署细节见 [DEPLOY-ALIBB.md](DEPLOY-ALIBB.md),后端设计见 [superpowers/specs/2026-07-13-alibaba-inquiry-backend-design.md](superpowers/specs/2026-07-13-alibaba-inquiry-backend-design.md)。
|
||||
|
||||
## 1. 项目概述
|
||||
|
||||
本项目是一套**阿里国际站(alibaba.com)询盘数据的采集与管理系统**,由三个部分组成:
|
||||
|
||||
| 组成部分 | 位置 | 技术 | 角色 |
|
||||
|---|---|---|---|
|
||||
| **采集端** Chrome 扩展 | `d:\alibb`(本仓库) | Chrome MV3、原生 JS | 抓取询盘列表、客户资料、行为数据、聊天记录 |
|
||||
| **服务端** 后端 API | `d:\alibb-server\backend` | Node.js + Express + TypeScript + Prisma + MySQL | 接收推送、幂等入库、提供查询/统计/导出 API |
|
||||
| **服务端** 管理后台 | `d:\alibb-server\frontend` | Vue3 + Element Plus + Pinia + ECharts | 询盘列表/详情/聊天查看/统计报表 |
|
||||
|
||||
核心链路:
|
||||
|
||||
```
|
||||
浏览器(登录阿里主账号)
|
||||
Chrome 扩展 content.js
|
||||
├─ 列表抓取(DOM 翻页)→ 详情抓取(DOM)→ 聊天抓取(阿里内部 API 直拉)
|
||||
├─ 双写 A(本地):chrome.storage 汇聚 + customerId 去重 → CSV 导出
|
||||
└─ 双写 B(推送):IndexedDB 队列 → alarm 每分钟消费 → POST /api/ingest(Bearer Token)
|
||||
↓ 失败指数退避(≤8 次),成功出队
|
||||
服务器(阿里云 ECS,1Panel + OpenResty + MySQL 8.4,:23010)
|
||||
后端 zod 校验 → 幂等入库(同客户合并 / 消息级去重 / 早返回)
|
||||
↓
|
||||
管理员浏览器 → Vue3 后台(JWT 登录):列表筛选 / 聊天详情 / 统计 / CSV 导出
|
||||
```
|
||||
|
||||
使用模式:主管理员账号一人操作扩展手动触发抓取(非实时同步),后台数据 = 最近一次抓取的快照;询盘记录的 `manager` 字段天然区分业务员,无需多人登录。
|
||||
|
||||
---
|
||||
|
||||
## 2. 采集端:Chrome MV3 扩展(本仓库)
|
||||
|
||||
### 2.1 文件结构
|
||||
|
||||
| 文件 | 行数/大小 | 职责 |
|
||||
|---|---|---|
|
||||
| [manifest.json](../manifest.json) | — | MV3 清单:权限 `storage/activeTab/alarms/unlimitedStorage/scripting`;host 覆盖 `message/alicrm/onetalk.alibaba.com`;`optional_host_permissions` 运行时授权任意后端地址 |
|
||||
| [content.js](../content.js) | ~1300 行 | 核心:注入 `message.alibaba.com` 的抓取状态机 |
|
||||
| [background.js](../background.js) | 145 行 | Service Worker:推送队列调度 + 本地数据汇聚(双写) |
|
||||
| [push/queue.js](../push/queue.js) | — | IndexedDB 持久化队列(库 `inquiry_push`) |
|
||||
| [push/worker.js](../push/worker.js) | — | 队列消费:POST `/api/ingest`,指数退避,8 次后标 failed |
|
||||
| [push/payload.js](../push/payload.js) | — | 页面数据 → 后端 zod schema 的字段适配层 |
|
||||
| [push/config.js](../push/config.js) | — | 推送配置(`chrome.storage.local`) |
|
||||
| [popup.html](../popup.html) / [popup.js](../popup.js) | — | 操作界面:抓取控制 + 后端推送配置 |
|
||||
| [tests/](../tests/) | 5 个文件 | vitest 单测(config/queue/payload/sync/worker,fake-indexeddb) |
|
||||
| [scripts/](../scripts/) | — | `e2e-push.mjs`(推送端到端)、`verify-chat-range.mjs`、`verify-risk-detect.mjs` |
|
||||
| [使用手册.html](../使用手册.html) | — | 面向操作者的图文五步指南 |
|
||||
|
||||
### 2.2 抓取状态机与断点续抓
|
||||
|
||||
[content.js](../content.js) 的核心是**跨页面持久化的状态机**:`state.phase ∈ idle | list | detail | paused | done`,存储于 `chrome.storage.local`(键 `alibaba_scraper_state`)。每跳转一个详情页 content script 就被销毁重建,靠恢复 `queueIndex` 继续队列 —— 整轮抓取可跨导航、可暂停恢复、可扩展重载续跑。
|
||||
|
||||
抓取编排由 `processCurrentDetailPage()` 完成:等待渲染 → 聊天抓取(API 优先)→ 详情提取 → **双写**(本地 `storeDetail` + 推送 `enqueue`)→ 跳下一条。
|
||||
|
||||
### 2.3 数据抓取能力
|
||||
|
||||
- **列表页**:DOM 解析 `a.inquiry-item`(询价单号/时间/买家/国家/消息预览/负责人/状态等 13 字段),100 条/页,自动翻页;
|
||||
- **详情页**:客户资料(邮箱/电话/公司/注册时间等)、买家标签(8 词表)、买家特征(12 词表)、近 90 天行为数据(产品浏览/有效询盘/RFQ/登录天数/垃圾询盘/黑名单数)、店内行为与最近询盘产品;
|
||||
- **聊天记录**(最复杂模块):
|
||||
- **API 直拉主路径**:`querySummary.htm` 拿会话加密参数 → `getOpMessages.htm` 游标循环翻页(复用页面 cookie 凭据),单询盘实测 8000+ 条;实测全量 4576 询盘 + 68058 条消息零风控;
|
||||
- **DOM 滚动回退**:API 失败时模拟 wheel 事件滚动 `.common-load-more` 懒加载;
|
||||
- **同客户去重**:同买家多询盘共享同一会话,只抓首个(`seenChatCustomer`);
|
||||
- **消息归一化**(`apiMsgToChat`):方向(incoming/outgoing/system)、富媒体(`img:`/`file:`/`video:` 前缀 + clouddisk 直链构造)、翻译(嵌套 JSON 双重 parse)。
|
||||
|
||||
### 2.4 反风控设计(贯穿全文件)
|
||||
|
||||
| 机制 | 说明 |
|
||||
|---|---|
|
||||
| 随机时延 | 翻页 1.2–3s、聊天每轮 2–5s、每 15 轮插入 30–90s"模拟走神" |
|
||||
| 作息时段 | 默认 8–12 / 14–18 点工作,会话级 ±30 分钟抖动,非工作时段自动挂起 |
|
||||
| 风控检测 | `RISK_KEYWORDS`(验证码/captcha/blocked 等)命中 → 暂停 + popup 红色横幅报警;只浅扫 API 响应顶层字段,不扫消息正文防误判 |
|
||||
| 跳过策略 | 注销/管控账号(`SKIP_KEYWORDS`)直接跳过不暂停 |
|
||||
| 增量水位 | 按 inquiryId 记录已抓最新消息(`lastTopMsg_*`),重抓只拉增量,只前进不倒退 |
|
||||
| 时间范围 | 近 1/2/3 月或自定义起止;`until` 跳游标直接从截止时间往回翻,大幅减少请求量 —— 同时是风控减压手段 |
|
||||
|
||||
### 2.5 推送链路(可靠性)
|
||||
|
||||
```
|
||||
content.js 抓完一条 → sendMessage 'enqueue' → SW 写 IndexedDB(不丢)
|
||||
→ 立即 + alarm 'push-tick'(每 1 分钟)触发 processQueue()
|
||||
→ 每批 3 条,POST {serverUrl}/api/ingest(Bearer Token)
|
||||
→ 成功标 sent;失败指数退避(2^n 秒,封顶 1h),8 次后标 failed(popup 红字提示)
|
||||
```
|
||||
|
||||
关键设计:worker **每次自己 `loadConfig()`**(无状态),因为 MV3 SW 休眠重启会清空模块级变量 —— 缓存配置会导致永不推送。
|
||||
|
||||
### 2.6 popup 界面
|
||||
|
||||
抓取模式(仅列表/列表+详情)、条数(全部/前10/前50)、customerId 去重开关、聊天上限、**聊天时间范围**(全部/近1-3月/自定义日期)、后端推送设置(服务器 URL + Token + 授权域名 + 启用开关,默认指向生产 `http://219.157.255.213:23010`)、实时推送状态条(已推/待推/失败,2s 刷新)、开始/暂停/恢复/导出 CSV/清空数据。
|
||||
|
||||
---
|
||||
|
||||
## 3. 服务端后端(d:\alibb-server\backend)
|
||||
|
||||
### 3.1 技术栈与结构
|
||||
|
||||
Express 4 + TypeScript + Prisma 5 + MySQL 8,zod 校验,Docker 两阶段构建(node:20-slim),容器启动自动 `prisma migrate deploy`。目录:`src/{routes,services,middleware,utils,config,lib}` + `prisma/` + `tests/`(vitest + testcontainers 真库集成测试)。
|
||||
|
||||
### 3.2 API 清单
|
||||
|
||||
| 方法 + 路径 | 鉴权 | 功能 |
|
||||
|---|---|---|
|
||||
| `GET /api/health` | 无 | 探活(DB 连通性) |
|
||||
| `POST /api/ingest/` | Ingest Token + 60次/min 限流 | 扩展推送入口,幂等入库 |
|
||||
| `POST /api/auth/login` / `refresh` | 无 / JWT | 管理员登录(bcrypt)、续签 |
|
||||
| `GET /api/inquiries` | JWT | 列表:分页 + 国家/业务员/状态/时间筛选 + 关键词搜索 + 排序 |
|
||||
| `GET /api/inquiries/:id` | JWT | 详情 + 客户 + 行为 + 消息数 |
|
||||
| `GET /api/inquiries/:id/messages` | JWT | 聊天游标分页(复合键 `(sendTime, messageId)`,微信式向上翻页) |
|
||||
| `GET /api/inquiries/:id/messages/export` | JWT | 单询盘聊天 CSV 流式导出(预留) |
|
||||
| `GET /api/stats/overview` | JWT | 总数 / 国家 Top10 / 业务员分布 / 近 12 月趋势 |
|
||||
| `GET /api/export` | JWT | 询盘列表 CSV 导出(21 列中文表头,与列表筛选一致) |
|
||||
|
||||
### 3.3 ingest 幂等核心(亮点)
|
||||
|
||||
1. **同客户合并**:同一 customerId 只保留一条"代表询盘"(最早创建),后续询盘追加进 `allInquiries` JSON 数组,消息统一归入代表 —— 不产生冗余行;
|
||||
2. **消息级幂等**:按 `message_id` 唯一约束 `createMany skipDuplicates`,500 条/批,单批失败只计数不回滚(`has_failed_batches` 标记,下次推送强制重插);
|
||||
3. **早返回优化**:"消息数 + 最大 sendTime + 无失败批次" 三项指纹未变 → 跳过批量插入直接返回,避免重复重写几千条消息;
|
||||
4. customer upsert 用 `skipNulls`,本次没抓到的字段不覆盖已落库值。
|
||||
|
||||
### 3.4 数据模型(4 张表)
|
||||
|
||||
- **customers**:`customer_id` 唯一;资料 + 行为七字段(可空,区分"无数据"与"为 0")+ 店内行为 JSON 列;
|
||||
- **inquiries**:`inquiry_id` 唯一;`all_inquiries`(同客户合并)、`messages_count`/`last_send_time`(幂等指纹)、`has_failed_batches`;
|
||||
- **chat_messages**:`message_id` 唯一(幂等键);`content MEDIUMTEXT`;复合索引 `(inquiry_id, send_time, message_id)` 恰好覆盖游标分页;
|
||||
- **users**:管理员(bcrypt,空表时按环境变量播种)。
|
||||
|
||||
### 3.5 鉴权
|
||||
|
||||
两套并行:扩展 → 静态 `INGEST_TOKEN`(`timingSafeEqual` 常量时间比较防时序攻击);后台 → JWT(HS256,7 天,登录签发)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 管理后台前端(d:\alibb-server\frontend)
|
||||
|
||||
### 4.1 页面
|
||||
|
||||
| 路由 | 页面 | 功能要点 |
|
||||
|---|---|---|
|
||||
| `/login` | 登录 | JWT + open-redirect 防护 |
|
||||
| `/inquiries` | 询盘列表 | 筛选栏(国家/业务员/状态/时间范围/搜索/排序)、表格分页、**CSV 导出复用当前筛选**;列表状态存 sessionStorage,进详情返回后恢复 |
|
||||
| `/inquiries/:id` | 询盘详情 | 客户信息卡 + 行为数据卡(复刻阿里详情页行为区)+ **同会话多询盘列表** + 完整聊天 |
|
||||
| `/stats` | 统计 | 数字卡(总数/本月新增)+ ECharts:国家 Top10 环形图、业务员柱状图、12 月趋势折线 |
|
||||
|
||||
### 4.2 聊天组件(核心体验)
|
||||
|
||||
- **虚拟滚动**(vue-virtual-scroller):几千条消息流畅渲染,可变高度气泡自动测量;
|
||||
- **游标分页**:首屏拉最新一页自动滚到底,顶部按钮向上加载历史,prepend 后保持视觉位置不跳动;
|
||||
- **微信式气泡**:incoming 左 / outgoing 右 / system 居中;消息类型协议(`img:`/`file:`/`video:`/纯链接/纯文本);
|
||||
- **XSS 防护**:文本一律插值不用 v-html;富媒体 URL 走 alibaba 域名白名单,纯 URL 只许 http/https。
|
||||
|
||||
### 4.3 工程细节
|
||||
|
||||
axios 封装(401 全局跳登录、错误脱敏不含 token)、Pinia auth store(JWT 存 localStorage,MVP 可接受)、路由守卫、全套 vitest 单测、双 lint(oxlint + eslint)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 部署与运维
|
||||
|
||||
生产环境(详见 [DEPLOY-ALIBB.md](DEPLOY-ALIBB.md)):
|
||||
|
||||
- 阿里云 ECS `219.157.255.213`(Ubuntu 26.04),对外端口 **23010**;
|
||||
- 1Panel 面板管理:OpenResty(host 网络,站点 `alibaba`)→ SPA 静态 + `/api/*` 反代 → `alibb-backend` 容器(127.0.0.1:8090)→ 1Panel MySQL 8.4(库 `alibaba`,utf8mb4,仅本机可达);
|
||||
- `client_max_body_size 50m` 配合大聊天 payload;备份:mysqldump → 本机 `D:\alibb-server\backups\`(权威)+ 服务器副本;
|
||||
- 三份代码位置:扩展 `d:\alibb`(master)、后端 `d:\alibb-server\backend`(master)、前端 `d:\alibb-server\frontend`(main);服务器重装恢复 runbook 见 [superpowers/plans/](superpowers/plans/)。
|
||||
|
||||
## 6. 已知限制
|
||||
|
||||
- 客户资料**折叠面板字段抓不到**(阿里校验 `event.isTrusted` 只认真实鼠标点击),好在多数买家本就是 "-";
|
||||
- 公网明文 HTTP(23010),内网/测试可接受,长期公网建议套域名 + HTTPS;
|
||||
- 数据时效 = 上次手动抓取快照,需浏览器保持阿里页打开;
|
||||
- 同 messageId 内容被上游修订时早返回会跳过更新(MVP 接受,重推主场景是新增消息)。
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,61 @@
|
||||
# 管理后台前端 实施计划 (Plan 3)
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development 或 superpowers:executing-plans。Steps 用 `- [ ]` 跟踪。
|
||||
|
||||
**Goal:** Vue3 + Element Plus 管理后台,对接 Plan 1 后端(`/api/*`),实现登录 + 询盘列表(筛选/搜索/分页)+ 详情(客户+行为+聊天虚拟滚动)+ 统计 + 导出。
|
||||
|
||||
**Architecture:** SPA。axios 调后端 REST API(JWT 鉴权)。Pinia 管 auth 状态。Vue Router(+ 守卫)。构建产物 dist 由 nginx 反代(/api→backend, /→dist)。
|
||||
|
||||
**Tech Stack:** Vue3 + TypeScript + Element Plus + Vite + Pinia + Vue Router + axios + ECharts + vue-virtual-scroller。
|
||||
|
||||
**关联:** spec §9;后端 Plan 1(`d:/alibb-server/backend`,compose 跑在 `localhost:8080`)。
|
||||
|
||||
**位置:** `d:/alibb-server/frontend/`(新建,git init)。
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
d:/alibb-server/frontend/
|
||||
src/
|
||||
api/{request.ts(client+JWT interceptor), inquiry.ts, stats.ts, auth.ts}
|
||||
stores/auth.ts # token + 用户名(localStorage)
|
||||
router/index.ts # 路由 + 守卫
|
||||
layouts/MainLayout.vue # 侧边栏 + 头部
|
||||
views/{Login.vue, InquiryList.vue, InquiryDetail.vue, Stats.vue}
|
||||
components/{BehaviorCard.vue, ChatList.vue(虚拟滚动)}
|
||||
App.vue / main.ts
|
||||
vite.config.ts # 代理 /api → localhost:8080(dev)
|
||||
Dockerfile / nginx.conf # 构建 + 反代
|
||||
package.json / tsconfig.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
1. **项目初始化**:`npm create vue@latest`(Vue3+TS+Router+Pinia),装 Element Plus / axios / ECharts / vue-virtual-scroller。vite dev 代理 `/api→:8080`。git init。
|
||||
2. **API client + auth + 登录**:`api/request.ts`(axios,baseURL `/api`,请求拦截加 Bearer,401 跳登录);`stores/auth.ts`(token 存 localStorage);`views/Login.vue`(用户名+密码 → `/api/auth/login` → 存 token → 跳 `/inquiries`)。
|
||||
3. **主布局 + 路由守卫**:`MainLayout.vue`(左侧菜单:询盘/统计;头部:用户名/登出);router 守卫(无 token → `/login`)。
|
||||
4. **询盘列表** `InquiryList.vue`:Element Plus `el-table` + 顶部筛选(国家/业务员/状态/时间范围 el-date-picker)+ 搜索框 + el-pagination + 导出按钮(调 `/api/export` 下载 CSV)。列:询价单号/买家/国家/业务员/状态/创建时间/消息数。点行跳详情。
|
||||
5. **询盘详情** `InquiryDetail.vue`:`el-tabs` 或左右分栏:客户信息卡 + 行为数据卡(产品浏览/有效询盘/RFQ/登录天数/垃圾询盘/最常行业)+ 聊天(`ChatList.vue` vue-virtual-scroller,游标分页加载 `/api/inquiries/:id/messages`,左右气泡按 direction)。
|
||||
6. **统计** `Stats.vue`:数字卡片(总数/本月新增)+ ECharts(国家 Top10 饼图 / 业务员柱状图 / 月趋势折线)。调 `/api/stats/overview`。
|
||||
7. **导出**:列表页「导出 CSV」按钮 → `window.open('/api/export?...当前筛选', headers JWT)` 或 axios blob 下载。
|
||||
8. **构建 + nginx**:`npm run build` → dist;`nginx.conf`(/api → backend:8080,/ → dist);Dockerfile。可选:加到 backend 的 docker-compose(nginx 服务)。
|
||||
|
||||
---
|
||||
|
||||
## 完成标准(DoD)
|
||||
|
||||
- [ ] 登录页 → 输入 admin/admin-password-1234 → 进列表
|
||||
- [ ] 列表:筛选(国家/业务员/状态/时间)+ 搜索 + 分页工作
|
||||
- [ ] 详情:客户 + 行为 + 聊天(虚拟滚动,游标分页)显示
|
||||
- [ ] 统计:卡片 + 3 图表
|
||||
- [ ] 导出:按筛选下载 CSV
|
||||
- [ ] `npm run build` 成功;nginx 反代后前后端通
|
||||
|
||||
## 后续
|
||||
|
||||
- 真实 HTTPS 域名(Plan 4)
|
||||
- 聊天全文搜索、客户合并视图、跟进管理
|
||||
@@ -0,0 +1,395 @@
|
||||
# 插件推送改造 实施计划 (Plan 2)
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 在现有 `d:\alibb\` Chrome 插件(已实现抓取 + API 直拉聊天)上**新增推送模块**:抓完一个询盘自动入队,由 service worker 经 `chrome.alarms` 消费、POST 到 Plan 1 后端 `/api/ingest`。带 IndexedDB 队列(防丢)+ 指数退避重试 + 配置页 + 运行时域名授权。**保留**现有本地导出能力(双写)。
|
||||
|
||||
**Architecture:**
|
||||
- `background.js`(SW)改 **ES module**(`manifest` `type:module`),`import` 推送逻辑 `push/*.js`
|
||||
- 推送逻辑分三个 ES module(可被 node + vitest 测试):`push/queue.js`(IndexedDB 队列)、`push/payload.js`(组装后端 payload)、`push/worker.js`(消费 + POST + 重试)
|
||||
- `content.js` 保持经典脚本,抓完一条 → `chrome.runtime.sendMessage({type:'enqueue', record, messages})` 发原始数据给 SW;SW 组装 payload + 入队
|
||||
- IndexedDB 由 **SW 落盘**(content script 的 IDB 属页面源,v3 修订),`chrome.storage.local` 只存 `queue_index` + 元数据
|
||||
- `popup` 加「后端设置」(URL/token/启用)+ 推送状态显示;`chrome.permissions.request` 运行时授权后端域名
|
||||
|
||||
**Tech Stack:** 现有 JS 插件 + vitest 2 + fake-indexeddb(node 测队列)+ Chrome MV3 API(alarms/indexedDB/permissions/storage)。
|
||||
|
||||
**关联:** spec `docs/superpowers/specs/2026-07-13-alibaba-inquiry-backend-design.md` §8;后端 Plan 1 已就绪(`d:\alibb-server\backend`,`/api/ingest`)。
|
||||
|
||||
**前提:** `d:\alibb` 当前**非 git 仓库**,Task 1 会 `git init`。Plan 1 后端能 `docker compose up` 跑起来供端到端验证。
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
d:\alibb\ # 插件根(本 plan 改这里)
|
||||
manifest.json # 改:type:module + permissions(alarms/unlimitedStorage) + optional_host_permissions
|
||||
background.js # 改:ES module,import push/*,alarms worker,接收 enqueue;保留 storeDetail/getBgData(本地导出)
|
||||
content.js # 改:apiMsgToChat 扩展(messageId/sendTime ISO/translation);fetchChatByApi 扩展(meta);processCurrentDetailPage 加 enqueue
|
||||
popup.html / popup.js # 改:加后端设置区 + 推送状态显示
|
||||
push\
|
||||
queue.js # 新:IndexedDB 队列操作(addItem/getPending/markStatus/remove/getStats)
|
||||
payload.js # 新:buildIngestPayload(record, messages) → 适配后端 zod
|
||||
worker.js # 新:processQueue(消费 + POST + 指数退避)
|
||||
tests\
|
||||
queue.test.js # vitest + fake-indexeddb
|
||||
payload.test.js # 纯函数测试
|
||||
worker.test.js # mock fetch + fake-indexeddb
|
||||
package.json # 新:vitest + fake-indexeddb
|
||||
vitest.config.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 1: node 测试基础 + git init
|
||||
|
||||
**Files:** `package.json`, `vitest.config.ts`, `.gitignore`
|
||||
|
||||
- [ ] **Step 1:** `cd d:/alibb && git init`(若已是 git 仓库则跳过)
|
||||
- [ ] **Step 2:** `package.json`:`{ "name":"alibaba-inquiry-plugin", "type":"module", "scripts":{"test":"vitest run"} }`
|
||||
- [ ] **Step 3:** `npm install -D vitest fake-indexeddb`
|
||||
- [ ] **Step 4:** `vitest.config.ts`:`{ test:{ globals:true, testTimeout:30000, poolOptions:{ forks:{ singleFork:true } }, setupFiles:['fake-indexeddb/auto'] } }`
|
||||
- [ ] **Step 5:** `.gitignore`:`node_modules/ .env`(注意:**不忽略** push/*.js、content.js 等插件文件)
|
||||
- [ ] **Step 6:** commit `chore: add node test harness (vitest + fake-indexeddb)`
|
||||
|
||||
---
|
||||
|
||||
## Task 2: manifest 改造
|
||||
|
||||
**Files:** `manifest.json`
|
||||
|
||||
- [ ] **Step 1:** permissions 加 `alarms`、`unlimitedStorage` → `["storage","activeTab","alarms","unlimitedStorage"]`
|
||||
- [ ] **Step 2:** 加 `optional_host_permissions: ["http://*/*","https://*/*"]`(后端域名运行时授权)
|
||||
- [ ] **Step 3:** background 加 `"type":"module"` → `{ "service_worker":"background.js", "type":"module" }`
|
||||
- [ ] **Step 4:** commit `feat(manifest): alarms/unlimitedStorage + optional_host_permissions + sw module`
|
||||
|
||||
---
|
||||
|
||||
## Task 3: push/queue.js(IndexedDB 队列)
|
||||
|
||||
**Files:** `push/queue.js`, `tests/queue.test.js`
|
||||
|
||||
- [ ] **Step 1:** 写 `tests/queue.test.ts`(TDD):addItem → getPending 返回含它;markStatus('sent') → 不在 pending;getStats → {pending,failed,sent} 计数
|
||||
- [ ] **Step 2:** 跑测试确认失败
|
||||
- [ ] **Step 3:** 写 `push/queue.js`:
|
||||
```js
|
||||
const DB_NAME = 'inquiry_push', STORE = 'queue', VERSION = 1;
|
||||
function openDb() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, VERSION);
|
||||
req.onupgradeneeded = () => req.result.createObjectStore(STORE, { keyPath: 'id' });
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
function tx(db, mode) { return db.transaction(STORE, mode).objectStore(STORE); }
|
||||
function req2promise(r) { return new Promise((res, rej) => { r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error); }); }
|
||||
|
||||
export async function addItem(item) {
|
||||
const db = await openDb();
|
||||
const rec = { id: crypto.randomUUID(), payload: item.payload, inquiryId: item.inquiryId,
|
||||
status: 'pending', retries: 0, nextRetry: Date.now(), createdAt: Date.now() };
|
||||
await req2promise(tx(db, 'readwrite').add(rec));
|
||||
db.close();
|
||||
return rec.id;
|
||||
}
|
||||
export async function getPending(limit = 5) {
|
||||
const db = await openDb();
|
||||
const all = await req2promise(tx(db, 'readonly').getAll());
|
||||
db.close();
|
||||
const now = Date.now();
|
||||
return all.filter(r => r.status === 'pending' && r.nextRetry <= now)
|
||||
.sort((a, b) => a.nextRetry - b.nextRetry)
|
||||
.slice(0, limit);
|
||||
}
|
||||
export async function markStatus(id, status, opts = {}) {
|
||||
const db = await openDb();
|
||||
const store = tx(db, 'readwrite');
|
||||
const r = await req2promise(store.get(id));
|
||||
if (!r) { db.close(); return; }
|
||||
Object.assign(r, { status, ...opts });
|
||||
await req2promise(store.put(r));
|
||||
db.close();
|
||||
}
|
||||
export async function remove(id) {
|
||||
const db = await openDb();
|
||||
await req2promise(tx(db, 'readwrite').delete(id));
|
||||
db.close();
|
||||
}
|
||||
export async function getStats() {
|
||||
const db = await openDb();
|
||||
const all = await req2promise(tx(db, 'readonly').getAll());
|
||||
db.close();
|
||||
return all.reduce((a, r) => { a[r.status] = (a[r.status] || 0) + 1; return a; }, {});
|
||||
}
|
||||
```
|
||||
> `crypto.randomUUID()` 在 SW 和 node 18+ 都可用。fake-indexeddb 提供 indexedDB 全局。
|
||||
- [ ] **Step 4:** 测试通过 + commit `feat(push): IndexedDB queue (add/getPending/markStatus/remove/stats)`
|
||||
|
||||
---
|
||||
|
||||
## Task 4: push/payload.js(组装后端 payload)
|
||||
|
||||
**Files:** `push/payload.js`, `tests/payload.test.js`
|
||||
|
||||
- [ ] **Step 1:** 测试:`buildIngestPayload(record, messages)` 输出匹配后端 zod(inquiryId/createTime ISO/updateTime ISO/customer{customerId,country,...}/messages[{messageId,sendTime ISO,direction,content,...}]);空字符串 behavior 归 null;messages 为空时抛错(后端 zod min(1))
|
||||
- [ ] **Step 2:** 写 `push/payload.js`:
|
||||
```js
|
||||
const s = v => (v == null || v === '') ? null : v;
|
||||
const iso = ms => new Date(ms).toISOString();
|
||||
|
||||
export function buildIngestPayload(record, messages) {
|
||||
if (!messages || messages.length === 0) throw new Error('messages required');
|
||||
const iq = record || {};
|
||||
return {
|
||||
inquiry: {
|
||||
inquiryId: String(iq.inquiryId),
|
||||
manager: s(iq.manager) || undefined,
|
||||
// 优先用 meta 的 gmtCreate/gmtModified(record.createTimeFromApi),回退列表 createTime
|
||||
createTime: iq.createTimeFromApi || iq.createTime || iso(Date.now()),
|
||||
updateTime: iq.updateTimeFromApi || iq.updateTime || iq.createTimeFromApi || iso(Date.now()),
|
||||
status: s(iq.status) || undefined,
|
||||
msgPreview: s(iq.msgPreview) || undefined,
|
||||
isFlagged: !!iq.isFlagged,
|
||||
},
|
||||
customer: iq.customerId ? {
|
||||
customerId: String(iq.customerId),
|
||||
name: s(iq.detailBuyerName) || s(iq.buyerName),
|
||||
country: s(iq.detailCountry) || s(iq.countryCode) ? (s(iq.detailCountry) || s(iq.countryCode)) : null,
|
||||
email: s(iq.email), company: s(iq.company),
|
||||
registerTime: s(iq.registerTime) || null,
|
||||
avatarUrl: s(iq.detailAvatarUrl) || s(iq.avatarUrl) || undefined,
|
||||
buyerTags: s(iq.buyerTags) || undefined, buyerFeatures: s(iq.buyerFeatures) || undefined,
|
||||
behavior: iq.behavior ? {
|
||||
productViews: n(iq.behavior.productViews), validInquiries: n(iq.behavior.validInquiries),
|
||||
validRfq: n(iq.behavior.validRfq), loginDays: n(iq.behavior.loginDays),
|
||||
spamInquiries: n(iq.behavior.spamInquiries), topIndustry: s(iq.behavior.topIndustry),
|
||||
} : undefined,
|
||||
} : null,
|
||||
messages: messages.map(m => ({
|
||||
messageId: String(m.messageId), sendTime: m.sendTime,
|
||||
sender: s(m.sender) || undefined, direction: m.direction,
|
||||
content: m.original ?? m.content ?? '', translation: s(m.translation) || '',
|
||||
})),
|
||||
};
|
||||
}
|
||||
function n(v) { const x = Number(v); return Number.isFinite(x) ? x : null; }
|
||||
```
|
||||
> 注:`messages` 已是 apiMsgToChat 转换后的对象(messageId/sendTime ISO/sender/original/direction/translation)。content 映射用 original(apiMsgToChat 产出 original)。
|
||||
- [ ] **Step 3:** 测试通过(对照 Plan 1 后端 PayloadSchema 语义)+ commit `feat(push): buildIngestPayload adapter`
|
||||
|
||||
---
|
||||
|
||||
## Task 5: push/worker.js(消费 + POST + 重试)
|
||||
|
||||
**Files:** `push/worker.js`, `tests/worker.test.js`
|
||||
|
||||
- [ ] **Step 1:** 测试:mock global.fetch(成功 → markStatus sent 风格的 remove);失败 → retries++ nextRetry 退避;超过 8 次 → markStatus failed。注入 `{addItem,getPending,markStatus,remove}` mock 或用真实 fake-indexeddb + fetch mock
|
||||
- [ ] **Step 2:** 写 `push/worker.js`:
|
||||
```js
|
||||
import { getPending, markStatus, remove } from './queue.js';
|
||||
import { buildIngestPayload } from './payload.js';
|
||||
|
||||
const MAX_RETRIES = 8;
|
||||
let _config = null; // {serverUrl, token, enabled}
|
||||
|
||||
export function configure(cfg) { _config = cfg; }
|
||||
|
||||
export async function processQueue() {
|
||||
if (!_config || !_config.enabled || !_config.serverUrl || !_config.token) return { processed: 0 };
|
||||
const items = await getPending(3);
|
||||
let processed = 0;
|
||||
for (const it of items) {
|
||||
try {
|
||||
const payload = buildIngestPayload(it.payload.record, it.payload.messages);
|
||||
const res = await fetch(`${_config.serverUrl}/api/ingest`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${_config.token}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (res.ok) { await remove(it.id); processed++; }
|
||||
else { await bumpRetry(it, `HTTP ${res.status}`); }
|
||||
} catch (e) {
|
||||
await bumpRetry(it, String(e && e.message || e));
|
||||
}
|
||||
}
|
||||
return { processed };
|
||||
}
|
||||
|
||||
async function bumpRetry(it, err) {
|
||||
const retries = it.retries + 1;
|
||||
if (retries >= MAX_RETRIES) {
|
||||
await markStatus(it.id, 'failed', { retries, lastError: err });
|
||||
} else {
|
||||
const delay = Math.min(Math.pow(2, retries) * 1000, 3600 * 1000);
|
||||
await markStatus(it.id, 'pending', { retries, nextRetry: Date.now() + delay, lastError: err });
|
||||
}
|
||||
}
|
||||
```
|
||||
- [ ] **Step 3:** 测试通过 + commit `feat(push): worker (consume + POST + backoff)`
|
||||
|
||||
---
|
||||
|
||||
## Task 6: background.js(SW,alarms worker + 接收 enqueue)
|
||||
|
||||
**Files:** `background.js`
|
||||
|
||||
- [ ] **Step 1:** 顶部改 ES module import:
|
||||
```js
|
||||
import { addItem, getStats } from './push/queue.js';
|
||||
import { processQueue, configure } from './push/worker.js';
|
||||
```
|
||||
- [ ] **Step 2:** 保留现有 storeDetail/getBgData/clearBgData(本地导出)**不动**
|
||||
- [ ] **Step 3:** 加配置加载 + alarms 注册 + enqueue 处理:
|
||||
```js
|
||||
const CONFIG_KEY = 'push_config';
|
||||
const ALARM = 'push-tick';
|
||||
|
||||
async function loadConfig() {
|
||||
const d = await chrome.storage.local.get(CONFIG_KEY);
|
||||
return d[CONFIG_KEY] || { enabled: false, serverUrl: '', token: '' };
|
||||
}
|
||||
|
||||
// 启动 + 配置变更时刷新 worker 配置 + alarm
|
||||
async function refresh() {
|
||||
const cfg = await loadConfig();
|
||||
configure(cfg);
|
||||
if (cfg.enabled) {
|
||||
await chrome.alarms.create(ALARM, { periodInMinutes: 1 });
|
||||
processQueue(); // 立即跑一次
|
||||
} else {
|
||||
await chrome.alarms.clear(ALARM);
|
||||
}
|
||||
}
|
||||
|
||||
chrome.runtime.onInstalled.addListener(refresh);
|
||||
chrome.runtime.onStartup.addListener(refresh);
|
||||
chrome.alarms.onAlarm.addListener(a => { if (a.name === ALARM) processQueue(); });
|
||||
|
||||
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
if (msg.type === 'enqueue') {
|
||||
(async () => {
|
||||
const cfg = await loadConfig();
|
||||
if (!cfg.enabled) { sendResponse({ ok: false, reason: 'disabled' }); return; }
|
||||
const id = await addItem({ payload: { record: msg.record, messages: msg.messages }, inquiryId: msg.record?.inquiryId });
|
||||
sendResponse({ ok: true, id });
|
||||
processQueue(); // 立即尝试
|
||||
})();
|
||||
return true;
|
||||
}
|
||||
if (msg.type === 'getPushStats') {
|
||||
(async () => sendResponse(await getStats()))();
|
||||
return true;
|
||||
}
|
||||
if (msg.type === 'setPushConfig') {
|
||||
(async () => {
|
||||
await chrome.storage.local.set({ [CONFIG_KEY]: msg.config });
|
||||
await refresh();
|
||||
sendResponse({ ok: true });
|
||||
})();
|
||||
return true;
|
||||
}
|
||||
});
|
||||
```
|
||||
- [ ] **Step 4:** commit `feat(bg): push worker + alarms + enqueue handler (keep local export)`
|
||||
|
||||
---
|
||||
|
||||
## Task 7: content.js — apiMsgToChat 扩展
|
||||
|
||||
**Files:** `content.js`(apiMsgToChat ~434)
|
||||
|
||||
- [ ] **Step 1:** apiMsgToChat 改产出 `{ messageId, sendTime(ISO), sender, original, translation, direction }`:
|
||||
- `messageId: String(m.messageId)`
|
||||
- `sendTime: new Date(m.sendTime).toISOString()`(替代原来的本地时区 "YYYY-MM-DD HH:MM:SS")
|
||||
- `translation`:从 `m.extInfo?.icbuData?.extend` 解析(extend 是 JSON 字符串,内含 extParams.inputTranslateInfo.translate);解析失败留 `''`
|
||||
- sender/original/direction 保持
|
||||
- [ ] **Step 2:** translation 解析加双重 JSON.parse 兜底(extend 可能是字符串里的字符串),失败不抛(留 '')
|
||||
- [ ] **Step 3:** 手动/Playwright 验证(无 node 测试,因 content.js 非 module):用 Playwright 注入新版 apiMsgToChat + 真实 m 样本,断言 messageId/sendTime/translation 正确
|
||||
- [ ] **Step 4:** commit `feat(content): apiMsgToChat emits messageId/sendTime(ISO)/translation`
|
||||
|
||||
---
|
||||
|
||||
## Task 8: content.js — fetchChatByApi 扩展(提取 gmtCreate/country)
|
||||
|
||||
**Files:** `content.js`(fetchChatByApi ~451)
|
||||
|
||||
- [ ] **Step 1:** fetchChatByApi 在调 querySummary 后,额外提取 `d.gmtCreate`/`d.gmtModified`(毫秒→ISO)存入返回 meta;`d.contact?.countryCode` 或 `d.cid` 派生也行(spec 用 contact.countryCode)。返回 `{ messages, meta: { gmtCreate, gmtModified } }`
|
||||
- [ ] **Step 2:** 调用方(processCurrentDetailPage / scrapeDetailData)把 meta 挂到 detail:`detail.createTimeFromApi = meta.gmtCreate; detail.updateTimeFromApi = meta.gmtModified`
|
||||
- [ ] **Step 3:** commit `feat(content): fetchChatByApi extracts gmtCreate/gmtModified for payload`
|
||||
|
||||
---
|
||||
|
||||
## Task 9: content.js — processCurrentDetailPage 加 enqueue
|
||||
|
||||
**Files:** `content.js`(processCurrentDetailPage ~347,storeDetail 之后)
|
||||
|
||||
- [ ] **Step 1:** 在 storeDetail(本地导出)**之后**加 enqueue(推送):
|
||||
```js
|
||||
// 推送到后端(Plan 2):发 record + messages 给 SW 入队
|
||||
try {
|
||||
chrome.runtime.sendMessage({
|
||||
type: 'enqueue',
|
||||
record: merged, // {item, detail} 合并
|
||||
messages: detail.chatMessages || [], // apiMsgToChat 产出数组
|
||||
}, () => {}); // fire-and-forget,失败不影响本地导出
|
||||
} catch (e) {}
|
||||
```
|
||||
> `detail.chatMessages` 需在 scrapeDetailData 产出(现有 chatText 是序列化串,加一个 `chatMessages` 数组字段)。确保 merged 含 `createTimeFromApi/updateTimeFromApi`(Task 8)。
|
||||
- [ ] **Step 2:** commit `feat(content): enqueue record+messages to SW after detail scrape`
|
||||
|
||||
---
|
||||
|
||||
## Task 10: popup 配置页 + permissions.request
|
||||
|
||||
**Files:** `popup.html`, `popup.js`
|
||||
|
||||
- [ ] **Step 1:** popup.html 加「后端设置」区:服务器 URL 输入、Token 输入、启用推送 checkbox、「授权域名」按钮、「保存」按钮
|
||||
- [ ] **Step 2:** popup.js:加载时 `chrome.runtime.sendMessage({type:'getPushConfig'})` 回填(或直接 `chrome.storage.local.get`);保存时 setPushConfig
|
||||
- [ ] **Step 3:** 「授权域名」按钮 → `chrome.permissions.request({origins:[serverUrl + '/*']})`(用户手势触发);成功后提示
|
||||
- [ ] **Step 4:** commit `feat(popup): backend config UI + runtime origin permission`
|
||||
|
||||
---
|
||||
|
||||
## Task 11: popup 推送状态显示
|
||||
|
||||
**Files:** `popup.html`, `popup.js`
|
||||
|
||||
- [ ] **Step 1:** popup 加状态条:「已推 N / 待推 M / 失败 K」
|
||||
- [ ] **Step 2:** popup 打开时 + 每 2s `chrome.runtime.sendMessage({type:'getPushStats'})` 刷新
|
||||
- [ ] **Step 3:** 失败 K > 0 时红字 + 可点击查看 lastError(简化:弹 alert 或展开列表)
|
||||
- [ ] **Step 4:** commit `feat(popup): push stats display (pending/failed/sent)`
|
||||
|
||||
---
|
||||
|
||||
## Task 12: 端到端验证
|
||||
|
||||
**Files:** `scripts/e2e-plugin.md`(验证步骤文档)
|
||||
|
||||
- [ ] **Step 1:** 起 Plan 1 后端:`cd d:/alibb-server/backend && docker compose up -d --build`,等 health ok
|
||||
- [ ] **Step 2:** Chrome `chrome://extensions` 开发者模式 → 加载 `d:\alibb`(本 plan 改后的插件)
|
||||
- [ ] **Step 3:** 点插件图标 → 后端设置填 `http://localhost:8080` + token `dev-ingest-token-1234` + 启用 → 授权域名 → 保存
|
||||
- [ ] **Step 4:** 打开 `message.alibaba.com` → 抓一个询盘(用插件现有抓取流程,或 Playwright 注入触发)
|
||||
- [ ] **Step 5:** 验证:`curl -H "Authorization: Bearer <jwt>" http://localhost:8080/api/inquiries` 能看到刚抓的询盘;`docker compose exec mysql mysql -uroot -proot inquiry -e "SELECT inquiry_id, messages_count FROM inquiries"` 数据在
|
||||
- [ ] **Step 6:** popup 状态条显示「已推 1」
|
||||
- [ ] **Step 7:** 验证幂等:再抓同一询盘 → 后端 messages_count 不变(早返回)
|
||||
- [ ] **Step 8:** commit `test: e2e plugin→backend validation doc`
|
||||
|
||||
---
|
||||
|
||||
## 完成标准(DoD)
|
||||
|
||||
- [ ] manifest:`type:module` + alarms/unlimitedStorage + optional_host_permissions
|
||||
- [ ] `push/queue.js` / `payload.js` / `worker.js` 三个 module + vitest 测试全绿
|
||||
- [ ] `background.js`:alarms(1 分钟)消费队列,POST `/api/ingest`,失败指数退避(≤8 次);保留本地 storeDetail/getBgData
|
||||
- [ ] `content.js`:apiMsgToChat 产 messageId/sendTime(ISO)/translation;fetchChatByApi 提取 gmtCreate/gmtModified;processCurrentDetailPage enqueue
|
||||
- [ ] popup:后端设置(URL/token/启用)+ permissions.request 授权 + 状态显示(已推/待推/失败)
|
||||
- [ ] 端到端:抓一条询盘 → Plan 1 后端能看到数据;再抓同一条幂等(messages_count 不变)
|
||||
- [ ] 断网/后端停 → 数据进队列不丢;后端恢复 → 自动推完
|
||||
|
||||
## 后续(本 plan 不含)
|
||||
|
||||
- Plan 3:管理后台前端(查看/筛选/导出/统计)
|
||||
- Plan 4:正式部署(域名 + HTTPS + 备份定时 + 监控)
|
||||
- 增量推送(插件记录 last sendTime,只推新增消息)
|
||||
- 失败项重试 UI(popup 查看失败 + 手动重推)
|
||||
@@ -0,0 +1,126 @@
|
||||
# 线上测试环境部署(1Panel + alibaba 库 + OpenResty 站点)
|
||||
|
||||
> 实际部署方案(2026-07-17)。对应 spec §11.2「测试环境:公网 IP + 端口 + HTTP」。
|
||||
|
||||
## 目标服务器
|
||||
|
||||
- 公网 IP:`219.157.255.213`,Ubuntu 24.04,主机 `kt-W580G20`
|
||||
- 登录用户:`kt`(sudo 免密,不在 docker 组但 `sudo docker` 可用)
|
||||
- 已装:Docker 28.0.1 + compose v2.33.1、Node v20(前端 build 用 docker node 镜像)
|
||||
- 镜像加速:`/etc/docker/daemon.json` → `https://docker.1panel.live`
|
||||
- 已有 1Panel + OpenResty(管理所有站点,占 80/443)
|
||||
|
||||
## SSH 访问(本机 → 服务器)
|
||||
|
||||
- 本机密钥:`C:\Users\30997\.ssh\alibb_deploy`(ed25519,无 passphrase)
|
||||
- 连接(用 Windows 自带 OpenSSH,Git Bash 的 ssh 因私钥权限 644 被拒):
|
||||
```bash
|
||||
/c/Windows/System32/OpenSSH/ssh.exe -i 'C:\Users\30997\.ssh\alibb_deploy' kt@219.157.255.213 '...'
|
||||
```
|
||||
- 或本机 `~/.ssh/config` 加别名(Windows 路径 IdentityFile):
|
||||
```
|
||||
Host alibb
|
||||
HostName 219.157.255.213
|
||||
User kt
|
||||
IdentityFile C:\Users\30997\.ssh\alibb_deploy
|
||||
```
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
浏览器 ──http:23010──► 1Panel OpenResty 站点(alibb)
|
||||
├─ / → 站点 index(前端 dist 静态)
|
||||
└─ /api/* → backend 容器(:8080)
|
||||
后端容器(alibb-backend,Express)──► 1Panel MySQL(alibaba 库,:3306)
|
||||
扩展(抓 alibaba)──POST──► http://219.157.255.213:23010/api/ingest
|
||||
```
|
||||
|
||||
- **后端**:`~/alibb-server/backend`,docker compose(backend 服务,build 本地 Dockerfile,映射 127.0.0.1:8080)。连 **1Panel 已有的 MySQL**(容器 `1Panel-mysql-sLiG`,库 `alibaba`,密码 `wecbni5zRRnZrMMh`)。env:JWT_SECRET/INGEST_TOKEN/ADMIN_PASSWORD 强随机(存服务器 `~/alibb-secrets.txt`)。
|
||||
- **前端**:`~/alibb-server/frontend`,docker build 出 dist → 复制到 **1Panel 站点目录** `/opt/1panel/apps/openresty/openresty/www/sites/alibb/index`。用 1Panel 的 OpenResty serve(不再单独起 nginx 容器)。
|
||||
- **站点**:1Panel 建的「alibb」站点,`listen 23010`,`server_name 219.157.255.213`,root = 站点 index,`/api` 反代到 `backend:8080`(容器名),`try_files` SPA fallback,`client_max_body_size 50m`。站点 conf 在 `/opt/1panel/apps/openresty/openresty/conf/conf.d/alibb.conf`(1Panel 管理,**改后要在面板重载 OpenResty**,别手改被覆盖)。
|
||||
- **访问**:http://219.157.255.213:23010(admin / ADMIN_PASSWORD)
|
||||
|
||||
## 部署步骤(从零)
|
||||
|
||||
### 1. SSH 密钥(本机生成 + 上传 kt)
|
||||
```bash
|
||||
# 本机 Git Bash 生成
|
||||
ssh-keygen -t ed25519 -f ~/.ssh/alibb_deploy -N ""
|
||||
# 上传到 kt(用密码)
|
||||
/c/Windows/System32/OpenSSH/scp.exe -i ... ~/.ssh/alibb_deploy.pub kt@219.157.255.213:... # 或 ssh-copy-id
|
||||
```
|
||||
|
||||
### 2. 传代码(本机 → 服务器 ~/alibb-server)
|
||||
```bash
|
||||
cd /d && tar -czf ~/alibb-server.tgz --exclude='alibb-server/*/node_modules' --exclude='*/dist' --exclude='*/.git' alibb-server
|
||||
/c/Windows/System32/OpenSSH/scp.exe -i ... 'C:\Users\30997\alibb-server.tgz' kt@219.157.255.213:/tmp/
|
||||
# 服务器解压
|
||||
... 'tar xzf /tmp/alibb-server.tgz -C ~'
|
||||
```
|
||||
|
||||
### 3. 后端 build + up(连 alibaba 库)
|
||||
```bash
|
||||
cd ~/alibb-server/backend
|
||||
# 生成线上密钥(替换 compose dev 值)
|
||||
JWT_SECRET=$(openssl rand -hex 32); INGEST_TOKEN=$(openssl rand -hex 24); ADMIN_PASSWORD=$(openssl rand -hex 16)
|
||||
sed -i -e "s/dev-ingest-token-1234/$INGEST_TOKEN/" -e "s/dev-jwt-secret-1234/$JWT_SECRET/" -e "s/admin-password-1234/$ADMIN_PASSWORD/" docker-compose.yml
|
||||
# compose 改成连 1Panel mysql(alibaba 库)+ backend 端口映射 127.0.0.1:8080
|
||||
sudo docker compose build backend
|
||||
sudo docker compose up -d
|
||||
```
|
||||
|
||||
### 4. 前端 build → 复制站点
|
||||
```bash
|
||||
cd ~/alibb-server/frontend
|
||||
sudo docker run --rm -v "$PWD":/app -w /app node:22-alpine sh -c 'npm ci && npm run build'
|
||||
SITE=/opt/1panel/apps/openresty/openresty/www/sites/alibb/index
|
||||
sudo rm -rf "$SITE"/* && sudo cp -r dist/. "$SITE"/ && sudo chmod -R a+rX "$SITE"
|
||||
```
|
||||
|
||||
### 5. 站点 conf(1Panel 面板配或 alibb.conf)
|
||||
1Panel 面板建站点「alibb」(域名/IP `219.157.255.213`,端口 23010,根目录 = 站点 index),再配:
|
||||
- 伪静态:`try_files $uri $uri/ /index.html;`(SPA 路由刷新)
|
||||
- 反向代理:`/api/` → `http://backend:8080`(容器名,同 docker 网络;或 `http://127.0.0.1:8080`)
|
||||
- 或直接编辑 `alibb.conf`(root + `/api` proxy + try_files + client_max_body_size 50m),面板重载 OpenResty。
|
||||
|
||||
### 6. 安全组放 23010(云控制台)
|
||||
TCP 23010 入站;服务器防火墙 `sudo ufw allow 23010/tcp`(若开)。
|
||||
|
||||
### 7. 验证
|
||||
```bash
|
||||
curl http://219.157.255.213:23010/ # 200(index.html)
|
||||
curl http://219.157.255.213:23010/inquiries # 200(SPA fallback)
|
||||
curl http://219.157.255.213:23010/api/health # {"ok":true}
|
||||
```
|
||||
浏览器开 http://219.157.255.213:23010 → admin / ADMIN_PASSWORD 登录。
|
||||
|
||||
### 8. 扩展配置
|
||||
popup:serverUrl `http://219.157.255.213:23010` + token(~/alibb-secrets.txt 的 INGEST_TOKEN)→ 授权 → 启用推送 → 抓取。
|
||||
|
||||
## 增量更新(改代码后)
|
||||
|
||||
- **后端改**:传变更文件 → `cd ~/alibb-server/backend && sudo docker compose build backend && sudo docker compose up -d`(migrate 自动跑)。
|
||||
- **前端改**:传变更 → 服务器 `npm run build` → 复制 dist 到站点(Step 4)。
|
||||
- **扩展改**:本机改 → chrome://extensions 刷新扩展(用户操作)。
|
||||
|
||||
## 常用运维
|
||||
|
||||
| 操作 | 命令 |
|
||||
|---|---|
|
||||
| 后端日志 | `sudo docker compose logs -f backend` |
|
||||
| 重启后端 | `sudo docker compose restart backend` |
|
||||
| 查库 | `sudo docker exec -e MYSQL_PWD=wecbni5zRRnZrMMh 1Panel-mysql-sLiG mysql -ualibaba alibaba` |
|
||||
| 清询盘重抓 | `... mysql ... -e "SET FOREIGN_KEY_CHECKS=0; TRUNCATE chat_messages; TRUNCATE inquiries; SET FOREIGN_KEY_CHECKS=1;"`(customers 保留,重抓 upsert 更新) |
|
||||
|
||||
## 已知限制
|
||||
|
||||
- **折叠面板字段抓不到**:客户资料折叠面板里的 手机/座机/商业类型/销售平台/公司官网,alibaba 检查 `event.isTrusted`(只接受真实鼠标点击),扩展 JS 无法展开。这些字段大部分买家本就是"-"(alibaba 默认隐藏联系方式)。保留抓其他全部(基本信息 + 行为数据 + 店内行为 + 最近询盘产品图)。
|
||||
- **风控**:抓取间隔已随机化(randSleep),降低被识别为爬虫的概率;大量抓取建议分批 + 中途暂停。
|
||||
- **MySQL 公网暴露**:1Panel 的 mysql 映射 `0.0.0.0:3306`(1Panel 默认,非本次部署),建议后续在 1Panel 收紧为本地。
|
||||
|
||||
## 仓库
|
||||
|
||||
- 扩展:`d:/alibb`(master)
|
||||
- 后端:`d:/alibb-server/backend`(master)
|
||||
- 前端:`d:/alibb-server/frontend`(main)
|
||||
- 服务器代码:`~/alibb-server/{backend,frontend}`
|
||||
@@ -0,0 +1,587 @@
|
||||
# 聊天时间范围抓取 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 聊天抓取支持时间范围(全部/近1/2/3月/自定义起止),到范围起点即停翻页 —— 时间范围同时是风控减压手段。
|
||||
|
||||
**Architecture:** spec `docs/superpowers/specs/2026-08-25-chat-time-range-design.md`(7轮审查+真机实测,含附录A实现基准伪代码、附录B实测记录)。改动全部在扩展侧(popup + content.js),后端/推送零改动。判定逻辑以 `scripts/verify-chat-range.mjs` 纯函数为基准实现,content.js/popup.js 照搬(IIFE 架构无法被 vitest import,沿用 verify-risk-detect.mjs 复刻模式)。
|
||||
|
||||
**Tech Stack:** Chrome MV3 扩展(原生 JS)、vitest(fake-indexeddb)、node 复刻验证脚本。
|
||||
|
||||
---
|
||||
|
||||
## ⛔ 风控红线(全程有效,用户明确要求)
|
||||
|
||||
1. **不得改动任何现有安全层参数**:`randSleep(2000,5000)` 翻页间隔、`waitUntilWorkTime`、`postForm` 的 RISK 检测、`RISK_KEYWORDS`/`SKIP_KEYWORDS`。
|
||||
2. **真机验证只用小规模**:手动验证(Task 8)一律 `limit=10`,单次验证 ≤ 10 个询盘,两次验证之间间隔 ≥ 2 分钟。
|
||||
3. **不新增任何 API 端点调用**:只改现有 `querySummary`/`getOpMessages` 的参数(timeStamp)与本地逻辑。
|
||||
4. Task 1-7 全部是本地代码/纯逻辑验证,**零真实请求**。
|
||||
|
||||
## File Structure
|
||||
|
||||
| 文件 | 职责 |
|
||||
|---|---|
|
||||
| `scripts/verify-chat-range.mjs`(新) | 判定逻辑基准实现 + 用例锁定(纯函数,零请求) |
|
||||
| `popup.html` | 时间范围下拉/日期框/置灰 UI |
|
||||
| `popup.js` | computeChatRange 计算、校验、start 传参 |
|
||||
| `content.js` | state 字段、fetchChatByApi 主路径、水位结构、DOM 回退 |
|
||||
| `docs/.../spec`(已完成) | 依据,不再改 |
|
||||
|
||||
**同步义务**:verify 脚本中的纯函数(`minusMonths/computeChatRange/inChatRange/parseWatermark/shouldWriteWatermark/probeFailKind/toMs`)与 popup.js/content.js 内联版本必须逐字一致 —— 改任一侧必须同步另一侧(文件头注明)。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: verify-chat-range.mjs — 判定逻辑基准(TDD)
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/verify-chat-range.mjs`
|
||||
|
||||
- [ ] **Step 1: 写用例 + 桩函数(预期红)**
|
||||
|
||||
```js
|
||||
// 验证聊天时间范围判定逻辑(纯函数基准实现)。
|
||||
// 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++; };
|
||||
|
||||
// ---- 基准实现(实现照搬区)----
|
||||
function minusMonths(base, n) { throw new Error('TODO'); }
|
||||
function computeChatRange(mode, sinceStr, untilStr) { throw new Error('TODO'); }
|
||||
function inChatRange(sendTime, sinceMs, untilMs) { throw new Error('TODO'); }
|
||||
function parseWatermark(v) { throw new Error('TODO'); }
|
||||
function shouldWriteWatermark(oldWm, candId, candSendTime) { throw new Error('TODO'); }
|
||||
function probeFailKind(json) { throw new Error('TODO'); }
|
||||
|
||||
// ---- 用例 ----
|
||||
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 在 25~32 天前,until 不限', (() => {
|
||||
const r = computeChatRange('1m');
|
||||
const age = Date.now() - Date.parse(r.since);
|
||||
return r.until === null && age > 25 * 86400e3 && age < 32 * 86400e3;
|
||||
})());
|
||||
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);
|
||||
|
||||
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('只前进:候选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');
|
||||
|
||||
// toMs:parse 容错(spec §5:非法 → null 降级不限)
|
||||
function toMs(v) { const n = v ? Date.parse(v) : NaN; return Number.isFinite(n) ? n : null; }
|
||||
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 全部、不漏不重
|
||||
ok('until 游标 +1ms 恰好覆盖含止日末毫秒', (() => {
|
||||
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);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑,确认红**
|
||||
|
||||
Run: `node scripts/verify-chat-range.mjs`
|
||||
Expected: 脚本在**第一个用例即崩溃**(stack trace: `Error: TODO`)—— 桩函数的 throw 发生在用例 IIFE 内,无法逐行 FAIL;崩溃 + exit 1 即为有效红灯
|
||||
|
||||
- [ ] **Step 3: 填基准实现(替换 6 个桩函数)**
|
||||
|
||||
```js
|
||||
// 自然月回退:直接 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') {
|
||||
return { since: minusMonths(new Date(), Number(mode[0])).toISOString(), until: null };
|
||||
}
|
||||
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;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 跑,确认全绿(36 passed)**
|
||||
|
||||
Run: `node scripts/verify-chat-range.mjs`
|
||||
Expected: `36 passed, 0 failed`,exit 0
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add scripts/verify-chat-range.mjs
|
||||
git commit -m "feat(ext): chat time-range predicate baseline + verification cases"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: popup UI + 传参
|
||||
|
||||
**Files:**
|
||||
- Modify: `popup.html`(options 区,chatLimitInput 行附近)
|
||||
- Modify: `popup.js`(元素引用、syncChatModeUI、start 计算)
|
||||
|
||||
- [ ] **Step 1: popup.html 加控件**
|
||||
|
||||
在"每询盘聊天上限"label 行后追加(chatLimit label 内加 hint span):
|
||||
|
||||
```html
|
||||
<label>每询盘聊天上限(0=全量;测试可设小值加快): <input type="number" id="chatLimitInput" value="0" min="0" style="width:64px;"> 条 <span id="chatLimitHint" style="color:#999;"></span></label>
|
||||
<label>聊天时间范围:
|
||||
<select id="chatTimeRange" style="width:120px;">
|
||||
<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>
|
||||
</label>
|
||||
<div id="chatCustomDates" style="display:none;margin:4px 0;">
|
||||
<input type="date" id="chatSinceDate"> ~ <input type="date" id="chatUntilDate">
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: popup.js 加引用 + 模式切换 + 计算传参**
|
||||
|
||||
元素引用区(文件顶部 getElementById 区)追加:
|
||||
|
||||
```js
|
||||
const chatTimeRangeSelect = document.getElementById('chatTimeRange');
|
||||
const chatCustomDates = document.getElementById('chatCustomDates');
|
||||
const chatSinceDate = document.getElementById('chatSinceDate');
|
||||
const chatUntilDate = document.getElementById('chatUntilDate');
|
||||
const chatLimitHint = document.getElementById('chatLimitHint');
|
||||
```
|
||||
|
||||
(在 push-config 区之前)追加模式切换 + 基准函数照搬(注明同步义务,函数体与 verify-chat-range.mjs **逐字一致**):
|
||||
|
||||
```js
|
||||
// ==== 聊天时间范围(spec 2026-08-25 §4.1)====
|
||||
// minusMonths/computeChatRange 与 scripts/verify-chat-range.mjs 基准实现逐字同步
|
||||
function minusMonths(base, n) { /* 同 verify-chat-range.mjs */ }
|
||||
function computeChatRange(mode, sinceStr, untilStr) { /* 同 verify-chat-range.mjs */ }
|
||||
|
||||
function syncChatModeUI() {
|
||||
const mode = chatTimeRangeSelect.value;
|
||||
const custom = mode === 'custom';
|
||||
chatCustomDates.style.display = custom ? 'block' : 'none';
|
||||
if (custom && !chatUntilDate.value) {
|
||||
const t = new Date();
|
||||
chatUntilDate.value = `${t.getFullYear()}-${String(t.getMonth() + 1).padStart(2, '0')}-${String(t.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
const timeMode = mode !== 'all';
|
||||
document.getElementById('chatLimitInput').disabled = timeMode; // D1: 时间替代条数
|
||||
chatLimitHint.textContent = timeMode ? '(时间模式下忽略)' : '';
|
||||
}
|
||||
chatTimeRangeSelect.addEventListener('change', syncChatModeUI);
|
||||
syncChatModeUI();
|
||||
```
|
||||
|
||||
btnStart click 里,`send('start', {...})` **之前**插入校验,并改传参:
|
||||
|
||||
```js
|
||||
const range = computeChatRange(chatTimeRangeSelect.value, chatSinceDate.value, chatUntilDate.value);
|
||||
if (range.error) { addLog(range.error, 'error'); statusText.textContent = '就绪'; updateButtons('idle'); return; }
|
||||
```
|
||||
|
||||
`send('start', {...})` 的参数改为:
|
||||
|
||||
```js
|
||||
await send('start', {
|
||||
mode: modeSelect.value,
|
||||
dedup: optDedup.checked,
|
||||
chat: optChat.checked,
|
||||
limit: parseInt(limitSelect.value, 10),
|
||||
chatLimit: chatTimeRangeSelect.value === 'all' ? (parseInt(document.getElementById('chatLimitInput').value, 10) || 0) : 0,
|
||||
chatSince: range.since,
|
||||
chatUntil: range.until,
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 语法自检**
|
||||
|
||||
Run: `node --check popup.js 2>&1 || echo "popup.js 非 module,--check 对 ESM 误报可忽略;改用浏览器加载验证"`
|
||||
手动:重载扩展 → 打开 popup → 切下拉:custom 显示日期框且止=今天;时间模式下条数框置灰;切回 all 恢复。(零请求)
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add popup.html popup.js
|
||||
git commit -m "feat(ext): chat time-range picker in popup (all/1m/2m/3m/custom)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: content.js — state 字段 + 派生
|
||||
|
||||
**Files:**
|
||||
- Modify: `content.js`(start 分支 state 重建对象;fetchChatByApi/loadAllChatHistory 入口)
|
||||
|
||||
- [ ] **Step 1: start 分支重建对象加两字段**
|
||||
|
||||
`case 'start':` 的 `state = {...}` 对象中,`chatLimit: msg.chatLimit ?? 0,` 行后加:
|
||||
|
||||
```js
|
||||
chatSince: msg.chatSince ?? null, chatUntil: msg.chatUntil ?? null, // 时间范围(spec §4.2);显式列入重建清单
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 文件顶部(IIFE 内、RISK_KEYWORDS 之前)加范围工具(照搬 verify + 派生)**
|
||||
|
||||
```js
|
||||
// ==== 聊天时间范围工具(与 scripts/verify-chat-range.mjs 基准逐字同步)====
|
||||
function inChatRange(sendTime, sinceMs, untilMs) { /* 同 verify-chat-range.mjs */ }
|
||||
function parseWatermark(v) { /* 同 verify-chat-range.mjs */ }
|
||||
function shouldWriteWatermark(oldWm, candId, candSendTime) { /* 同 verify-chat-range.mjs */ }
|
||||
function probeFailKind(json) { /* 同 verify-chat-range.mjs */ }
|
||||
function toMs(v) { const n = v ? Date.parse(v) : NaN; return Number.isFinite(n) ? n : null; } // 同 verify-chat-range.mjs 照搬区(parse 失败→null 降级不限,spec §5)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: fetchChatByApi 与 loadAllChatHistory 入口各派生一次**
|
||||
|
||||
```js
|
||||
const sinceMs = toMs(state.chatSince), untilMs = toMs(state.chatUntil);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 手动自检(零请求)**
|
||||
|
||||
重载扩展 → popup"开始抓取"选"全部" → 行为与现状一致(state 字段为 null 短路所有新逻辑);SW console 无报错。
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add content.js
|
||||
git commit -m "feat(ext): chatSince/chatUntil state plumbed through start"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: content.js — 水位存储升级(只前进)
|
||||
|
||||
**Files:**
|
||||
- Modify: `content.js`(`getLastTopMsgId`/`setLastTopMsgId` 改造为 `getWatermark`/`trySetWatermark`)
|
||||
|
||||
- [ ] **Step 1: 替换两个函数**
|
||||
|
||||
删除 `getLastTopMsgId`/`setLastTopMsgId`,替换为(键名 `lastTopMsg_` 不变,clear 分支无需改):
|
||||
|
||||
```js
|
||||
// 增量水位(新格式 {id,sendTime};只前进不倒退,spec §4.5)
|
||||
async function getWatermark(inquiryId) {
|
||||
if (!inquiryId) return null;
|
||||
try {
|
||||
const r = await chrome.storage.local.get(['lastTopMsg_' + inquiryId]);
|
||||
return parseWatermark(r && r['lastTopMsg_' + inquiryId]);
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
async function trySetWatermark(inquiryId, candId, candSendTime) {
|
||||
if (!inquiryId) return;
|
||||
const oldWm = await getWatermark(inquiryId);
|
||||
if (!shouldWriteWatermark(oldWm, candId, candSendTime)) return;
|
||||
try {
|
||||
await chrome.storage.local.set({ ['lastTopMsg_' + inquiryId]: JSON.stringify({ id: candId, sendTime: candSendTime }) });
|
||||
} catch (e) { /* 持久化失败不致命 */ }
|
||||
}
|
||||
```
|
||||
|
||||
注:调用点改造在 Task 5(fetchChatByApi 内),本 Task 仅换函数定义会暂留旧调用 → **Task 4 与 Task 5 合并提交**(见 Task 5 Step 5)。
|
||||
|
||||
---
|
||||
|
||||
### Task 5: content.js — fetchChatByApi 主路径(spec 附录 A 落地)
|
||||
|
||||
**Files:**
|
||||
- Modify: `content.js`(`// 2) 循环 getOpMessages` 起 至 `return { messages: ... }` 止的整段)
|
||||
|
||||
- [ ] **Step 1: 替换翻页整段(对照附录 A 伪代码逐行落地)**
|
||||
|
||||
```js
|
||||
// 增量水位(新格式;旧 string 兼容读取)
|
||||
const lastTopWm = await getWatermark(inquiryId);
|
||||
const lastTopId = lastTopWm ? lastTopWm.id : '';
|
||||
if (lastTopId) log(`聊天 API 增量模式: 上次水位 ${lastTopId}(sendTime=${lastTopWm && lastTopWm.sendTime}),遇此停止翻页`);
|
||||
if (sinceMs != null || untilMs != null) {
|
||||
log(`聊天范围: ${sinceMs != null ? new Date(sinceMs).toLocaleString() : '最早'} ~ ${untilMs != null ? new Date(untilMs).toLocaleString() : '至今'}`);
|
||||
}
|
||||
// 2) 循环 getOpMessages(spec 附录A:跳游标/probe/过滤/顺序/停止/水位)
|
||||
const jump = untilMs != null && !state.cursorJumpUnsupported;
|
||||
let cursor = jump ? untilMs + 1 : Date.now(); // +1ms:实测语义=严格小于,恰好覆盖 <= untilMs(附录B)
|
||||
let probeRound = jump;
|
||||
const raw = [];
|
||||
const MAX_ROUNDS = state.chatLimit > 0 ? Math.ceil(state.chatLimit / 20) : 2000;
|
||||
let candId = null, candSendTime = null; // 候选水位:逐轮取最大 sendTime(spec §4.5)
|
||||
let stopReason = '轮数上限';
|
||||
for (let i = 0; i < MAX_ROUNDS; i++) {
|
||||
if (state.phase === 'paused') return null;
|
||||
if (!await waitUntilWorkTime('聊天翻页')) return null;
|
||||
if (state.phase === 'paused') return null;
|
||||
const json = await postForm(
|
||||
`https://onetalk.alibaba.com/message/getOpMessages.htm?ctoken=${encodeURIComponent(ctoken)}&_tb_token_=${encodeURIComponent(tbToken)}`,
|
||||
{ params: JSON.stringify({ scene: '', timeSlide: { timeStamp: cursor, forward: false, pageSize: 20 }, secOwnerAccountId, secTradeId, secTargetAccountId, openRealTimeTranslation: true, targetAliId }), _csrf: xsrf }
|
||||
);
|
||||
if (probeRound) { // 首轮探测:仅降级集走 fallback(真风控照抛,spec §4.3)
|
||||
probeRound = false;
|
||||
const kind = probeFailKind(json);
|
||||
if (kind) {
|
||||
state.cursorJumpUnsupported = false; // 先不设标志,由降级重试结果决定(实测精化)
|
||||
log(`until 跳游标首轮 ${kind === 'empty' ? '空 list' : '结构异常'},降级 cursor=now 重试`);
|
||||
cursor = Date.now();
|
||||
await randSleep(2000, 5000);
|
||||
// 重试首轮非空才实锤"不支持"(真空时重试也空,不设标志,下个询盘继续尝试)
|
||||
// —— 实现方式:本标志在下一轮循环体内设置(见下)
|
||||
state._probeDowngraded = true;
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
if (!json || typeof json !== 'object') throw new Error('RISK:结构异常(非对象)');
|
||||
if (json.code !== undefined && Number(json.code) !== 200) throw new Error('RISK:结构异常(code=' + json.code + ')');
|
||||
if (json.data == null) throw new Error('RISK:结构异常(data=null)');
|
||||
}
|
||||
const list = (json.data && json.data.list) || [];
|
||||
if (!list.length) {
|
||||
// 降级重试后的空 list = 真空(正常到顶);且若 _probeDowngraded 未实锤则清试探态
|
||||
if (state._probeDowngraded) { state._probeDowngraded = false; /* 重试也空 → 真空,不设 unsupported */ }
|
||||
stopReason = '到顶(空列表)'; break;
|
||||
}
|
||||
if (state._probeDowngraded) { // 降级重试首批非空 → 跳游标确实漏数据,实锤不支持
|
||||
state._probeDowngraded = false;
|
||||
state.cursorJumpUnsupported = true; saveState();
|
||||
}
|
||||
for (const m of list) { // 候选水位逐轮 max(未过滤口径,spec §4.5)
|
||||
if (m.sendTime != null && (candSendTime == null || m.sendTime > candSendTime)) {
|
||||
candId = String(m.messageId); candSendTime = m.sendTime;
|
||||
}
|
||||
}
|
||||
let hitLastTop = false;
|
||||
for (const m of list) { // 顺序:水位命中查未过滤 list,再范围过滤(spec §4.3.2)
|
||||
if (lastTopId && String(m.messageId) === lastTopId) { hitLastTop = true; break; }
|
||||
if (inChatRange(m.sendTime, sinceMs, untilMs)) raw.push(m);
|
||||
}
|
||||
let minT = Infinity;
|
||||
for (const m of list) if (m.sendTime != null && m.sendTime < minT) minT = m.sendTime; // null 跳过(防游标跳1970)
|
||||
if (minT !== Infinity) cursor = minT;
|
||||
if (raw.length % 400 < 20) log(`聊天 API 拉取中: ${raw.length} 条`);
|
||||
if (hitLastTop) { stopReason = '增量命中'; break; }
|
||||
if (sinceMs != null && cursor < sinceMs) { stopReason = '时间边界'; break; }
|
||||
if (list.length < 20) { stopReason = '到顶'; break; }
|
||||
await randSleep(2000, 5000);
|
||||
}
|
||||
log(`聊天 API 完成: ${raw.length} 条(停:${stopReason})`, 'success');
|
||||
await trySetWatermark(inquiryId, candId, candSendTime); // 只前进写入(spec §4.5)
|
||||
```
|
||||
|
||||
同时删除原 `newestMsgIdThisRound` 相关整段与旧 `setLastTopMsgId` 调用;`lastTopMsgId` 读取段(原 `const lastTopMsgId = await getLastTopMsgId(...)`)由开头 `getWatermark` 段替代。**其后的 `return { messages: raw.map(...), meta };` 原样保留,不在替换范围内。**
|
||||
|
||||
- [ ] **Step 2: 同步义务检查**
|
||||
|
||||
对照 `scripts/verify-chat-range.mjs`:`inChatRange/parseWatermark/shouldWriteWatermark/probeFailKind` 函数体逐字一致(Task 3 已内联)。
|
||||
|
||||
- [ ] **Step 3: 逻辑回归(零请求)**
|
||||
|
||||
Run: `node scripts/verify-chat-range.mjs && node scripts/verify-risk-detect.mjs && npx vitest run`
|
||||
Expected: 36 passed / 10 passed / 16 passed,全部 exit 0
|
||||
|
||||
- [ ] **Step 4: 语法自检(浏览器加载)**
|
||||
|
||||
重载扩展,SW console 无报错;popup"开始抓取(全部, limit=10)"跑一轮 —— 行为与现状一致(all → sinceMs/untilMs null,新分支全短路)。
|
||||
|
||||
- [ ] **Step 5: Commit(Task 4+5 合并)**
|
||||
|
||||
```bash
|
||||
git add content.js
|
||||
git commit -m "feat(ext): time-bounded chat paging - cursor jump + probe fallback + forward-only watermark"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: content.js — DOM 回退路径
|
||||
|
||||
**Files:**
|
||||
- Modify: `content.js`(`loadAllChatHistory`)
|
||||
|
||||
- [ ] **Step 1: 全量分支加时间探测停滚**
|
||||
|
||||
`loadAllChatHistory` 全量分支(无 lastDomTime 时)滚动循环内,`await randSleep(2000, 6000)` 之后、计数逻辑之前加:
|
||||
|
||||
```js
|
||||
// DOM 时间边界:顶部向下找第一个带时间 span 的 wrapper(时间戳稀疏渲染),早于 since → 停滚(spec §4.4)
|
||||
if (sinceMs != null) {
|
||||
let oldestT = null;
|
||||
for (const mw of document.querySelectorAll('.message-item-wrapper')) {
|
||||
const te = mw.querySelector('.item-base-info');
|
||||
let hit = false;
|
||||
if (te) for (const sp of te.querySelectorAll('span')) {
|
||||
const m = sp.innerText.match(/(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2})/);
|
||||
if (m) { oldestT = new Date(m[1] + 'T' + m[2]).getTime(); hit = true; break; }
|
||||
}
|
||||
if (hit) break;
|
||||
}
|
||||
if (oldestT != null && oldestT < sinceMs) { log('DOM 时间边界,停止滚动'); break; }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提取后过滤(全量/增量两分支)**
|
||||
|
||||
全量分支尾部(水位 maxT 计算保持用**未过滤** `all`):
|
||||
|
||||
```js
|
||||
const all = extractDomChatMessages();
|
||||
// ... maxT 用 all 计算并存 lastDomTime(不变,水位=见到的最新)...
|
||||
const filtered = all.filter(m => inChatRange(m.sendTime ? Date.parse(m.sendTime) : null, sinceMs, untilMs));
|
||||
state._incDomMessages = filtered;
|
||||
state._incDomFiltered = (sinceMs != null || untilMs != null); // 空数组也具决定性(见下)
|
||||
```
|
||||
|
||||
**配套改消费点**(scrapeDetailData 内,原 `incMsgs && incMsgs.length` 处)—— 否则空 `filtered` 会穿透 `incMsgs.length` 保护、回退到**未过滤**的 `extractDomChatMessages()`,把范围外可见消息泄漏出去(违背 §4.4"0 条降级"):
|
||||
|
||||
```js
|
||||
const incMsgs = state._incDomMessages || null;
|
||||
const incFiltered = !!state._incDomFiltered; // 空数组 + 过滤标记 → 空也有效(0条降级)
|
||||
state._incDomMessages = null; state._incDomFiltered = false;
|
||||
// ...(apiMsgs 判定不变)...
|
||||
if (!msgs) msgs = (incMsgs && (incMsgs.length || incFiltered)) ? incMsgs : null;
|
||||
```
|
||||
|
||||
增量分支:
|
||||
|
||||
```js
|
||||
const inc = all.filter(m => m.sendTime && m.sendTime > lastDomTime)
|
||||
.filter(m => inChatRange(Date.parse(m.sendTime), sinceMs, untilMs));
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 回归 + Commit**
|
||||
|
||||
Run: `node scripts/verify-chat-range.mjs && npx vitest run`
|
||||
|
||||
```bash
|
||||
git add content.js
|
||||
git commit -m "feat(ext): DOM fallback respects chat time range (probe-stop + filter)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 全量回归(零请求)
|
||||
|
||||
- [ ] **Step 1: 三套验证全绿**
|
||||
|
||||
Run: `node scripts/verify-chat-range.mjs && node scripts/verify-risk-detect.mjs && npx vitest run`
|
||||
Expected: 36 / 10 / 16 全 passed,exit 0
|
||||
|
||||
- [ ] **Step 2: 对照 spec 附录 A 逐行走查一遍 fetchChatByApi 实际代码(人工,15 分钟)**
|
||||
|
||||
锚点清单:初始游标+1 / probe 只首轮 / PROBE 降级集不含 non-JSON / 水位命中未过滤 / 过滤 null guard / minT 跳 null / 停止优先级(增量>时间>到顶)/ stopReason 日志 / trySetWatermark 只前进 / randSleep 与 waitUntilWorkTime 原样。
|
||||
|
||||
---
|
||||
|
||||
### Task 8: 手动验证(⚠️ 风控纪律:limit=10、间隔≥2分钟、不批量)
|
||||
|
||||
- [ ] **Step 1: 回归基线** — 选"全部"+limit=10,跑一轮,行为与旧版一致(注:all 模式**无**"聊天范围"日志,仅范围模式输出)。
|
||||
- [ ] **Step 2: 近1月** — 选"近 1 个月"+limit=10(挑含老客户的窗口),日志出现 `停:时间边界`,单询盘条数明显少于全量。两次运行间隔 ≥2 分钟。
|
||||
- [ ] **Step 3: 校验** — 选 custom,起>止 → popup 报错不发;止选明天 → 报错。(零请求)
|
||||
- [ ] **Step 4: until 跳游标复测(附录B复测)** — custom 起=2个月前、止=1个月前 + limit=10:每个询盘**首轮** SW Network 里 `getOpMessages` 请求体 timeStamp ≈ 止日+1ms(注:无成功标志,后续询盘首轮**仍会**以止日起跳 —— 这是设计行为,不是重复试探 bug);首轮返回正常 → 本询盘后续轮次从 minT 正常翻页、无"降级 cursor=now"日志;若出现降级且降级重试首批非空 → 后续询盘直接 now 起跳(标志生效)。
|
||||
- [ ] **Step 5: 增量** — 同询盘重复 Step 2,第二次 `停:增量命中` 且推送无重复(后端 count 不变)。
|
||||
- [ ] **Step 6: 验证结果记录回 spec 附录 B 复测栏(如行为有出入,STOP 并回到 systematic-debugging)**
|
||||
- [ ] **Step 7: 范围存活验证** — 范围模式任务中途暂停→恢复、扩展重载后:抓取沿用同一范围(state 持久化链路),日志仍显示同一"聊天范围"。
|
||||
- [ ] **Step 8: 水位升级确认** — 首轮范围抓取后旧水位(string)升级为 JSON,下轮增量正常命中(停:增量命中)。
|
||||
- [ ] **Step 9: 上线前开关** — 全部验证完成后把 `WORK_TIME_ENABLED` 改回 `true`(生产作息限制;当前为用户测试态 false)。
|
||||
@@ -0,0 +1,103 @@
|
||||
# 服务器重装备份方案(alibb 项目)
|
||||
|
||||
> 2026-08-25。服务器 `219.157.255.213`(kt-W580G20)需要重装系统。
|
||||
> **范围:只备 alibb**(后端 + 前端 + alibaba 库 + 站点配置);服务器上其他业务(1Panel 全家桶、邮件、小红书等)与本备份无关。
|
||||
> 备份落盘:本机 `D:\alibb-server\backups\alibb-backup-20260825\`(D 盘重装不受影响)。
|
||||
|
||||
## 一、备份内容(已完成 ✅)
|
||||
|
||||
服务器端打包于 `~/alibb-backup-20260825`,已 scp 拉回本机 `D:\alibb-server\backups\`,SHA256 全部校验通过。
|
||||
|
||||
| 文件 | 大小 | 内容 |
|
||||
|---|---|---|
|
||||
| `alibaba-db.sql` | 25M | alibaba 库全量 dump(`--single-transaction --no-tablespaces`):chat_messages 61872 行、customers 14794 行、inquiries 13570 行、users、_prisma_migrations |
|
||||
| `alibb-server-code.tgz` | 998K | `~/alibb-server` 代码(backend+frontend),**docker-compose.yml 内含生产密钥**(JWT_SECRET/INGEST_TOKEN/ADMIN_PASSWORD) |
|
||||
| `alibb-secrets.txt` | 296B | 生产密钥明文 |
|
||||
| `alibb-site.conf` | 750B | OpenResty 站点 conf:`listen 23010`、`/api` → `http://backend:8080`、SPA fallback、`client_max_body_size 50m` |
|
||||
| `alibb-frontend-dist.tgz` | 811K | 1Panel 站点目录(前端 dist;可由代码重 build,备作保险) |
|
||||
| `alibb-backend-inspect.json` | 11K | 容器完整配置快照(env/网络/挂载) |
|
||||
| `RESTORE-NOTES.md` | — | 恢复要点:MySQL 授权重建、网络别名、密钥清单 |
|
||||
|
||||
dump 完整性:文件尾 `-- Dump completed on 2026-08-25 14:14:01`,5 张 CREATE TABLE 齐全。
|
||||
|
||||
## 二、关键事实(重装前记牢)
|
||||
|
||||
1. **密钥全部沿用备包里的值** → 重装后扩展的 serverUrl + INGEST_TOKEN 不用改,老数据 JWT 也不失效。
|
||||
2. **mysqldump 不含用户授权** → 重装后必须手工执行(见 RESTORE-NOTES.md):
|
||||
```sql
|
||||
CREATE USER 'alibaba'@'%' IDENTIFIED BY 'wecbni5zRRnZrMMh';
|
||||
GRANT ALL PRIVILEGES ON `alibaba`.* TO 'alibaba'@'%' WITH GRANT OPTION;
|
||||
FLUSH PRIVILEGES;
|
||||
```
|
||||
3. backend 加入 **external 网络 `1panel-network`**(1Panel 装好后自动存在),容器别名 `alibb-backend`/`backend`——OpenResty 反代按容器名 `backend` 寻址,别名丢了 /api 就 502。
|
||||
4. 端口映射是 `127.0.0.1:8090:8080`(宿主 8090,比 7 月部署文档写的 8080 新,以备包为准)。
|
||||
5. 1Panel 的 MySQL root 密码重装后会变(装 1Panel 时新设),与业务无关;业务只依赖上面的 `alibaba` 账号。
|
||||
|
||||
## 三、恢复步骤(新系统从零)
|
||||
|
||||
### 1. 基础环境
|
||||
```bash
|
||||
# Ubuntu 24.04 + Docker(官方脚本)+ 1Panel(官方安装脚本,装时设新的面板/MySQL root 密码)
|
||||
# 1Panel 应用商店装:OpenResty、MySQL 8.x
|
||||
# 云安全组放行:23010/tcp(以及 1Panel 面板端口)
|
||||
```
|
||||
|
||||
### 2. 恢复代码
|
||||
```bash
|
||||
# 本机推备包上去(Windows OpenSSH)
|
||||
/c/Windows/System32/OpenSSH/scp.exe -i 'C:\Users\30997\.ssh\alibb_deploy' -r 'D:\alibb-server\backups\alibb-backup-20260825' kt@219.157.255.213:~/
|
||||
ssh 上去:
|
||||
tar xzf ~/alibb-backup-20260825/alibb-server-code.tgz -C ~ # → ~/alibb-server,compose 已是生产值,勿再跑 sed
|
||||
```
|
||||
|
||||
### 3. 恢复数据库
|
||||
```bash
|
||||
# 建库 + 授权 + 导入(注意库名 alibaba 与用户名 alibaba 同名)
|
||||
sudo docker exec -i 1Panel-mysql-<新后缀> mysql -uroot -p'<新root密码>' <<'SQL'
|
||||
CREATE DATABASE alibaba CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
CREATE USER 'alibaba'@'%' IDENTIFIED BY 'wecbni5zRRnZrMMh';
|
||||
GRANT ALL PRIVILEGES ON `alibaba`.* TO 'alibaba'@'%' WITH GRANT OPTION;
|
||||
FLUSH PRIVILEGES;
|
||||
SQL
|
||||
sudo docker exec -i 1Panel-mysql-<新后缀> mysql -uroot -p'<新root密码>' alibaba < ~/alibb-backup-20260825/alibaba-db.sql
|
||||
```
|
||||
|
||||
### 4. 起后端
|
||||
```bash
|
||||
cd ~/alibb-server/backend
|
||||
sudo docker compose build backend && sudo docker compose up -d # migrate 自动跑(应显示全部 already applied)
|
||||
```
|
||||
注意:compose 里 networks 声明 `1panel-network` external——1Panel 装好后已存在,无需改。
|
||||
|
||||
### 5. 站点 + 前端
|
||||
- 1Panel 面板建网站「alibb」:域名/IP `219.157.255.213`,端口 `23010`,根目录默认站点 index
|
||||
- 直接用备份 conf:把 `alibb-site.conf` 内容对到 `/opt/1panel/apps/openresty/openresty/conf/conf.d/alibb.conf`(经面板配伪静态+反代等价),**面板里重载 OpenResty**
|
||||
- 前端二选一:解包 `alibb-frontend-dist.tgz` 复制进站点目录;或 `~/alibb-server/frontend` 里 docker node build 后复制(同 7 月文档 Step 4)
|
||||
|
||||
### 6. 验证
|
||||
```bash
|
||||
curl http://219.157.255.213:23010/ # 200
|
||||
curl http://219.157.255.213:23010/api/health # {"ok":true}
|
||||
```
|
||||
浏览器登录 admin / ADMIN_PASSWORD(alibb-secrets.txt),确认询盘/客户/聊天记录数量与备份时一致(询盘 13570 / 客户 14794 / 消息 61872)。
|
||||
|
||||
### 7. 扩展侧
|
||||
无需改配置(serverUrl `http://219.157.255.213:23010` + 原 token 均不变)。Chrome 里点一次「测试连接」确认,再恢复日常抓取。
|
||||
|
||||
## 四、重装前 Checklist
|
||||
|
||||
- [x] 服务器备包完成并拉回本机,SHA256 校验通过
|
||||
- [x] MySQL 授权语句已记录(RESTORE-NOTES.md)
|
||||
- [ ] **再拷一份 `D:\alibb-server\backups\` 到外接硬盘或网盘**(D 盘虽不动,双保险,防重装误格全盘)
|
||||
- [ ] 重装窗口内**停止扩展抓取**(推送会失败;若误抓,数据在本地,恢复后可重推;被风控的会话除外)
|
||||
- [ ] 本机 `d:\alibb-server\backend` 有未提交改动(schema.prisma / ingest.service.ts / 新 migration)——与本次备份无关,但建议提交或至少保留现状
|
||||
- [x] 重装方式无要求:恢复完全依赖本机 D 盘备份,服务器**彻底重置也可接受**(其他业务数据是否保留与 alibb 无关)
|
||||
|
||||
## 五、风险与说明
|
||||
|
||||
- **备份截止 2026-08-25 14:14**。之后新增的询盘/抓取数据不在 dump 里;重装前若又抓了数据,可重新导一次(命令见下)。
|
||||
- 服务器备包副本留在 `kt@219.157.255.213:~/alibb-backup-20260825/`,若重装保留 /home 可顺带幸存(不依赖)。
|
||||
- 增量重导命令:
|
||||
```bash
|
||||
ssh … 'sudo docker exec -e MYSQL_PWD=wecbni5zRRnZrMMh 1Panel-mysql-sLiG mysqldump -ualibaba --single-transaction --no-tablespaces --routines --triggers --databases alibaba' > D:\alibb-server\backups\alibaba-db-final.sql
|
||||
```
|
||||
@@ -0,0 +1,164 @@
|
||||
# alibb 服务器重装后完整恢复部署方案(Runbook)
|
||||
|
||||
> 2026-08-25。配合 [2026-08-25-server-reinstall-backup.md](2026-08-25-server-reinstall-backup.md) 使用。
|
||||
> 目标:新装系统后,把 alibb 恢复到与重装前**业务完全一致**(数据、密钥、访问方式、扩展零改动)。
|
||||
> 备份位置:本机 `D:\alibb-server\backups\`(SHA256 已校验)。
|
||||
|
||||
## 0. 恢复后与重装前的差异(预期内,不影响业务)
|
||||
|
||||
| 项 | 重装前 | 重装后 | 影响 |
|
||||
|---|---|---|---|
|
||||
| MySQL 容器名 | `1Panel-mysql-sLiG` | `1Panel-mysql-<新随机后缀>` | compose 的 DATABASE_URL 要 sed 替换(见 §4.2) |
|
||||
| 1Panel 面板/MySQL root 密码 | 旧值 | 装机新设 | 无(alibb 只用 `alibaba` 账号) |
|
||||
| SSH host key / kt 用户 | 有 | 新建 | 本机要清 known_hosts + 重新传公钥(§1) |
|
||||
| 公网 IP | 219.157.255.213 | 云盘重装一般保留 | **若 IP 变了**,要改 3 处(§8) |
|
||||
| 数据 | — | 恢复到 2026-08-25 14:14 备份时点 | 之后的新数据需重装前重导(见备份方案 §五) |
|
||||
|
||||
## 1. 前置:本机能连上新服务器
|
||||
|
||||
```bash
|
||||
# 1) 清掉旧 host key(重装后会报 REMOTE HOST IDENTIFICATION HAS CHANGED)
|
||||
/c/Windows/System32/OpenSSH/ssh.exe -i 'C:\Users\30997\.ssh\alibb_deploy' kt@219.157.255.213 'true' 2>&1 | head -3
|
||||
# 报错则执行:
|
||||
/c/Windows/System32/OpenSSH/ssh-keygen.exe -R 219.157.255.213
|
||||
|
||||
# 2) 新系统建 kt 用户(sudo 免密)+ 传公钥(用 root/云控制台密码登录一次)
|
||||
ssh root@219.157.255.213 'useradd -m -s /bin/bash kt && echo "kt ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/kt && mkdir -p /home/kt/.ssh'
|
||||
/c/Windows/System32/OpenSSH/scp.exe 'C:\Users\30997\.ssh\alibb_deploy.pub' root@219.157.255.213:/home/kt/.ssh/authorized_keys
|
||||
ssh root@219.157.255.213 'chown -R kt:kt /home/kt/.ssh && chmod 700 /home/kt/.ssh && chmod 600 /home/kt/.ssh/authorized_keys'
|
||||
```
|
||||
|
||||
## 2. 系统基础(Ubuntu 24.04)
|
||||
|
||||
```bash
|
||||
# Docker(官方脚本)
|
||||
curl -fsSL https://get.docker.com | sudo sh
|
||||
# 镜像加速(照旧)
|
||||
sudo tee /etc/docker/daemon.json <<'EOF'
|
||||
{ "registry-mirrors": ["https://docker.1panel.live"] }
|
||||
EOF
|
||||
sudo systemctl restart docker
|
||||
# 1Panel(官方脚本,装时设新面板密码)
|
||||
curl -sSL https://resource.fit2cloud.com/1panel/package/quick_start.sh -o quick_start.sh && sudo bash quick_start.sh
|
||||
```
|
||||
|
||||
## 3. 1Panel 应用安装
|
||||
|
||||
面板 → 应用商店,安装:
|
||||
|
||||
- **OpenResty**(必须先于建站)
|
||||
- **MySQL 5.7.44**(与重装前一致,优先选 5.7)
|
||||
- ⚠️ 若商店已无 5.7 → 装 8.0 也可:dump 为 utf8mb4,兼容;仅 §4.1 建用户时多加一句 `IDENTIFIED WITH mysql_native_password BY '...'`(防老驱动连不上,Prisma/mysql2 两种都支持,加这句最稳)
|
||||
|
||||
装好后记下 **MySQL 容器实际名称**(下称 `<MYSQL容器名>`,形如 `1Panel-mysql-XXXX`)和 root 密码。
|
||||
|
||||
## 4. 恢复数据库
|
||||
|
||||
### 4.1 传备包 + 建库授权导入
|
||||
```bash
|
||||
# 本机推备包
|
||||
/c/Windows/System32/OpenSSH/scp.exe -i 'C:\Users\30997\.ssh\alibb_deploy' -r 'D:\alibb-server\backups' kt@219.157.255.213:~/
|
||||
```
|
||||
```bash
|
||||
# 服务器上(替换 <MYSQL容器名> 和 <root密码>)
|
||||
M=<MYSQL容器名>; R=<root密码>
|
||||
sudo docker exec -i $M mysql -uroot -p"$R" <<'SQL'
|
||||
CREATE DATABASE alibaba CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
|
||||
CREATE USER 'alibaba'@'%' IDENTIFIED BY 'wecbni5zRRnZrMMh';
|
||||
GRANT ALL PRIVILEGES ON `alibaba`.* TO 'alibaba'@'%' WITH GRANT OPTION;
|
||||
FLUSH PRIVILEGES;
|
||||
SQL
|
||||
sudo docker exec -i $M mysql -uroot -p"$R" alibaba < ~/backups/alibaba-db.sql
|
||||
```
|
||||
> 注:mysqldump 不含用户授权,以上 CREATE USER/GRANT 来自重装前实机导出(RESTORE-NOTES.md)。
|
||||
|
||||
### 4.2 验证行数(必须与下表一致,否则停)
|
||||
```bash
|
||||
sudo docker exec -i $M mysql -ualibaba -pwecbni5zRRnZrMMh alibaba -e \
|
||||
"SELECT (SELECT COUNT(*) FROM inquiries) inquiries,(SELECT COUNT(*) FROM customers) customers,(SELECT COUNT(*) FROM chat_messages) chat_messages;"
|
||||
```
|
||||
期望:**inquiries 13570 / customers 14794 / chat_messages 61872**(若重装前重导过,以最终 dump 为准)。
|
||||
|
||||
## 5. 恢复后端
|
||||
|
||||
```bash
|
||||
# 解包代码(~/alibb-server,compose 已是生产值,严禁再跑 sed 替换 dev 值)
|
||||
tar xzf ~/backups/alibb-server-code.tgz -C ~
|
||||
|
||||
# ★ 关键一步:DATABASE_URL 里的旧容器名替换为新容器名
|
||||
cd ~/alibb-server/backend
|
||||
M=<MYSQL容器名>
|
||||
sed -i "s/1Panel-mysql-sLiG/$M/g" docker-compose.yml
|
||||
grep DATABASE_URL docker-compose.yml # 人工确认已指向新容器名
|
||||
|
||||
# build + 起(首次 migrate 会显示全部 already applied)
|
||||
sudo docker compose build backend && sudo docker compose up -d
|
||||
sudo docker compose logs backend | tail -20
|
||||
```
|
||||
|
||||
- compose 声明 `1panel-network` 为 external——1Panel 装好后已存在,无需改。
|
||||
- 密钥(JWT_SECRET/INGEST_TOKEN/ADMIN_PASSWORD/ADMIN_USERNAME)已内嵌在 compose 里,保持不变 → **扩展 token、登录账号全部沿用**。
|
||||
|
||||
## 6. 站点 + 前端
|
||||
|
||||
1. 1Panel → 网站 → 创建网站:名称 `alibb`,域名 `219.157.255.213`,端口 `23010`,根目录用默认站点 index 路径。
|
||||
2. 站点配置替换为备份内容(面板「配置文件」里改,或直接编辑 conf 后在面板重载 OpenResty):
|
||||
```bash
|
||||
sudo cp ~/backups/alibb-site.conf /opt/1panel/apps/openresty/openresty/conf/conf.d/alibb.conf
|
||||
# 面板 → OpenResty → 重载
|
||||
```
|
||||
> conf 要点:`listen 23010`、root 指向站点 index、`/api` → `http://backend:8080`、`try_files … /index.html`、`client_max_body_size 50m`。若 1Panel 生成的路径/结构与 conf 里写的不一致,以面板生成的 server 块为骨架,把上面四项并进去。
|
||||
3. 前端文件(二选一):
|
||||
```bash
|
||||
# 方案 A(快,直接用备包):解包即完整 dist
|
||||
sudo tar xzf ~/backups/alibb-frontend-dist.tgz -C /opt/1panel/apps/openresty/openresty/www/sites/alibb/
|
||||
sudo chmod -R a+rX /opt/1panel/apps/openresty/openresty/www/sites/alibb/index
|
||||
# 方案 B(干净,代码重 build):同 7 月部署文档 Step 4(docker node:22-alpine npm ci && npm run build 后复制 dist)
|
||||
```
|
||||
4. 云安全组放行 **TCP 23010** 入方向。
|
||||
|
||||
## 7. 端到端验证(全部通过才算恢复完成)
|
||||
|
||||
```bash
|
||||
curl http://219.157.255.213:23010/ # 200,返回 index.html
|
||||
curl http://219.157.255.213:23010/inquiries # 200(SPA fallback)
|
||||
curl http://219.157.255.213:23010/api/health # {"ok":true}
|
||||
```
|
||||
- [ ] 浏览器开 http://219.157.255.213:23010 → admin / ADMIN_PASSWORD(compose 里的原值)登录成功
|
||||
- [ ] 询盘/客户/聊天页数据齐全,条数与 §4.2 一致
|
||||
- [ ] Chrome 扩展 popup:**配置不变**,点「测试连接」通过 → 恢复日常抓取
|
||||
|
||||
## 8. 若公网 IP 变了(默认不变,仅备用)
|
||||
|
||||
需要同步改 3 处:
|
||||
1. 扩展 popup 的 serverUrl → `http://<新IP>:23010`
|
||||
2. 站点 conf 的 `server_name` → 新 IP
|
||||
3. 云安全组规则跟随新 IP/网卡
|
||||
|
||||
## 9. 收尾
|
||||
|
||||
- [ ] 把 `~/backups/alibb-secrets.txt` 复制回 `~/alibb-secrets.txt`(惯例位置),`chmod 600`
|
||||
- [ ] 服务器上留一份 `~/alibb-backup-20260825/`(把 ~/backups 里文件拷过去),本机 `D:\alibb-server\backups\` 永久保留
|
||||
- [ ] 更新 [2026-07-15-deploy-test-env.md](2026-07-15-deploy-test-env.md) 里的 MySQL 容器名/端口等(或注明以本 runbook 为准)
|
||||
|
||||
## 10. 故障速查
|
||||
|
||||
| 症状 | 原因 → 处理 |
|
||||
|---|---|
|
||||
| `/api` 502 | ① backend 没起来:`docker compose logs backend`;② OpenResty 与 backend 不在同网络/别名丢失:确认 compose networks 段 `1panel-network: external: true` 且容器别名含 `backend`;③ OpenResty 容器未加入 `1panel-network` |
|
||||
| 后端起不来,连接拒绝 | DATABASE_URL 容器名没替换对(§5),`grep DATABASE_URL` 核对 |
|
||||
| 登录 401 | ADMIN_USERNAME/ADMIN_PASSWORD 与旧值不一致——用备包 compose,别用本地 dev compose 覆盖 |
|
||||
| 扩展测试连接失败 | INGEST_TOKEN 变了(同上)或 23010 未放行 |
|
||||
| MySQL 8.0 下用户连不上 | `ALTER USER 'alibaba'@'%' IDENTIFIED WITH mysql_native_password BY 'wecbni5zRRnZrMMh';` |
|
||||
| migrate 报表不存在 | dump 导入的库不对/没选 alibaba 库,重做 §4 |
|
||||
|
||||
## 11. 实际恢复记录(2026-08-27,已完成 ✅)
|
||||
|
||||
新环境与原方案的差异(下次重装直接按这个来):
|
||||
|
||||
1. **MySQL 8.4.11**(容器 `1Panel-mysql-YL0F`),非 5.7 → dump 直接导入零问题;`alibaba@%` 用户保持默认 caching_sha2_password,Prisma 连接正常,无需切 native 插件
|
||||
2. **OpenResty 1.31 是 host 网络模式** → 容器名 DNS 不可用,`/api` 反代**必须** `proxy_pass http://127.0.0.1:8090`(backend 的宿主映射),不能用 `http://backend:8080`
|
||||
3. **1Panel 新路径 `/opt/1panel/www`**(挂载到容器 `/www`,与旧 conf 完全兼容):站点在 `www/sites/alibb/`,conf 在 `www/conf.d/alibb.conf`
|
||||
4. 站点目录//conf 为手工创建,**1Panel 面板里不显示该站点**(如需面板管理,后续在面板建同名站再合并 conf)
|
||||
5. 精确行数(COUNT(*)):inquiries 14817 / customers 15402 / chat_messages 68268(高于备份时 information_schema 估算值,属正常)
|
||||
6. 验证结果:外网 `http://219.157.255.213:23010` index 200 / SPA 200 / api/health `{"ok":true}`,migrate 无缺项,安全组 23010 已放行
|
||||
@@ -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