0. 测试工具面板 Auto-Pilot 标签页
这些按钮/开关都在同一个 Auto-Pilot 标签页里,但分布在两个不同的子面板——之前把它们混在一张表里描述得不够准确,这里订正一下,并说明每一个到底调用了哪个后端接口。
| 按钮 / 开关 | 所在位置 | 调用的接口 | 实际做什么 |
|---|---|---|---|
| Run First Email Now | 「Email Outreach Autopilot」面板 → Auto Send New Emails 那一行 | POST /api/autopilot/run-now | 立即给测试客户发首封邮件,跳过每日上限/工作时段/节假日检查。注意:只有当 Auto Send New Emails 开关是 OFF 时才可点——开关 ON 时它会变灰禁用(不是消失,是变成灰色不可点,配文字提示 "Disabled while auto-send is ON"),很容易被忽略过去。 |
| Set Test Client | 「Behaviour Triggers / Re-engagement」面板 | POST /api/autopilot/test-client | 指定 test_lead_id / test_client_id,下面几个控件都只作用于这一个客户。 |
| Test Follow-up: ON | 同上 | 每小时 cron,受 test_followup_enabled 控制 | 开启后每小时自动对测试客户跑一遍 generateFollowupQueue + autopilotFollowUp,无视节假日/时段限制——这就是能把"14天"压缩成"1小时"的机制,见下方"准备工作"第3步。这个开关只对选中的测试客户生效,跟下面的真实开关完全独立,见下方"规则4"。 |
| Run Test Follow-up Now | 同上 | POST /api/autopilot/run-test-followup-now | 手动触发一次:先轮询真实收件箱(checkInboxReplies),再跑 generateFollowupQueue。不会跑 autopilotFollowUp——那部分只能靠上面的每小时 cron(Test Follow-up 开着)或真的等到下一个整点。 |
| Preview Send Queue | 同上 | GET /api/autopilot/preview-queue | 展示两部分:「First Emails」(可批量发)和「Follow-ups」(cold_restart / followup1 / followup2 / warm / high_intent / reengagement,逐条发送,发送前都会重新校验一次)。 |
c:\Stella(localhost)上测,Brevo 推不进来,除非开了内网穿透(如 ngrok)并把 Brevo 后台的 webhook 地址临时改过去;如果打算直接在生产环境(OnlineStella)上测,要注意这些改动目前只在本地 c:\Stella 里,还没有部署到生产,生产上测到的还是旧逻辑。0.5 贯穿全局的规则 先懂这几条,后面才讲得通
下面这几条不属于任何一个具体场景,而是所有场景背后共用的底层逻辑。之所以单独拎出来,是因为它们容易被忘记,一忘就会看着某个场景的结果觉得"不对啊,怎么跟想的不一样"。
规则1 · 客户只要回复,无论当前处于哪个阶段,都会立刻被"捞出来"重新分类——但不是发新的第一封邮件
这条是最容易被忽略、也最重要的一条。
不管客户当前是刚发完 first 还在等打开、是在 followup1/warm/high_intent 排队中、还是已经进了 cooling 在倒数第几轮的冷却期——只要他真的回复了一封邮件,系统检查收件箱时(checkInboxReplies)就会立刻按回复内容重新判定这个客户该去哪,把它从原本正在走的自动化路径里"捞出来",不会等原来的流程走完。具体去哪,看回复内容属于哪一类(对应 B组):
| 回复类型 | 不管之前是什么状态,回复后会变成 |
|---|---|
| 感兴趣 / 提问 | 直接变 active,即使当时正处在 cooling 里也一样会被拉出来 |
| 软拒绝 | 变/刷新成 cooling,冷却计时器从这一刻重新算 |
| 硬拒绝 | 如果这是最后一个还没被拒绝的联系人 → 变 not_interested;即使当时是 cooling 也会被直接改掉 |
first 类型邮件的情况,是规则3说的"爬虫在这家公司发现了不同的联系人",这是两件完全不同的事,不要混在一起。规则2 · 为什么 reengagement 邮件发出去后,客户状态是变回 contacted 而不是留在 cooling
cooling 这个状态代表的含义是"我们现在什么都不做,纯粹在倒数等待"。但 reengagement 邮件一旦真的发出去了,客户收件箱里就又躺着一封等待回复的邮件了——这和当初发完第一封邮件后的处境,本质上是同一回事:都是"发了一封东西,正等对方反应"。所以系统直接复用了 contacted 这个早就存在的状态来表示"正在等待",而不是另外发明一个"cooling 期间发了 reengagement 后的特殊等待状态"。
这样做的好处是:判断"等了多久还没反应、该不该重新打入冷宫"这件事,可以直接复用原本就用来监控 contacted 客户的那套逻辑(也就是 C2 场景里说的"cycle 2+ 客户"检查),不用为 reengagement 这一种情况单独再写一套监控代码。
contacted 是同一个概念,只是 cycle 字段记着这已经是第几轮了。规则3 · 唯一能让客户"重新收到一封真正意义上的第一封邮件"的情况
只有一种情况:爬虫在一家已经是 cooling / not_interested / bounced 的公司,抓到了一个和档案里不一样的联系邮箱(详见 D组)。这时系统才会把这家公司当成一个全新的机会,重新走一遍从 first 开始的完整流程,并且把这个新联系人之前累积的所有跟进计时清零(靠 contact_reset_at 这个字段实现,具体见 D1)。除此之外——不管是客户自己回复、还是冷却期满自动发 reengagement——都不会触发"重发第一封邮件"这个动作,最多只是把状态改变或者发一封 reengagement/跟进邮件。
规则4 · 测试必须和真实环境判断逻辑完全一致,但绝不能碰到除测试客户以外的任何真实客户
两条同等重要的原则,2026-07-30 明确定下的,任何测试相关的代码改动都要同时满足这两条。
原则A:判断逻辑必须和正式环境一模一样。候选人筛选条件(是否打开过、是否过了天数、cold_restart 互斥、cycle<=1、第2轮+续冷却)测试客户和真实客户走的是同一段代码、同一条 SQL,不允许测试模式用一套简化过的逻辑——不然测出来的结果没有意义。
原则B:测试执行范围必须严格限定在你选中的这一个测试客户身上,绝不能波及任何其他真实客户。这条比听起来更容易被违反——因为 autopilotFollowUp() 这类函数在正式环境的每小时 cron 里,本来就要被调用一次处理所有真实客户;如果测试模式的"允许发送"这个开关也去读 Auto Followup 1/2 这种全局设置,那为了测试而打开这个开关,就会同时让"处理所有真实客户"的那次调用也真的开始自动发信——所以 Test Follow-up 这个开关必须是独立于真实开关的,只作用于测试客户,Auto Followup 1/2 和 Auto Send New Emails 整个测试期间都要保持关闭。
generateFollowupQueue() 里生成 reengagement 邮件那部分,之前完全没按测试客户限定范围,点 Run Test Follow-up Now 或 Test Follow-up 每小时自动跑,都会把全部真实客户里"cooling 且今天到期"的记录一并处理掉。已修复并用真实数据验证:造一个真实客户和一个测试客户都到期,只跑测试客户的检查,确认真实客户完全没被碰。autoColdRestart / autoWarm / autoHighIntent / autoReengagement 这4个门禁条件改成 !isTestMode && 真实开关——测试模式下永远视为关闭(一定进队列,等人工点 Send),只有非测试的正式调用才会去读真实开关,决定真实客户要不要跳过队列直接自动发。发送本身用的都是同一个 sendFollowupQueueItem(),内容生成也是同一段代码,所以这个改动只影响"什么时候自动点 Send",不影响邮件内容或发送逻辑本身——测试通过等于正式打开开关后行为一样。1. 准备工作 — 每轮测试开始前做一遍
- 在 Opportunities 里找一条(或新建一条)线索,把它的
contact_email改成你自己的私人邮箱。 - 点 Set Test Client,选中这条线索。
- 确认 Auto Followup 1、Auto Followup 2、Auto Send New Emails、Auto Cold Restart、Auto Warm、Auto High Intent、Auto Reengagement 这7个真实开关都是 OFF,并且整个测试期间都保持关闭——它们跟 Test Follow-up 是两套独立机制(见"规则4"),测试不需要打开它们(测试客户触发这几类邮件时永远走 Preview Send Queue 人工审核,跟这些开关状态无关),等所有场景都测完了才手动打开用于正式环境。
- 在 Settings → Autopilot 里,把所有"天数"类设置都改成
0:Followup 1、Followup 2、Cold Restart、Warm send、High Intent、Cooldown after rejection(Cycle 1),以及新加的 Cycle 2 / Cycle 3 / Cycle 4+ cooldown(Warm trigger opens 和 Daily Send Limit 不用改,保持默认 2 和 20 即可)。改成0意味着"只要过了任意一点时间就算达标",下一次 cron 或手动触发就会立刻生效,不用真等好几天。 - 把 Test Follow-up 打开(ON)。
- 点 Run First Email Now。确认邮件真的到了你的私人邮箱。
first 邮件,客户状态是 contacted)。测完一个独立场景后,重新走一遍第5步(或换一条新线索)再进入下一个场景,避免互相干扰。1.5 标准复位 SQL 每个场景卡片末尾都会引用这一段
所有场景都是在同一个测试客户身上反复测的,所以复位逻辑是通用的:把 <test_client_id> 换成你的测试客户 id(Preview Send Queue 或客户详情页能看到),跑完下面这5条,就能回到"刚发完 first、状态 contacted、cycle 1"的干净起点,可以立刻测下一个场景。每个场景卡片下面的"清理"提示都是指这一段——除非那个场景另外说明了要多做点什么(比如 B5、F2/F3 会产生测试客户以外的额外记录,D1 不涉及测试客户)。
DELETE FROM followup_queue WHERE client_id = <test_client_id>;
DELETE FROM email_log WHERE client_id = <test_client_id> AND type != 'first';
DELETE FROM client_activities WHERE client_id = <test_client_id>;
UPDATE email_log SET status = 'sent', open_count = 0 WHERE client_id = <test_client_id> AND type = 'first';
UPDATE clients SET status = 'contacted', cycle = 1, rejection_type = NULL, reengagement_date = NULL,
question_received_at = NULL, contact_reset_at = NULL WHERE id = <test_client_id>;
A组:参与度路径 — 客户没回复,只看打开/点击行为。对应(并订正了)App 里 Follow-up Sequence 弹窗的内容。
A1 · 完全没打开 逻辑本身没变,但现在会按 contact_reset_at 分段计算
Cold Restart(进队列)→ 发送 → Followup 2(自动发)→ 进入 Cooling。
- 不要打开第一封邮件。等下一次每小时的测试 cron(或重启一次服务器,重启会立即跑一次 autopilot——如果这么做,注意这会同时触发一次真实爬虫抓取)。
- 打开 Preview Send Queue → Follow-ups 里应该出现一条
cold_restart,主题固定是A fresh start — {company},正文来自 Settings → Email Templates 里保存的 Cold Restart 模板(2026-07-30 起改为完全按模板生成,不再是 AI 现场写的内容——如果正文跟你在模板里编辑保存的不一样,就是 bug)。 - 点 Send。确认它带着模板里的内容真的进了收件箱。
- 仍然不要打开它。等下一次 Test Follow-up 的整点触发。
followup_2(最后一封,以回复形式串联主题)。客户状态变成 cooling,rejection_type = 'no_response',reengagement_date = 今天 + Cooldown 天数,cycle 加1。SELECT status, rejection_type, reengagement_date, cycle FROM clients WHERE id = <test_client_id>A2 · 打开一次
Followup 1(进队列)→ 发送 → Followup 2(进队列)→ 进入 Cooling。
- 真的打开第一封邮件一次(在私人邮箱里点开它,让 Brevo 的 open 事件真实触发)。
- 确认 Brevo 后台/邮件的追踪像素被加载后,点 Run Test Follow-up Now。Follow-ups 里应该出现一条
followup1。 - 从 Preview Send Queue 里发送它。
- 再次 Run Test Follow-up Now——应该出现
followup2(这一条同样是进队列,不是自动发,因为客户表现出过参与)。 - 发送它。
A3 · 打开2次以上(Warm)
Warm 邮件(进队列,按模板生成)→ 发送 → Followup 2(进队列)→ 进入 Cooling。
- 真的把第一封邮件打开两次(阈值是 "Warm trigger after (opens)" 设置项,默认2次)。
- Run Test Follow-up Now → 应该出现一条
warm,主题固定是Still relevant — {company},正文来自 Settings → Email Templates 里保存的 Warm 模板(2026-07-30 起改为完全按模板生成,不再是 AI 现场写的内容)。 - 发送它,再 Run Test Follow-up Now 一次 → 出现
followup2。
A4 · 点击了链接(High Intent)
High Intent 邮件(进队列)→ 发送 → Followup 2(进队列)→ 进入 Cooling。点击优先级高于打开——不会退回到 warm 路径。
- 真的点击第一封邮件里的链接。
- Run Test Follow-up Now → 应该出现一条
high_intent,主题固定是Quick chat — {company},正文来自 Settings → Email Templates 里保存的 High Intent 模板(2026-07-30 起改为完全按模板生成,不再是 AI 现场写的内容)。 - 发送它,再跑一次 → 出现
followup2。
A5 · 自动回复(度假自动回复)
确认系统不会把度假自动回复误判成真实回复。
- 把测试邮箱设成 out-of-office 自动回复,或者直接手动回一封标题像 "Automatic reply: Out of Office" 的邮件。
- Run Test Follow-up Now(会触发
checkInboxReplies)。
client_activities,类型是 auto_reply。客户状态和 email_log.status 都不受影响——这个客户会继续走 A1(完全没打开)那条路径,就像什么都没发生过一样。SELECT * FROM client_activities WHERE client_id=<id> ORDER BY created_at DESC LIMIT 3B组:回复分类路径 这周新实现 — 这些状态流转在这之前压根不存在,是本轮测试的重点。下面每个场景默认客户处在"刚发完 first,状态 contacted"这个最简单的起点;但按"规则1",这套分类逻辑在客户处于 cooling、处在第几轮都一样适用——如果时间允许,建议挑一两个场景故意先把客户弄进 cooling 再回复,确认结果和"从 contacted 直接回复"一致。
B1 · 感兴趣
- 回复第一封邮件:"这个看起来不错,想再多了解一下,能约个电话吗?"
- Run Test Follow-up Now。
active。跟进序列就此停止,不会再有任何自动跟进邮件排队。active。可以单独试一遍这句话,确认走的是这条路径。B2 · 提问
- 回复:"这个多少钱,需要签合同吗?"
- Run Test Follow-up Now。
active,question_received_at 被设置(Clients 标签页里会显示成一个待处理标记)。不会有 AI 自动起草回复——这类留给你自己手动回。B3 · 软拒绝 新行为
判断标准是"有没有明确邀请以后再联系"——有,走 B1(active);没有,走这里(cooling)。见 B1 的补充说明。
- 回复:"我们暂时不招人,谢谢。"(注意:不是"过几个月再联系"这种带邀请性质的说法,那种现在走 B1)
- Run Test Follow-up Now。
cooling,rejection_type='soft',reengagement_date = 今天 + Cooldown 天数。以前这类回复只会把这一个邮箱标记为不可再发,但客户整体状态完全不变——确认这次不再是这样。B4 · 硬拒绝,只有一个联系人 新行为
- 确认测试客户名下目前只登记了一个联系邮箱。
- 回复:"请把我们移除,我们不感兴趣,不要再联系了。"
- Run Test Follow-up Now。
rejected。客户 → not_interested,rejection_type='hard'。B5 · 硬拒绝,但还有其他联系人有效 新行为,也是这次改动真正的设计初衷
一个人拒绝了,不代表这家公司所有人都拒绝了——不应该因此关掉整个公司。
- 给测试客户加一个也能收到邮件的第二联系邮箱,比如
UPDATE clients SET contact_email = CONCAT(contact_email, ', your+alt@address.com') WHERE id=<id>,并给这个邮箱补一条email_log(type='first', status='sent'),让它看起来是一个真实、未被拦截的联系人。 - 用主邮箱回复和 B4 一样的硬拒绝措辞。
- Run Test Follow-up Now。
rejected。但客户状态仍然是 contacted——不会变成 not_interested,因为第二个邮箱还没被拦截。去活动日志里应该能看到"其他联系人仍然有效"这类记录。not_interested——这才是"所有联系人都拒绝了"这个判断真正生效的时候。UPDATE clients SET contact_email = SUBSTRING_INDEX(contact_email, ',', 1) WHERE id = <test_client_id>;再执行一遍"1.5 标准复位 SQL"清掉两个邮箱各自产生的 email_log/活动记录。
C组:冷却与再触达 — 客户没动静之后会发生什么。
C1 · 再触达按期触发
- 用上面任意场景(A1 或 B3 最快)把客户先弄进
cooling。 - 直接把等待期拨到今天:
UPDATE clients SET reengagement_date = CURDATE() WHERE id=<id>。 - Run Test Follow-up Now → 打开 Preview Send Queue,Follow-ups 里应该出现一条
reengagement,正文来自 Settings → Email Templates 里保存的 Reengagement 模板。 - 点 Send。
contacted(为什么是 contacted 不是继续留在 cooling,见上面"规则2"——简单说就是:邮件发出去了,现在是"等对方回音"的状态,跟第一次发 first 邮件后的处境是同一回事,系统直接复用了同一个状态)。C2 · 冷却期随轮次递增
只有第1轮会走完整的邮件序列,第2轮起每轮只发一封 reengagement 邮件。2026-07-30 起 90/180/365 这三个数字不再是写死的,Settings → Autopilot → Re-engagement 面板里新增了 Cycle 2 / Cycle 3 / Cycle 4+ cooldown 三个输入框,测试期间同样可以改成 0。
- 走 A1 完成第1轮,进入 cooling。
- 用 C1 的办法把
reengagement_date拨到今天,触发 reengagement 邮件;发送后客户变回contacted,cycle仍是2。 - 不回复,等 Followup 2 天数那么久(测试期已改成0),确认又自动回到 cooling。
- 重复第2-3步两次,检查
cycle与冷却天数是否照上表递增。
contacted 状态、cycle>=2 时,卡片上的状态标签会显示成"🔄 Re-engaged ×N"而不是普通的"Contacted",鼠标悬停能看到具体是第几轮——这是 2026-07-30 加的一个 UI 提示,方便一眼看出这不是全新客户。这里的 contacted 是什么意思——容易看错的一点
上面第2步会看到 clients.status 变成了 contacted,cycle 却已经是2、3、4——第一眼很容易误会成"系统把它当成了一个刚认识的全新客户"。不是这样的:clients.status 这个字段总共就 new / contacted / active / cooling / not_interested / bounced / competitor 这几个取值,没有专门给"reengagement 发出后等回应"单独造一个新状态,而是直接复用了 contacted 这个值——它在这里的含义是"当前有一封 outreach 邮件发出去了、正等对方反应",跟真正第一次认识这家公司时的 contacted 是同一个字段值、但不是同一件事,要看 cycle 字段才知道这其实是第几轮了。这样设计是为了能直接复用原本监控"contacted 客户等多久没反应该怎么办"的那套代码,不用为 reengagement 专门再写一套。
如果在等待期间(第2轮或以后)客户突然回复了呢
不管客户是在第1轮还没打开、还是已经进了第2、3、4轮的 cooling/等待期,只要真的回复了,checkInboxReplies 一检查到就会立刻按回复内容重新分类,把它从"按部就班等冷却期满"这套自动化节奏里拉出来——感兴趣/提问 → 直接变 active;软拒绝 → 刷新回 cooling,冷却计时器从这一刻重算;硬拒绝 → 如果这是最后一个还没被拒绝的联系人,变 not_interested。但注意:这几种结果都只是"改状态",都不会让客户重新收到一封全新的"第一封邮件"。系统里唯一会让客户真正收到一封全新 first 邮件的情况,只有 D组说的那种——爬虫在这家公司发现了一个和档案里不一样的联系人,那才是真正"重新开始",跟这里说的"回复被重新分类"是两件完全不同的事,别搞混。
D组:换联系人重置 全新子系统 — 爬虫在一家已归档的公司发现了不同的人,这家公司会获得一次真正意义上的全新周期。
D1 · 爬虫在已归档公司发现新联系人 已用自动化数据库测试验证,实际很难在真实操作里复现
这套逻辑只会从真实爬虫内部触发(Seek/Indeed/TradeMe/SJS/Himalayas 解析到一条真实招聘帖时)——没有任何 UI 操作能直接激活它,没法像上面的场景一样手动驱动。
clients.contact_reset_at 是否被更新,以及是否真的发出了一封全新的 first 邮件。E组:发送前二次校验 — 队列里的每一项在真正发送前都会被重新检查一遍。
E1 · 邮件在排队期间"过期"了
- 先让某条
followup1(或任意类型)静静地躺在队列里(参考 A2 第2步)。 - 在点发送之前,用别的方式让客户状态先变化——比如随便回复一句什么(会经由 B1–B4 改变状态),或者模拟一次退信:
UPDATE email_log SET status='bounced' WHERE client_id=<id> ORDER BY sent_at DESC LIMIT 1。 - 现在再去点这条仍显示"待发送"的队列项的 Send。
skipped,而不是 sent。不会有邮件真的被发出去。F组:三条发送路径的归档拦截 — 同一条规则,三种不同的落实方式:F1 全自动无需提示;F2 有人工在场,多给一句"是不是同一个人"的提示;F3 现在和 F1 用同一套判断逻辑。
F1–F3 · not_interested / bounced 公司防止重新联系 F2/F3 本轮升级
| 路径 | 接口 | 同一联系人再出现 | 换了联系人再出现 |
|---|---|---|---|
| F1 — 全自动主流程(autopilotAutoSend) | /api/autopilot/run-now、每小时 cron | 拦截 — 不会生成/放行这条线索 | 放行 — 经由 isCompanyArchived 判定为全新周期 |
| F2 — 手动单条发送 | POST /api/opportunities/leads/:id/send | 弹窗警告 — "已标记为…(同一个联系人),仍要发送吗?",人工确认 | 弹窗警告 — 提示会明确写"看起来换了个联系人,之前是 X",人工确认 |
| F3 — 批量发送 | POST /api/autopilot/send-queue(Preview Send Queue → Send All First Emails) | 拦截 — 静默跳过,不会发 | 放行 — 现在和 F1 用同一个 isCompanyArchived,换了联系人会自动发出,并触发 contact_reset_at 重置 |
F1/F3 可以复用 D1 的验证结论(本质是同一个判断函数)。F2 单独测:把测试客户手动标成 not_interested,分别用同一个邮箱和一个新邮箱去发,确认弹窗文案里"同一个联系人" vs "换了联系人,之前是…"这两句话分别在对的场景下出现。
b2b_leads 记录(用来触发发送流程),标准复位 SQL 不会清理这张表,需要额外单独处理:
DELETE FROM b2b_leads WHERE company_name = (SELECT company_name FROM clients WHERE id = <test_client_id>) AND status != 'sent';如果测试过程中不小心真的发成功了(status='sent'),把上面
!= 'sent' 去掉,连同已发送的那条一起清掉;再跑一遍"1.5 标准复位 SQL"把测试客户自身的状态改回 contacted。G组:Webhook message-id 精确归因 — 打开/点击/退信要归到真正触发它的那封邮件上,而不是"不管哪个事件,都算给最近发的那封"。
G1 · 两封邮件同时在外,一个事件
这是唯一真正需要靠真实 Brevo 才能测到位的场景——本地环境无法收到 webhook,交给真实环境验证。
- 给同一个客户先后发出
first邮件和一封followup1——这样email_log里会有两条记录,各自带着不同的brevo_message_id。 - 只打开较早那封(
first),先不要碰后面那封followup1。 - 去数据库确认结果。
first 那条记录的 status/open_count 会更新,更晚发出的 followup1 那条应该完全不受影响。修复之前,这类事件会被无脑记到"最近发出的那封"上,不管实际打开的是哪一封。SELECT type, status, open_count, brevo_message_id FROM email_log WHERE client_id=<id> ORDER BY sent_at ASC为什么记错行不只是"账算错了",而是会导致错误决策
generateFollowupQueue 每次决定"这个客户接下来该走哪条路",看的只是 first 这一封邮件自己的 open_count/status,不会去看客户名下其他邮件。所以打开/点击/退信这些事件如果被记到了错误的那一行,不是"记录不准但无所谓",而是直接改变了系统接下来要走的分支:
| 被误记的事件 | 本来应该发生什么 | 误记之后实际会发生什么 |
|---|---|---|
| 10天前的 first 被重新打开,却记到了昨天的 followup1 上 | 系统看到 first 打开过一次,走"打开一次"那条路(该发 followup1 或该发 followup2) | first 的 open_count 还是0,系统仍然认为"完全没反应",继续把客户当冷客户处理,可能提前把一个其实有兴趣的客户送进 cold_restart 甚至 cooling |
| 客户点了链接,却被记到了另一封邮件上 | 触发 high_intent,这是优先级最高的分支,会立刻追一封针对性更强的邮件 | 真正该收到 high_intent 追问的高意向客户,系统完全没察觉,继续走普通节奏,白白错过一个热信号 |
| first 真的退信了,退信状态却被记到后面一封没退信的邮件上 | first 那个地址被标记为死地址,以后不会再往这个地址发 | 真正的死地址一直显示"sent"(看起来正常),系统不知道它已经收不到,可能继续用;而一封其实寄达了、根本没问题的邮件却被误标成 bounced,之后可能被错误地当成坏地址处理 |
G2 · 回信记录本身缺失客户关联时的兜底匹配 2026-08-01 新增修复
验证即使某封发件记录先天没有关联到客户(历史遗留问题——早期 autopilotAutoSend 不会主动建客户档案,详见 H2 背景说明第③条),客户真的回信时依然能被正确识别、状态正常更新,不会因为这条记录本身有缺陷就把回信悄悄丢掉。
- 走一遍标准准备工作:Set Test Client → Run First Email Now,确认收到邮件。
- 手动跑一条 SQL,故意把这封邮件在数据库里的客户关联字段清空,模拟"当时建档没跟上"这个历史问题:
UPDATE email_log SET client_id = NULL WHERE client_id = <test_client_id> AND type = 'first';
- 正常回复这封邮件,比如跟 B1 一样写"这个看起来不错,想再多了解一下"。
- 点 Run Test Follow-up Now。
active——证明就算历史数据有缺陷,回信也不会因此丢失,系统还是能把它对应回正确的客户身上(靠新加的第三层匹配:按回信邮箱直接查 clients.contact_email)。Watchlist · 手工审核标记 2026-08-01 新增场景 — 被标记 Watchlist 的线索/客户,不管真实的 Auto Cold Restart / Auto Warm / Auto High Intent / Auto Reengagement 开关是开是关,都必须强制停在 Preview Send Queue 等人工审核,不能被自动发出去。这组场景不走 A-G 组那套"Set Test Client"机制——那套机制本来就是设计成绕开所有拦截条件,测不出 Watchlist 有没有生效,必须用真实的手工发送流程来测。
Watchlist · 标记后自动开关必须失效
验证即使真实的自动发送开关是打开的,被标记 Watchlist 的这一个客户依然会被强制摁住,不会被自动发出去。
- 准备工作跟平时一样,把 Settings → Autopilot 里所有"天数"类设置改成
0。 - 在 B2B Leads 里选一条线索,把它的联系邮箱改成你自己的私人邮箱。
- 点这条线索行前面的 Watchlist 复选框(🔖 图标),确认它被标记进了 Watchlist。
- 不要用 Set Test Client + Run First Email Now——直接在这条线索上点手工的 Send Email 按钮发出去。Watchlist 要测的是"真实客户的真实自动化流程会不会被拦住",用测试客户机制会直接绕开所有拦截判断,测不出东西。
- 这一次可以临时把 Auto Cold Restart / Auto Warm / Auto High Intent 这几个真实开关打开(平时测试要求它们必须关闭,这是唯一的例外——因为整个测试范围就锁定在这一条打了 Watchlist 标记的线索上,不会影响到其他真实客户)。
- 等系统状态从 New 变成
cold_restart或者followup1(取决于有没有打开过邮件,两者不会同时出现)。 - 去 Preview Send Queue 确认这封邮件在等着,没有因为开关是打开的就自动发出去。
- 手动点发送,在私人邮箱里打开/点击这封邮件,等 Brevo 把打开/点击事件推回来,系统状态升级成
warm/high_intent。 - 再去 Preview Send Queue 确认,同样在等着,依然没有自动发出去。
manual_review = 1 的线索(autopilotAutoSend 候选人查询),只要这条查询条件没被改动,就不需要单独验证。H组:部署交接 —— 测试库到正式接管 — 2026-07-30 定下的方案:新版 Stella 先连一份从生产库拷出来的独立副本做全场景测试,跟 OnlineStella 正式环境完全隔离;全部测完没问题,再让新版 Stella 正式接管、连上生产库本体,同时彻底停掉 OnlineStella。
H0 · 测试环境怎么搭(开始测试前)
- 从生产库整体拷贝一份出来(比如
mysqldump导出再导入一个新库名),新版 Stella 的.env连接这份独立副本,不要连生产库本体。 - 新版 Stella 首次连上这份副本启动时,会自动把
contact_reset_at、rejection_type这两个新列加上去(纯新增,不影响其他数据)。 - 确认 OnlineStella 那边的部署完全没有改动——它应该继续正常连着生产库本体、正常跑它自己的自动化,你的日常业务不受这次测试影响。
H1 · 正式接管的时序 全部场景测试通过后再做
这一步不是改几个设置那么简单,是两个独立进程的交接,顺序错了会有真实风险,请按顺序做,不要图快跳步骤。
- 接管前,对生产库做一次快照备份(纯粹是保险,用不上最好)。
- 先彻底停掉 OnlineStella 的进程(PM2 层面真正 stop,不是点某个页面上的开关)。这一步和下一步之间不要有任何真实自动化在跑——两边进程绝不能同时连着生产库,否则爬虫、收件箱监听、自动发送都可能撞车、重复处理。
- 把新版 Stella 的
.env数据库连接从"测试副本"改成指向生产库本体(不是 H0 拷的那份旧快照——OnlineStella 停之前一直在真实运行,数据比你测试用的那份新)。 - 启动新版 Stella 连生产库。
- 跑一次
DESCRIBE clients,确认contact_reset_at、rejection_type已经自动加上了。 - 去 Settings → Autopilot 确认所有天数类设置、Cooldown 数值都是生产真实值(14 / 14 / 7 / 3 / 7 / 60 / 90 / 180 / 365,真实 Daily Send Limit),不是测试期间在副本里改过的0——这是最容易出事的一步:如果这几个设置在生产库上被误留成0,新实例一启动,会瞬间判定所有真实客户都逾期,可能对整个客户名单猛发一轮。
- 确认 Auto Send New Emails / Auto Followup 1 / Auto Followup 2 / Auto Cold Restart / Auto Warm / Auto High Intent / Auto Reengagement / Auto Crawl Data 这几个真实开关此时都是关闭状态。
- 观察日志几分钟(
pm2 logs),确认没有报错、启动流程正常、(如果 Auto Crawl 后面会开)爬虫翻页记录正常。 - 确认反向代理/端口指向的是新进程,不是残留的旧进程。
- 一切正常后,把上面几个真实开关逐个手动打开,不要一次性全开——开一个观察一轮,再开下一个。
- 对 Auto Cold Restart / Auto Warm / Auto High Intent / Auto Reengagement 这4个新开关,打开后第一次有对应类型的邮件产生时,去 Preview Send Queue 确认它没有停在里面——应该是已经直接进了 email_log 变成已发送状态;如果还停在队列里等人工点 Send,说明开关没生效。
- 盯着接管后第一次整点 cron、第一次9点每日
generateFollowupQueue的日志,确认行为符合预期。
H2 · 正式接管后的历史数据 backfill 必须在 H1 完成之后才做
2026-08-01 排查线上数据时发现:由于两个独立的历史 bug,4 月下旬到现在这段时间,一部分本该发生的状态变化实际上从没写进 clients 表。这些 bug 本身已经在代码里修好了,但已经"漏掉"的这段历史,代码修复不会自动帮你补——需要在新版 Stella 正式接管生产库之后,单独跑一次性的数据补录。这里把来龙去脉、要做的两件事、以及每件事要注意的坑都写清楚,供接管后照着执行。
背景:为什么会漏,漏了什么
一共有三个独立的历史 bug,分别在这里发现、这里修复:
| # | 问题 | 影响 | 修复状态 |
|---|---|---|---|
| ① 收件箱配错 | 发件地址(email_sender)一直是 connect@jobvista.app,但 .env 里 IMAP_USER 一直是 support@jobvista.app —— 两个不是同一个邮箱 | 客户回复全部落在 connect@ 的收件箱里,Stella 这套"读信 → AI 判断情绪 → 更新客户状态"(checkInboxReplies / analyseReplySentiment)从来没有真正处理过一封真实客户回复。已确认 support@ 里没有任何真实客户回复,全部都在 connect@。 | 需要人工把 .env 的 IMAP_USER/IMAP_PASSWORD 切到 connect@(本文档不负责这一步,属于接管配置的一部分,见下方"前置条件") |
| ② Webhook 写库没等完成 | handleBrevoWebhook 里更新 clients 表的 db.run() 全部是"发出去不等"的写法,没有 await——如果响应返回 Brevo 和进程重启/数据库抖动撞在一起,写入可能静默丢失,不会报错、不会留下任何痕迹 | 用真实生产数据核实过:email_log 表本身完整、连续,没有缺口;缺的是这些正确记录没有同步到 clients.status,导致 Not Interested / Bounced 分类里漏了一批本该在里面的客户 | ✅ 已修复 —— c:\Stella 和 c:\OnlineStella 两边的 handleBrevoWebhook 都已经把相关写入包上 await,确保真正写完才回应 Brevo |
| ③ 自动发信不建客户档案 | autopilotAutoSend(批量自动发冷邮件的主流程)原本只查询是否已有 clients 记录,查不到也不创建,全靠服务器重启时的一次性迁移脚本兜底补建 —— 但这个迁移只在重启那一刻跑,两次重启之间发出的邮件,email_log.client_id 可能一直是空的 | 如果客户的第一封邮件恰好落在"还没建档"的这段窗口期,之后不管这个客户什么时候回信,系统都按"查无此人"处理,直接丢弃,不会分类、不会更新状态 | ✅ 已修复 —— autopilotAutoSend 发信前会先确保客户档案存在;同时给 checkInboxReplies 加了第三层兜底匹配(按回信邮箱直接查 clients.contact_email),这样即使历史邮件先天缺了 client_id,回信一样能被正确接住 |
真正开始批量往真实客户发信的日期,是用 email_log 里 unsubscribed / opened / clicked / replied 这四类各自独立的最早一条记录互相印证出来的——四个信号的最早时间都集中在 2026-04-23 这一天附近,不是最初猜测的 3 月,也不是中途猜测的 6 月。下面两个任务都以这个日期为起点。
举个例子,把整件事串起来
三个虚构的客户,分别对应"任务1要处理的情况"、"任务2要处理的情况"、"什么都不用管、自己会好的情况":
| 客户 | 历史上真实发生了什么 | backfill 之前,系统以为怎样 |
|---|---|---|
| 阳光建材(联系人 Lisa) | 5/2 收到第一封开发信;5/5 打开 2 次;5/8 回信"价格有点高,暂时不考虑"——一次明确的婉拒 | 因为收件箱一直配错,回信从没被看到,系统以为"发出去了,一直没回" |
| 绿源物流 | 6/10 直接点了邮件里的退订链接 | Brevo 正确判断出这是退订,但写 clients 表那几行代码当时没等它写完就回应了,偏巧那一刻服务器重启,这次写入悄悄丢了——email_log 是对的,clients.status 没跟上 |
| 东方物流(联系人 Mark) | 5/10 收到第一封开发信;5/14 打开 2 次;之后没有任何回复,也没有退订,就是单纯看了没理会 | 系统看到的和实际发生的完全一致,没有任何缺口 |
8月,新版 Stella 正式接管,跑 backfill:任务1连上 connect@ 邮箱,翻到 Lisa 5/8 那封回信,AI 判断是婉拒,调用 markClientReplied()——阳光建材的 clients.status 被正确改成 cooling(server.js soft_rejection 分支),冷却到期日从"今天"(backfill 执行的那天)往后推 60 天,不是从 5/8 号往后推,时间会比理想情况晚一些,这个没有完美解法。任务2重放 email_log 里绿源物流那条 unsubscribed 记录,clients.status 被正确同步成 not_interested,正式退出联系名单。东方物流不需要 backfill 做任何事,他的数据从头到尾都是对的。
代码上线,autopilot 定时任务开始正常跑之后:阳光建材——因为任务1已经把她的状态改成了 cooling,generateFollowupQueue() 挑候选人时有一条硬性条件 c.status IN ('contacted','active'),cooling 不满足这条,她会被直接排除,不会因为"打开过2次"而又收到一封 warm 跟进邮件,跟她已经婉拒这件事不矛盾。东方物流——他的状态自始至终一直是 contacted,"打开2次、还没发过 warm"这个条件本来就一直是对的,generateFollowupQueue() 下一次运行时会直接命中他,正确生成一条 warm 跟进邮件——不需要为"5月的那次开信"单独写脚本去补,系统检查的是"现在符不符合条件",不是"5月当时有没有实时触发过"。
.env 的 IMAP_USER/IMAP_PASSWORD 已经从 support@ 切到 connect@(对应真实密码),并且确认这个账号真的能连上(node 里跑一次 ImapFlow 连接测试,或者看 pm2 logs 有没有 IMAP 相关报错)。正确顺序是:H1 接管完成 → 任务1 + 任务2(这两个任务之间谁先谁后都行,互不依赖,甚至可以合并成一次脚本一起跑)→ 抽查确认结果没问题 → 再打开自动发送相关的开关(Auto Send New Emails / Auto Followup 1 / Auto Followup 2,以及 Auto Cold Restart / Auto Warm / Auto High Intent / Auto Reengagement 这4个,见 H1 步骤6/9)。顺序反了的话,backfill 还没做完,客户状态还停留在旧的"contacted",这时候如果自动化已经在跑,可能会给已经婉拒/退订过的客户又发一封不该发的邮件。
server.js,类似测试阶段反复用过的那种临时路由);像平时更新代码一样,通过 cPanel 的 File Manager 把改完的 server.js 上传,再去 Setup Node.js App 那个模块里重启应用(这一步只是用来部署代码,还不会真的执行 backfill);重启之后,backfill 代码已经在跑着的 Stella 程序里了,访问一个特定的网址来实际触发它执行;确认结果没问题后,再把这段临时代码删掉、重新上传一次干净版本、再重启一次。任务1 · 回复情绪分类 backfill(连 connect@ 邮箱,2026-04-23 至今)
- 写一个一次性脚本(不是常驻接口,跑完即可删除),复用
checkInboxReplies()里逐封处理邮件的完整逻辑,唯一的改动是把"只查最近7天"(since = 7天前)这个时间参数换成2026-04-23,一次性把这段时间内的邮件全部拉下来。 - 邮件必须按
sent_at/ 收件时间正序(从最早到最新)逐封处理——如果同一个客户在这几个月里回复过不止一次(比如先感兴趣、后来又拒绝),按正序处理完,最终状态才会正确停在最新的那一条上。 - 识别到自动回复(度假自动回复等)时,必须调用现成的
detectAutoReply()判断,不要囫囵当成真实客户情绪去分类——这一步直接复用现成函数,不要自己重写一遍类似逻辑,避免细节判断不一致。 - 每一封真实回复,必须直接调用
markClientReplied()这个函数本身(而不是照着它的逻辑重新写一遍)——这样能自动继承它已经写好的几层保护:按contact_reset_at限定范围的 hard_rejection 公司归档逻辑(不会误伤"联系人已经换过一轮"的公司)、按 message-id 写入client_activities的去重(保证跟以后正常的 7 天轮询不会重复处理同一封信)、清空question_received_at等细节。 - 排除你会额外提供的"这段时间里已经人工手动处理过"的客户名单,这些不需要 AI 重新分类一遍。
- 控制 Groq 调用节奏,配合已经上线的"按模型分开追踪 token 用量 + gpt-oss 额度不够自动借用 qwen"这套机制(
callGroq/pickFreshProtagonist附近的额度检查逻辑),不要因为一次性处理几个月的积压邮件,把当天 gpt-oss-120b 的额度一次性打满,影响到同一天其他实时功能(邮件分类、社媒生成等)。
clients.status 会被正确更新到位。因为 cooling 状态有一条触发路径就是"客户回复了婉拒"(soft_rejection),这一步做完之后,那些原本应该进入冷却期的客户会自然跟着落进 cooling,re-engagement 的时间线也会跟着开始生效——不需要为 cooling / re-engagement 单独再做什么(原因见下方"为什么 warm/high_intent/cooling/re-engagement 不需要单独 backfill")。1) re-engagement 的冷却期时间线会比"理想情况"晚——如果今天(8月)才补上 5 月的一条历史回复,系统计算冷却到期日(
reengagement_date)是从"今天"往后推算的,不是从 5 月那个真实时间点往后推。这个没有完美解法,只能接受这个折中,不影响正确性,只是时间会晚一些。
2) 可能覆盖掉人工手动改过的状态——如果这几个月里你在界面上凭电话沟通等人工渠道,手动改过某个客户的状态,backfill 重放老邮件时有可能把这个手动修正覆盖掉,因为系统目前没有"这条是人工改的,不要自动覆盖"这种标记。建议 backfill 跑完之后,抽查一下你记得手动改过的那几个客户,确认状态没有被意外覆盖,需要的话手动改回来。
3) 4 月 23 日之前的极少数邮件——如果当时恰好撞上"③ 自动发信不建客户档案"那个窗口期(
client_id 先天缺失),现在因为已经加了兜底匹配(按回信邮箱直接查 clients.contact_email),backfill 理论上一样能正确接住,不需要额外处理,但值得在抽查阶段多留意一下这类特别早期的记录。
任务2 · Not Interested / Bounced 状态重新同步
这个任务跟任务1完全独立,互不依赖,可以按任意顺序做,甚至可以合并到同一次脚本里一起跑。背景是"② Webhook 写库没等完成"这个 bug——email_log 里 unsubscribed / bounced 这两类记录本身没有缺失(已经用真实生产数据核实过,4-7 月每个工作日都有连续记录),缺的只是这些正确记录当时没有同步写进 clients.status。
- 查询
email_log里所有status IN ('unsubscribed','bounced')的记录,按sent_at正序取出——不限定具体某个月份,因为这个 bug 本质是"偶发的写入丢失",理论上随时可能发生,不是固定在某一个月的窗口内,限定月份反而可能漏掉窗口外的个案。 - 按时间正序逐条重放:
status = 'bounced'→clients.status = 'bounced';status = 'unsubscribed'→clients.status = 'not_interested'。同一个客户如果历史上有多条记录,正序处理完,状态会正确停在最新的那一条。 - 按你的决定,这次 backfill 不使用"active / not_interested / bounced 状态不可被覆盖"这条保护(
autopilotAutoSend判断新客户时用得上,但这里刻意不用)——原因是当前只有 2 家 active 客户,风险很小,直接覆盖即可,不用为了极小概率的场景增加复杂度。 - 每条重放的同时,补一条
client_activities记录,body 前面加一个类似[Backfilled]的标记前缀,方便以后区分这条是当时实时产生的,还是这次补录的。
为什么 warm / high_intent / cold_restart / re-engagement 不需要单独 backfill
这几类不是存起来的历史记录,而是 generateFollowupQueue() 每次运行时,根据 email_log 当前的 open_count / status,加上"距离首封邮件过了几天"现场算出来的,不依赖"当时是否被实时处理过"。只要满足两个条件:底层的 email_log.open_count / status 数据本身是对的(已确认——这块走的是 Brevo 的 opened/click webhook 事件,一直在正常记录,不受收件箱配错这个问题影响),以及代码上线后 autopilot 的每小时 / 每日定时任务正常跑起来——那么任何当前真实符合 warm / high_intent / cold_restart 条件的客户,下一次定时任务运行时就会被自动、正确地捕捉到,不需要额外写脚本去补历史。re-engagement 同理,是靠 cooling.reengagement_date 到期后自动触发的,只要 cooling 状态被任务1正确同步了,re-engagement 会自然跟上,不需要单独处理。
设置项 ↔ 代码对照表
| 界面字段 | 设置键名 | 默认值 | 被谁读取 |
|---|---|---|---|
| Daily Send Limit | autopilot_daily_limit | 20 | autopilotAutoSend |
| Followup 1 after (days) | behavior_followup1_days | 14 | autopilotFollowUp(opens=0 路径)、generateFollowupQueue(opens=1 路径) |
| Followup 2 after (days) | behavior_followup2_days | 14 | 两者都用,作为"发出F1等价物之后再等多久发最后一封"的时长 |
| Cold Restart after (days, no open) | behavior_cold_restart_days | 7 | generateFollowupQueue |
| Warm trigger after (opens) | behavior_warm_opens | 2 | generateFollowupQueue |
| Warm send after (days) | behavior_warm_send_days | 3 | generateFollowupQueue |
| High Intent after (days, no reply) | behavior_high_intent_days | 7 | generateFollowupQueue |
| Cooldown after rejection (days) — Cycle 1 | reengagement_cooldown_days | 60 | getCooldownDaysForCycle,仅第1轮冷却适用 |
| Cycle 2 cooldown (days) 新增 | reengagement_cooldown_cycle2_days | 90 | getCooldownDaysForCycle |
| Cycle 3 cooldown (days) 新增 | reengagement_cooldown_cycle3_days | 180 | getCooldownDaysForCycle |
| Cycle 4+ cooldown (days) 新增 | reengagement_cooldown_cycle4_days | 365 | getCooldownDaysForCycle |
| Auto Cold Restart 新增 | autopilot_auto_cold_restart | false | generateFollowupQueue(!isTestMode && 此设置——只对非测试调用生效,决定真实客户是否跳过 Preview Send Queue 直接自动发) |
| Auto Warm 新增 | autopilot_auto_warm | false | generateFollowupQueue(同上,仅非测试调用) |
| Auto High Intent 新增 | autopilot_auto_high_intent | false | generateFollowupQueue(同上,仅非测试调用) |
| Auto Reengagement 新增 | autopilot_auto_reengagement | false | generateFollowupQueue(同上,仅非测试调用) |
2026-07-30 之前,Cycle 2/3/4+ 这三档是写死在代码里的字面量(90/180/365),现在统一收进了 getCooldownDaysForCycle(cycle) 这个共用函数,读取上面这几个设置项,三处用到冷却天数的代码(autopilotFollowUp 的两处、/api/autopilot/send-followup/:id 发送接口)都改成调用它,不再各自写一份。
同一天,Cold Restart / Warm / High Intent 这3个类型的邮件内容也从"AI 现场生成"改成了"完全按 Settings → Email Templates 里保存的模板生成"(Reengagement 一直就是按模板来的,没有变过);同时新增了上面这4个 Auto 开关,用来控制正式环境下这4类邮件要不要跳过 Preview Send Queue 直接自动发——测试客户不受这4个开关影响,永远走队列人工审核,见"规则4"。
测试结束后记得复位
- 把 Test Follow-up 关回 OFF。
- 清空测试客户(测试客户提示条上的 ✕)。
- 把所有"天数"类设置改回正常值(14 / 14 / 7 / 3 / 7 / 60,Cycle 2/3/4+ 改回 90 / 180 / 365)。
- 删除或归档 B5、F2/F3 场景里为了测试临时建的客户/线索记录。
- 确认没有遗留问题后,再手动打开 Auto Followup 1 / Auto Followup 2 / Auto Send New Emails / Auto Cold Restart / Auto Warm / Auto High Intent / Auto Reengagement 用于正式环境——测试期间这7个必须一直是关着的(见"规则4")。