This commit is contained in:
浅念
2026-08-27 16:12:37 +08:00
commit 7f4f3be8d8
35 changed files with 8388 additions and 0 deletions
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 已放行