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"),很容易被忽略过去。2026-08-07 重构 优先按 test_client_id(这家公司)动态查当前符合条件的下一条 lead,不再死认一个固定的 test_lead_id——爬虫在同一家公司发现新联系人后,这个按钮也总能找到最新那一条。只有全新测试客户(还没生成过 clients 记录,即从没成功发过第一封邮件)才会退回用固定的 test_lead_id 查,作为启动阶段的兜底。 |
| 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 + autopilotRecycleReengagements,无视节假日/时段限制——这就是能把"14天"压缩成"1小时"的机制,见下方"准备工作"第3步。2026-08-24 起 generateFollowupQueue 已经独自负责全部6种类型的判断(包括"Cold Restart 发过、没打开、升级到 Followup 2"这一段,不管打开次数多少),autopilotRecycleReengagements(原名 autopilotFollowUp)现在只剩"再触达发了没人回、超时收回 Cooling"这一件不相关的事。这个开关只对选中的测试客户生效,跟下面的真实开关完全独立,见下方"规则4"。 |
| Run Test Follow-up Now | 同上 | POST /api/autopilot/run-test-followup-now | 手动触发一次:先轮询真实收件箱(checkInboxReplies),再跑 generateFollowupQueue(现在独自完成"生成 Cold Restart"和"没打开、升级到 Followup 2"两段,不用再依赖另一个函数接力),最后跑 autopilotRecycleReengagements 处理跟这两段完全无关的"再触达超时收尾"。 |
| Preview Send Queue | 同上 | GET /api/autopilot/preview-queue | 展示两部分:「First Emails」(可批量发)和「Follow-ups」(cold_restart / followup1 / followup2 / warm / high_intent / reengagement,逐条发送,发送前都会重新校验一次)。2026-08-07 Follow-ups 每条右侧显示的收件邮箱,现在是实际会发给谁(跟发送时 pickFollowupEmail() 解析出的地址完全一致),不再是这家公司名下全部已知邮箱的原始列表——之前如果一家公司同时有新旧两个联系人,这里会把两个邮箱都显示出来,容易让人以为要发给两个人。 |
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:测试执行范围必须严格限定在你选中的这一个测试客户身上,绝不能波及任何其他真实客户。这条比听起来更容易被违反——因为 generateFollowupQueue() 这类函数在正式环境本来就要每天9点被调用一次处理所有真实客户,并且真的会读 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",不影响邮件内容或发送逻辑本身——测试通过等于正式打开开关后行为一样。规则5 · Clients 标签页的 Contacted 分类,现在是互斥的,并且带一个子状态筛选下拉框 2026-08-04
这条直接影响你在下面每个场景里"去哪个 tab 验证结果"。
改之前,Contacted tab 是个"宽口径"分类:只要发过邮件,即使客户现在的真实状态是 cooling / not_interested / bounced,也会一并显示在 Contacted 里——结果就是同一个客户会在两个 tab 里同时出现(比如一个 bounced 客户,Bounced tab 有它,Contacted tab 也有它)。现在改成和 Active / Cooling / Not Interested / Bounced / Competitors 这几个 tab 完全一样的口径:Contacted tab 只显示 status='contacted' 的客户,一个客户永远只会出现在一个 tab 里。
同时在 Contacted tab 里,"All Industries" 筛选框右边新增了一个 "All Contacted Stages" 下拉框(只在 Contacted tab 打开时才显示),可以直接按卡片上那个状态标签筛选:Awaiting Reply / Cold Restart Pending / Cold Restart / Follow-up 1 Pending / Follow-up 1 / Follow-up 2 Pending / Follow-up 2 / Warm Pending / Warm / High Intent Pending / High Intent / Re-engagement Pending / Re-engaged——这些选项和 createClientRow() 里算出来的卡片状态标签是一一对应的,"Pending" 代表还在 Preview Send Queue 里等你审核,不带 Pending 的代表已经发出去、还没排下一个。
cold_restart 排进队列后,与其在 Contacted 列表里翻找,不如直接把下拉框切到 "Cold Restart Pending" 一步定位;C1 发完 reengagement 后,切到 "Re-engaged" 就能立刻确认。status='contacted',页面却是宽口径),这次一并修掉了,两边现在共用同一段筛选逻辑(buildClientsWhereClause)。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(Cycle 1),以及新加的 Cycle 2 / Cycle 3 / Cycle 4+ cooldown(Warm trigger opens 和 Daily Send Limit 不用改,保持默认 1 和 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。
- 不要打开第一封邮件。点 Run Test Follow-up Now,不用等一小时。
- 打开 Preview Send Queue → Follow-ups 里应该出现一条
cold_restart,主题是Re: {第一封邮件的原主题}(2026-08-06 起,Cold Restart/Warm/High Intent/Followup1/Followup2/Reengagement 这6类统一改成这个格式,不再各自用独立文案——实测发现独立主题即使带了正确的回复邮件头,Gmail 也不会把它跟原邮件合并成同一个对话串,客户看不到之前的邮件内容,所以改成跟原主题挂钩的 "Re:" 格式,确保能在 Gmail 里正确合并显示),正文来自 Settings → Email Templates 里保存的 Cold Restart 模板(2026-07-30 起改为完全按模板生成,不再是 AI 现场写的内容——如果正文跟你在模板里编辑保存的不一样,就是 bug)。 - 点 Send。确认它带着模板里的内容真的进了收件箱。
- 仍然不要打开它。再点一次 Run Test Follow-up Now。
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>Run Test Follow-up Now 背后的 /api/autopilot/run-test-followup-now 只调用了 generateFollowupQueue(),没调用 autopilotFollowUp()。这两个函数分工不同:generateFollowupQueue() 能生成 Cold Restart,但它的 opens===0 分支只有"生成 Cold Restart"这一段,没有像打开1次/Warm/High Intent 那三条路径一样带"升级到 Followup 2"的第二段;"Cold Restart 发完、还是没打开、升级成 Followup 2"这一步,是 autopilotFollowUp() 单独管的。所以点这个按钮之前,不管点多少次都不会有 Followup 2,看起来像是卡住了。当时把 autopilotFollowUp({testClientId}) 补进了这个接口,效果跟每小时 cron 完全一致,两步都能直接点按钮测,不用再等。2026-08-24 起这里说的"两个函数分工"已经是旧账了——generateFollowupQueue() 现在自己就能完成"生成 Cold Restart"和"升级到 Followup 2"两段,不再需要另一个函数接力,见上面工具面板表格里的最新说明。A2 · 打开一次(Warm → Followup 1 → Followup 2)
Warm(进队列,按模板生成)→ 发送 → Followup 1(进队列)→ 发送 → Followup 2(进队列)→ 进入 Cooling。
behavior_warm_opens(Settings 里的 "Warm trigger after (opens)",默认 1 次,因为 Gmail 等邮箱的图片代理会缓存追踪像素,"数到第2次打开"这个信号本身就测不准,见下面第306行附近的历史说明)就先进 Warm,Warm 发出后没回复才轮到 Followup 1,Followup 1 没回复才轮到 Followup 2。也就是说"只打开一次"现在触发的是 Warm,不再是 Followup 1。- 真的打开第一封邮件一次(在私人邮箱里点开它,让 Brevo 的 open 事件真实触发)。
- 确认 Brevo 后台/邮件的追踪像素被加载后,点 Run Test Follow-up Now。Follow-ups 里应该出现一条
warm,正文来自 Settings → Email Templates 里保存的 Warm 模板。 - 从 Preview Send Queue 里发送它。
- 再次 Run Test Follow-up Now——应该出现一条
followup1(正文用的是新版"help you get set up"这段中性措辞,不再预设对方到底看没看过邮件,因为这条现在只会接在 Warm 后面出现)。 - 发送它,再 Run Test Follow-up Now 一次 → 出现
followup2。
A3 · 调高 Warm 阈值不会有实际效果——只要打开过就算 Warm 2026-08-25 起
验证"把 behavior_warm_opens 手动调高(比如调到3)之后,客户只打开了1次,会怎样"——这是个边界确认,不是常规路径,默认设置(阈值1)下走不到这里,A2 已经覆盖默认情况。
open_count 基本只会停在1,不会真的涨到2、3。所以 behavior_warm_opens 这个设置调到2以上,在当前 Brevo 的抓取能力下不会有任何实际效果——代码里把它的判断值封顶在1(`Math.min(warmOpenThreshold, 1)`),不管界面上这个数字填多少,只要客户真的打开过一次,就一定会走 Warm,不会因为你调高了这个数字就被晾在一边、也不会误落回 Cold Restart。这条设置本身没有被删掉或锁死——万一以后 Brevo 那边打开次数抓取能力有提升,这个设置可以重新变得有意义,只是现在调高它不产生任何效果。- 把 Settings 里
behavior_warm_opens("Warm trigger after (opens)")临时改成 3。 - 真的打开第一封邮件1次。
- 等够 Warm send after (days) 设置的天数,Run Test Follow-up Now → Follow-ups 里正常出现一条
warm,不会因为阈值调到了3而被拦下、也不会误触发cold_restart。 - 把
behavior_warm_opens改回默认值 1,测试完记得复位。
behavior_warm_opens 已经改回 1,即可开始下一个场景。A4 · 点击了链接(High Intent)
High Intent 邮件(进队列)→ 发送 → Followup 2(进队列)→ 进入 Cooling。点击优先级高于打开——不会退回到 warm 路径。
- 真的点击第一封邮件里的链接。
- Run Test Follow-up Now → 应该出现一条
high_intent,主题是Re: {第一封邮件的原主题}(见 A1 场景里 2026-08-06 那条说明),正文来自 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'。not_interested 正确救回 active——之前这条 UPDATE 的 WHERE status IN (...) 没包含 not_interested,救不回来,已修复。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——这才是"所有联系人都拒绝了"这个判断真正生效的时候。email_log(也就是这个联系人还从没被发过信)。用主邮箱回复硬拒绝,Run Test Follow-up Now → 客户状态应该仍然不变,不会因为"第二联系人没有已发送记录"而被误判成"没有其他有效联系人"。活动日志里应该能看到"a known contact hasn't been emailed yet"这条记录。这个场景对应的是 D1 里说的真实触发过的那个漏洞。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 提示,方便一眼看出这不是全新客户。2026-08-04 现在更快的办法是:打开 Clients → Contacted tab,把新加的 "All Contacted Stages" 下拉框切到 "Re-engaged",直接筛出来,不用在列表里翻找(见"规则5")。2026-08-07 口径改了 N 现在是真实发送过的 re-engagement 邮件次数(鼠标悬停能看到具体几轮冷却期已完成),不再是内部的 cycle 计数器——旧口径下 cycle=2 时显示"×2",但实际 re-engagement 邮件只发过1次,容易让人误以为发了2次;如果一家公司经历过换联系人重置,新旧联系人各自的 re-engagement 次数也不会混在一起算。这里的 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 · 爬虫在已归档公司发现新联系人 2026-08-06 真实环境触发过一次,顺带修了两个缝隙
这套逻辑只会从真实爬虫内部触发(Seek/Indeed/TradeMe/SJS/Himalayas 解析到一条真实招聘帖时)——没有任何 UI 操作能直接激活它,没法像上面的场景一样手动驱动。
- 之前完全无迹可寻——触发之后
cycle/reengagement_date静默改变,时间线上找不到任何原因,只看到状态莫名其妙变了。现在isCompanyArchived()会写一条client_activities记录("🆕 New contact found: {邮箱} — starting fresh outreach"),时间线弹窗会把这条记录和其他历史事件混在一起按真实时间排好序显示出来;旧联系人的历史邮件记录完全保留,不会因为这次重置而消失。 - 硬拒绝误判风险——如果重置发生后,新联系人的第一封邮件还没来得及发出去,这时候旧联系人恰好发来一封硬拒绝,之前的代码会因为"新联系人没有任何已发送、未被拒绝的邮件记录"而误判成"没有其他有效联系人了",直接把公司标成
not_interested——即使这个新联系人根本还没被联系过。现在硬拒绝判断新增了"是否存在从未发过信的已知联系人"这层保护,具体测试步骤见 B5 那条"未联系过的联系人保护"分支。
继续追查这次真实触发的过程中,同一条链路上又挖出三个更深的问题,一并修复(2026-08-07):
- 发信后邮箱被覆盖,不是合并——"发出第一封邮件后回填客户记录"这段代码(真实自动发信循环、"Send All First Emails"批量路由、单条手动发送共用的
ensureClientForLead()、以及测试用的 Run First Email Now,一共4处)之前都是contact_email = 新邮箱直接覆盖整个字段,不是合并进已有的逗号分隔列表。新联系人的第一封邮件一旦真的发出去,isCompanyArchived()之前辛苦合并进去的老联系人邮箱反而会被冲掉丢失——The Painting Company 这个测试客户真实中招过,老邮箱被冲得只剩新邮箱一个。已改成发信前先查一次数据库当前值,再合并写回,不再是无脑覆盖。 isCompanyArchived()自我拉黑——真实自动发信循环、以及"Send All First Emails"批量发送路由,之前会在真正发信之前再调用一次isCompanyArchived()做"最后确认"。但这个函数第一次调用(爬虫抓取时)已经把新邮箱合并进contact_email了,第二次调用比较的是"已经合并过的邮箱",永远会判定为"已知联系人"从而被永久跳过——新联系人的第一封邮件因此永远发不出去,这就是2026-08-06那次真实卡住的根本原因。已删除这个多余的第二次调用。- 联系人姓名没跟着换——邮箱合并只处理了
contact_email,从没更新过contact_person,导致给新联系人发的邮件称呼还是老联系人的名字(比如实际收件人是"Dave",邮件称呼却还是"John")。现在合并邮箱的同时也会把姓名换成新联系人的(提取不到像样姓名就清空,落到"Hi there",绝不会保留旧姓名去称呼一个不同的人)。
contacted / active 期间发现新联系人,现在怎么处理
2026-08-07 之前,只要公司当前是 contacted 或 active 状态(原联系人的序列还在进行中),爬虫爬到的任何新联系人都会被 isCompanyArchived() 当成"已归档"整条丢弃,连 b2b_leads 里的记录都不会留下——如果这条招聘帖后来下架、爬虫再也没爬到过同一条,这个新联系人就永久性地丢失了。现在改成:照常记录进 b2b_leads,但不会立即合并进 contact_email、不会打扰原联系人正在进行的序列。等这家公司真正因为"没回应"要从 contacted 转入 cooling 的那一刻(2026-08-24 起是 sendFollowupQueueItem 的 Followup2 完成分支、以及 autopilotRecycleReengagements(原名 autopilotFollowUp)的 cycle 2+ 收尾这两处,不再是原来同一个函数里的两段),系统会主动回头检查 b2b_leads 里有没有这种"记录了但还没合并"的新联系人——如果有,就直接走"发现新联系人"那套流程(合并邮箱+姓名、cycle 归1、清空 reengagement_date),而不是照常排一封给老联系人的 re-engagement 邮件。
contacted,往它的公司名下插入一条带新邮箱的 b2b_leads(status='new'),确认它不会立刻出现在 clients.contact_email 里;然后把测试客户拨到 F2 已发、即将因没反应转 cooling 的临界点,Run Test Follow-up Now(或等 Test Follow-up 每小时 cron),确认这时候 contact_email 和 contact_person 才真正更新,状态直接进入"🆕 New Contact Found"这个 cooling 分支,而不是普通的"排 N 天后发 re-engagement"。clients.contact_reset_at 被更新,而且旧联系人的历史邮件记录还完整保留在上面。E组:发送前二次校验 — 队列里的每一项在真正发送前都会被重新检查一遍。
E1 · 邮件在排队期间"过期"了
2026-08-07 现在有两条独立的防线:如果是客户直接回复导致的状态变化,回复处理的同时就会立刻把排队项标掉,不用等到点 Send 才发现;如果状态变化是通过别的方式发生的(没经过回复处理这条链路),才会退回"点 Send 时才被拦下来"这条本来就有的保险丝。这个场景把两种情况都测一遍。
变体1:客户真的回复了(现在会立刻清掉,不用等点 Send)
- 先让某条
followup1(或任意类型)静静地躺在队列里(参考 A2 第2步)。 - 用 B1(感兴趣)/B2(提问)/B3(软拒绝)/B4(硬拒绝)任意一种真实回复,让客户状态发生变化。
- 回到 Preview Send Queue 刷新看一眼,不用点 Send。
status='skipped')——不用你手动点 Send 才发现它失效,markClientReplied() 在处理这条回复、改状态的同一时刻就顺手把它清掉了。变体2:状态变化不是通过回复触发的(还是走"点 Send 时才拦下"这条旧路径)
- 同样先让一条排队项躺着。
- 不经过回复,直接改数据库模拟退信:
UPDATE email_log SET status='bounced' WHERE client_id=<id> ORDER BY sent_at DESC LIMIT 1。 - 现在再去点这条仍显示"待发送"的队列项的 Send。
skipped,而不是 sent。不会有邮件真的被发出去。generateFollowupQueue 每天9点定时任务)本身也收紧了:判断条件从 status IN ('contacted','active') 改成只认 status = 'contacted'。之前 'active' 状态的客户理论上也可能被选中生成一条候选,但发送前的复查永远要求精确是 contacted,'active' 从来发不出去——等于白生成一条注定失败、只会让 Preview Queue 看着奇怪的排队项。这条不好用测试客户直接复现(要卡在候选生成的具体时间窗口),了解即可,不强制验证。F组:三条发送路径的归档拦截 — 同一条规则,三种不同的落实方式:F1 全自动无需提示;F2 有人工在场,多给一句"是不是同一个人"的提示;F3 现在和 F1 用同一套判断逻辑。
F1–F3 · not_interested / bounced 公司防止重新联系 F2/F3 本轮升级
验证的是这条规则:公司已经被标成 not_interested 或 bounced 之后,同一个联系人再出现不该被重新联系;换了个不同联系人则应该正常放行,当成一个全新机会,不该被上一个人的拒绝连累。
先说清楚这条规则实际是怎么落实的,因为容易理解错:真正做判断的只有一个地方——isCompanyArchived(),而且只在 crawler.js 里、爬虫准备把一条新线索插入 b2b_leads 表的那一刻调用一次。同一联系人 → 这条线索压根不会被插入 b2b_leads;换联系人 → 正常插入,同时把新邮箱合并进 clients.contact_email,判定为全新周期。F1(全自动主流程)和 F3(批量发送)都没有自己的拦截逻辑,只是分别去读"现在 b2b_leads 里有哪些能发的线索"——既然同一联系人的线索从一开始就没能力进这张表,F1/F3 自然也就看不到它、更谈不上要不要拦,不是"F1/F3 收到了、判断一下、悄悄跳过"。F2 是唯一的例外,见下方说明。
| 路径 | 接口 | 同一联系人再出现 | 换了联系人再出现 |
|---|---|---|---|
| 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) | 这条线索根本不会有草稿、不会出现在 Preview Queue 列表里,无需拦截 | 放行 — 现在和 F1 用同一个 isCompanyArchived,换了联系人会自动发出,并触发 contact_reset_at 重置 |
为什么 F2 不一样,需要自己单独查一次:F2 操作的是你直接指定的某一条已经存在的线索 id——这条线索完全可能是在公司被标成 not_interested 之前就已经躺在 b2b_leads 里了(那时候公司还没被拒绝,插入时压根没触发过 isCompanyArchived 的拦截),所以 F2 必须在真正发送前自己单独查一次公司当前的真实状态,弹窗确认,不能假设"能走到这一步就说明已经检查过了"——这跟 F1/F3 只处理"当下正常插入进来的新线索"是不同的场景。
怎么测:F1/F3 不需要单独构造"同一联系人"的测试用例(构造不出来,线索进不了表),直接复用 D1 的验证结论即可(本质是同一个判断函数);F1/F3 只需要测"换联系人后能否正常放行发出"这一半。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 · 点击事件归因与优先级 2026-08-24 改版
这是唯一真正需要靠真实 Brevo 才能测到位的场景——本地环境无法收到 webhook,交给真实环境验证。三个子场景都围绕同一件事:点击事件有没有被记到正确的邮件上,以及记对之后系统会不会真的据此改变分支。
子场景 A(核心):Cold Restart 里的链接被点击 → 触发 High Intent
- 让测试客户走到"Cold Restart 已发送、Followup 2 还没发"这个阶段。
- 确认这封 Cold Restart 邮件里的链接是
https://www.jobvista.app这种真链接(而不是裸文字 "jobvista.app")——包括数据库settings.cold_restart_template里存的版本,不只是代码默认值。 - 点击邮件里的链接。
- 点 Run Test Follow-up Now(或等下一次每小时测试 cron)。
email_log 里 Cold Restart 那一行 status 变成 clicked;系统不会直接生成 Followup 2、也不会转 cooling,而是在 Preview Queue 里生成一条 high_intent 类型的排队邮件。2026-08-24 起这条链路才第一次被支持——此前 Cold Restart 自己的点击完全不会被判断逻辑读取,只有 First Email 的点击才算数(见下方"背景")。SELECT type, status, open_count, brevo_message_id FROM email_log WHERE client_id=<id> ORDER BY sent_at ASCSELECT id, type, status FROM followup_queue WHERE client_id=<id> ORDER BY created_at DESC LIMIT 1(确认最新一条是 high_intent,不是 followup2)子场景 B(边界测试):Warm 里的链接被点击 → 不应该触发 High Intent
- 让测试客户走到 Warm 已发送阶段(打开次数达到 Warm 阈值后自然会走到这一步)。
- 点击 Warm 邮件里的链接。
- 点 Run Test Follow-up Now。
email_log 里 Warm 那一行 status 会变成 clicked(这个记录本身没问题,只用于点击率统计),但不会生成 High Intent——客户仍然按 Warm→Followup 1→Followup 2 原节奏继续走。这一条是故意排除的:Warm 邮件本身的内容已经跟 High Intent 很接近,点击 Warm 不算一个独立的"高意向"信号。这条测试专门用来防止以后不小心把 Warm 也并进点击判断范围而不自知。status='clicked',但 followup_queue 里没有新增 high_intent 记录。子场景 C:两封邮件同时在外,事件的行归属
- 给同一个客户先后发出
first和cold_restart两封邮件——email_log里会有两条记录,各自带着不同的brevo_message_id。 - 用下面这个模拟工具,选中较早那封(
first),事件类型选"退信",点模拟——不用真的发到一个会退信的地址,也不用等 Brevo 真实回调。 - 看模拟结果、或去数据库确认。
first 那条记录的 status 会变成 bounced,更晚发出的 cold_restart 那条完全不受影响。如果记错行,后果是真实的:真正的死地址会一直显示"sent"、系统不知道它已经收不到会继续用;而一封其实寄达了、根本没问题的邮件却被误标成 bounced,之后可能被错误地当成坏地址处理。- 先准备好两封邮件 — 正常操作把
first和cold_restart都发出去(跟平时测试一样,Run Test Follow-up Now 之类)。 - 拿客户 ID — 跟平时查数据库一样,跑:
SELECT id FROM clients WHERE company_name LIKE '%公司名%';
查出来的数字(比如 75317)就是客户 ID。 - 用这个面板 — 客户 ID 填进输入框 → 点"查这个客户的邮件"(下面出现下拉框,列出这个客户所有邮件,格式类似
#967 · first · sent · 2026-08-27 ...,#后面是这封邮件自己的 id)→ 在下拉框里选中要模拟的那一封(按场景 C 的要求,选较早那封,也就是first)→ 事件类型选"退信 (hard_bounce)" → 点"模拟"。 - 看结果 — 会直接显示类似:
✓ 匹配正确 —— 模拟目标:#967(first),实际改动落在:#967(first, 现在状态=bounced)
意思是:你选的是 #967(first 那封),系统处理完之后确实是 #967 这一行被改成了 bounced。如果"实际改动落在"显示的是另一个编号(比如 #968,也就是cold_restart那封),前面会显示"✗ 匹配错了",说明代码有问题。
SELECT type, status, open_count, brevo_message_id FROM email_log WHERE client_id=<id> ORDER BY sent_at ASC背景:为什么"点击"要分邮件类型讨论,"打开"不用
generateFollowupQueue 判断"打开次数"(opens)时,看的只是 first 这一封邮件自己的 open_count——但要走到"两封邮件同时在外"这种可能记错行的状态,前提是打开阈值早就跨过去了,所以一次记错行的迟到打开,不会翻盘任何分支决策,不再需要单独测。判断"点击"则不同,2026-08-24 起明确定义为:点击 first 自己的链接,或点击 cold_restart 自己的链接,都会被判断逻辑读取并触发 High Intent;点击 warm/followup1/followup2/high_intent/reengagement 自己的链接则只会被记录、不会被判断逻辑读取。子场景 A/B 就是分别验证这两半是不是都对。
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 · 完整机制说明 2026-08-26 补充
这是背景说明,不是可执行的测试步骤——先搞清楚这个标记具体在哪几个环节起作用,再看下面 Watchlist1/2 具体怎么测。
manual_review 这个字段,b2b_leads 和 clients 两张表里都有,是"公司级别"的意图标记,不是只存在某一张表里:
| 阶段 | 生效方式 |
|---|---|
| First Email | autopilotAutoSend() 挑候选人的查询明确排除 b2b_leads.manual_review = 1 的线索,第一封邮件永远不会被自动发出,必须去 Opportunities 手动点发送。 |
| 转化成正式 Client | 标记会跟着一起转过去(ensureClientForLead/批量发送路由里的 manual_review = manual_review OR ?),不会因为转化就丢失标记。 |
| 后续6种跟进类型(Cold Restart/Warm/High Intent/Followup1/Followup2/Re-engagement) | generateFollowupQueue() 该怎么生成候选还是怎么生成,一视同仁,会正常生成一条 pending 的排队邮件;但最后那段"按优先级+每日上限自动发送"的循环里明确判断 if (item.manual_review || !autoToggleForType[item.type]) continue;——不管对应 Auto 开关开没开,直接跳过,永远停在 pending,只能靠人工去 Preview Send Queue 点 Send。 |
| 在哪里管理 | Opportunities 列表里线索行前面的复选框(🔖),批量勾选/取消走 PUT /api/opportunities/leads/bulk-flag;查看和编辑待发内容,是 Opportunities 里"筛选:Watchlist"这个视图——这个视图故意不限制 status,所以一家公司不管走到第几个阶段,只要还带着标记,都会一直出现在这个视图里,用同一个编辑器改内容、点发送。 |
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 候选人查询),只要这条查询条件没被改动,就不需要单独验证。Watchlist2 · 有排队邮件未处理时,复选框必须锁住无法取消 2026-08-26 新增
验证"取消 Watchlist 标记"这个动作,在这个客户名下还有没处理完的排队邮件时会被正确拦住——不然下一次自动任务一跑,这条从没被人审核过的排队邮件会立刻被自动发出去。
- 沿用 Watchlist1 场景,让测试用的这条线索/客户带着 Watchlist 标记,走到"名下有一条 pending 排队邮件"这个状态(比如 Cold Restart 或 Warm 正躺在 Preview Send Queue 里等审核)。
- 去 Opportunities 列表,尝试点掉这条线索前面的 Watchlist 复选框。
- 确认复选框是灰色、点不动的状态,鼠标悬停能看到提示文字说明原因。
- 把这条 pending 邮件处理掉(在线索详情页的编辑器里点 Send 真发出去,或者点 Skip 跳过)。
- 回到 Opportunities 列表,确认复选框恢复成可以点击的状态。
- 这次点掉复选框,确认能正常取消 Watchlist 标记。
PUT /api/opportunities/leads/bulk-flag 会在这种情况下拒绝取消标记),不是只靠前端禁用按钮这一层——就算跳过界面直接调接口,一样会被拒绝。Follow-up Draft · Clients 详情页的手动跟进面板 2026-08-26 重做 — 这个面板会在真实引擎判断的"当前该轮到的类型"基础上,自由生成内容(可以参考真实模板但不用照抄)后手动发出去,发送走的是跟自动引擎完全相同的 followup_queue + sendFollowupQueueItem() 这套机制,不会跟自动流程产生重复。
Follow-up Draft 1 · 面板显示的类型必须跟真实引擎判断一致
验证面板顶部显示的"当前该轮到的类型",跟时间线弹窗、真实引擎会生成的类型完全一致,不是另外一套自己猜的逻辑。
- 走标准准备工作,让测试客户处于"发完 First Email、还没打开过"的状态。
- 打开这个客户的 Clients 详情页,确认 Follow-up Draft 标签显示"Follow-up Draft (Cold Restart):",Generate 按钮可以点。
- 真实打开一次这封邮件,刷新详情页,确认标签变成"Follow-up Draft (Warm):"。
- 点击邮件里的链接,刷新详情页,确认标签变成"Follow-up Draft (High Intent):"。
- 对照时间线弹窗(Email Timeline)里"预测下一步"显示的类型,应该跟面板上显示的完全一致。
decideFollowupType(),不存在各自维护一份、容易对不上的风险。Follow-up Draft 2 · 手动发送不会跟自动流程重复,且能正确续接下一步
验证手动发送这一步真的会替代掉自动引擎本该做的事,不会两边都发一遍;也验证发送之后,自动引擎能正确从"这一步已经发过"续接到下一步,不会重跑或卡住。
- 让测试客户走到某个引擎已经生成了 pending 排队邮件的状态(比如打开过邮件,Warm 已经在 Preview Send Queue 里等审核)。
- 去这个客户的 Clients 详情页,Follow-up Draft 面板此时应该显示"Warm",点 Generate 生成内容,点 Send 发出去。
- 去 Preview Send Queue 确认:刚才那条 Warm 排队记录已经消失(变成 sent 状态),不是又多出来一条重复的。
- 去
email_log确认新记录的type是真实的warm,不是笼统的编号。 - 等够 Followup 1 after (days) 设置的天数,Run Test Follow-up Now,确认自动生成的是
followup1(不是重新生成一次 Warm,也不是卡住不知道下一步)。
H组:部署交接 —— 测试库到正式接管 — 2026-07-30 定下的方案:新版 Stella 先连一份从生产库拷出来的独立副本做全场景测试,跟 OnlineStella 正式环境完全隔离;全部测完没问题,再让新版 Stella 正式接管、连上生产库本体,同时彻底停掉 OnlineStella。
H0 · 测试环境怎么搭(开始测试前)
- 从生产库整体拷贝一份出来(比如
mysqldump导出再导入一个新库名),新版 Stella 的.env连接这份独立副本,不要连生产库本体。 - 新版 Stella 首次连上这份副本启动时,会自动把
contact_reset_at、rejection_type这两个新列加上去(纯新增,不影响其他数据)。 - 确认 OnlineStella 那边的部署完全没有改动——它应该继续正常连着生产库本体、正常跑它自己的自动化,你的日常业务不受这次测试影响。
H1 · 正式接管的时序 全部场景测试通过后再做
www.jobvista.app 是面向求职者/企业的 JobVista 产品官网本身,跟 Stella 不是同一个东西——Stella 是服务于这个产品的内部 CRM / 外联自动化工具(负责找企业线索、自动发开发信、跟进),运营自己用,不是 www.jobvista.app 这个域名下跑的东西。Stella 跑在独立的子域名上(测试阶段是 stellatest.jobvista.app),不影响 www.jobvista.app 本身的访问。2026-10 更新:实际走的路径跟这里原本写的顺序不一样,而且进展比这份文档原计划更快——没有先完整走完 H1 流程、再做 H2 backfill,而是先部署了一个独立的测试子域名(
stellatest.jobvista.app,跟旧的 Stella 完全分开的一套 Node 进程),直接连生产库本体(不是 H0 那种隔离副本)。H2 的 backfill(任务1-3)、H3 的脏数据清理、下面新增的任务4/5,全部是在这个实例上、对着生产库本体做的。这段文字写的时候,旧版 Stella(文档里一直叫 OnlineStella)的 Node 进程已经在今天(本轮测试期间)被删除了——不是"两边并行跑,旧的继续服务"这种状态,而是旧的已经不存在,stellatest.jobvista.app 现在是唯一在跑的 Stella 实例。7 个 Auto-send 开关目前仍然是关闭状态,这一点没变。换句话说,下面 H1 步骤2"停掉 OnlineStella"实质上已经发生,只是不是按这里写的顺序、也不是作为一个刻意规划好的步骤去做的——剩下还没做的主要是:确认子域名以后要不要从 stellatest 改成更像正式环境的名字(纯命名问题,不影响功能),以及正式打开 7 个 Auto-send 开关。这一步不是改几个设置那么简单,是两个独立进程的交接,顺序错了会有真实风险,请按顺序做,不要图快跳步骤。
- 接管前,对生产库做一次快照备份(纯粹是保险,用不上最好)。
- 先彻底停掉 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 Send New Emails有真实的Daily Send Limit把关,可以按原计划直接开。Auto Cold Restart / Auto Warm / Auto High Intent / Auto Followup 1 / Auto Followup 2 / Auto Reengagement 这6个共用 Settings → Standard → Email Send Limit → Capacity & Queue 里的Follow-up Daily Limit,推荐这样开:
推荐做法——先把 Follow-up Daily Limit 调低(比如 20-30),6 个开关一起打开:这个量级下,就算某个类型有问题,当天真发出去的总量也很小,影响范围可控,同时能一次性完整看到 6 个类型各自的真实效果,不用像逐个开关那样等好几天(generateFollowupQueue一天只跑一次,严格一个一个开、每个都等一整天的话,6 个类型可能要排到一周之后才能全部验证完)。跑完一轮之后,去 email_log / Preview Send Queue 抽查这次真发出去的邮件,6 个类型各挑几封,确认模板渲染、收件人、发送后的状态流转(比如 Follow-up 2 发完是不是正确进入了 cooling)都是对的。连续观察几天没问题,再把 Follow-up Daily Limit 调回 250。
如果想更谨慎、一个一个隔离着排查:也可以先去 Send Capacity & Queue 看一眼 6 个类型各自真实的 Pending 量级,按积压量从小到大开,不是按代码里 High Intent→Warm→Cold Restart→Follow-up1→Follow-up2→Re-engagement 那个固定顺序开——那个顺序只是同一天多个类型同时开着、互相抢 Follow-up Daily Limit 额度时的分配规则(只在多个开关同时开着时才起作用,不会记住你先开的是哪个),不代表开关必须照这个顺序打开。先开积压最小的类型,确认没问题再开下一个,把积压最大的(大概率是 Cold Restart 或 Follow-up 1)留到最后,出问题时影响范围最小;缺点是全部开完可能要好几天。 - 对 Auto Cold Restart / Auto Warm / Auto High Intent / Auto Reengagement 这4个新开关,打开后第一次有对应类型的邮件产生时,去 Preview Send Queue 确认它没有停在里面——应该是已经直接进了 email_log 变成已发送状态。但先排除两种正常情况再下结论:①这个客户是不是 watchlist(是的话,不管开关开没开,永远停在队列里等人工点 Send,这不代表开关没生效);②当天的
Follow-up Daily Limit额度是不是已经被更高优先级的类型用完了(用完的话,会正常留到下一次generateFollowupQueue运行,也不代表开关没生效)。排除这两种情况之后,如果还停在队列里,才说明开关没生效。 - 盯着接管后第一次整点 cron、第一次9点每日
generateFollowupQueue的日志,确认行为符合预期——尤其留意日志里[FollowupQueue] Auto-sent N queued item(s)和N auto-eligible item(s) held back - daily follow-up cap reached这两行,能直接看出当天有多少被发了、多少被上限挡住留到下一次。
generateFollowupQueue() 的候选人查询(server.js)没有 LIMIT,是对全部 status='contacted' 客户的穷举扫描——停摆几个月积攒下来的历史客户,大概率会有一大批同时符合 Cold Restart(一直没打开)和 Followup 1(打开过一次、没下文)的条件。当天已经把这个补上了:这6个类型现在共用一个真实的每日发送上限(autopilot_followup_daily_limit,默认 250,Settings → Standard → Email Send Limit → Capacity & Queue 里的 Follow-up Daily Limit 可以改,最高 300),按优先级 High Intent → Warm → Cold Restart → Follow-up 1 → Follow-up 2 → Re-engagement 发放——每天最多发这么多,用不完的额度不会累积到第二天,发不完的候选人留在 Pending,下一次 generateFollowupQueue 跑的时候接着按同样规则发,不会丢,也不会一次性爆发。接管当天按上面步骤9的推荐做法打开这6个开关即可,不需要再像之前版本要求的那样先手动清一遍积压——上限本身就会把每天的发送量控制住。默认值是 250,Brevo 账号本身的额度上限是 300,这里刻意设了一点余量。
举例:Follow-up Daily Limit 具体怎么算
假设 Follow-up Daily Limit 设的是 250,6 个 Auto 开关全部打开,今天队列里躺着这些"等发"的邮件:
| 类型 | Pending | 其中 Watchlist |
|---|---|---|
| High Intent | 3 | 0 |
| Warm | 5 | 0 |
| Cold Restart | 400 | 9 |
| Follow-up 1 | 200 | 4 |
| Follow-up 2 | 10 | 1 |
| Re-engagement | 8 | 0 |
9 点定时任务跑起来,实际发生的是:
- 按优先级发,不是雨露均沾:先把 High Intent 的 3 个全发(剩 247 额度)→ 再把 Warm 的 5 个全发(剩 242 额度)→ 轮到 Cold Restart,400 个里刨掉 9 个 watchlist 还剩 391 个候选,只够发 242 个,剩下 149 个继续留在 Pending → 额度花光,Follow-up 1(196 个非 watchlist 候选)、Follow-up 2(9 个非 watchlist 候选)、Re-engagement(8 个)今天一封都不发,全部留到明天。
- 发失败的不算钱:上面那 242 个尝试里,如果有 2 个客户刚好在这几分钟里自己回信了,系统会跳过这 2 个不发,也不会占用额度——会多试 2 个候选人补上,保证真正发出去的还是尽量接近该有的名额,不白白浪费。
- 手动发的也占用额度:如果 9 点之前你自己在 Preview Send Queue 手动点发了 50 封,9 点定时任务算出来的额度就是 250-50=200,不是满额 250——目的是保护 Brevo 一天的总发送量,不是只管"自动"发的那部分。
- 没发完的不会丢,明天接着排:Cold Restart 剩的 149 个、Follow-up 1 的 196 个,原样留在 Pending,不会消失也不会被重发。明天额度重置回 250,还是按同样的优先级顺序,先紧着上面几类,剩下的才轮到 Cold Restart 继续清。
- watchlist 的 14 个(9+4+1),不管开关和额度,永远不会被这套逻辑自动发——这是 watchlist 本身的设计目的,需要你自己去 Preview Send Queue 手动点 Send,Preview Send Queue 因此不会因为开关打开就完全清空,watchlist 的部分会一直留着等你处理。
- 测试客户不受影响:如果候选人是专门用来测试的"测试客户",不管开关开没开、额度够不够,系统都不会自动发,永远要手动点 Send。
generateFollowupQueue() 处理所有 status='pending' 的记录,不只是这次新生成的——这是故意的(为了让"额度用完留到明天接着发"这套机制成立)。但也意味着:如果重启前数据库里,某个类型的 Auto 开关已经是开着的,并且已经攒了一批 Pending(比如测试阶段手滑开过、忘了关),下次程序一跑起来(重启服务,或者到了每天 9 点),这批积压会立刻开始按新规则真实发送,不会等你再手动确认一次。接管当天启动新版 Stella 之前,务必先确认这6个 Auto 开关现在的真实状态——如果都是关的(H1 步骤6要求的状态),就没有这个风险;如果有任何一个意外开着,先想清楚要不要保留这批积压再继续。H2 · 正式接管后的历史数据 backfill 必须在 H1 完成之后才做
stellatest 影子实例上、对着生产库本体全部执行完成,执行方式跟本节原计划一致(临时路由、一次性触发、确认无误后删除)。跑完之后做过一次全面的最终核对(任务1+任务2+手动补的 Lifeblood 个案),逐项在 phpMyAdmin 里核对过真实结果,确认最终状态都正确。具体执行细节、过程中新发现的坑,见每个任务下面补充的"✅ 实际执行结果"说明。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 = 'contacted',cooling 不满足这条,她会被直接排除,不会因为"打开过邮件"而又收到一封 warm 跟进邮件,跟她已经婉拒这件事不矛盾。东方物流——他的状态自始至终一直是 contacted,"打开过邮件、还没发过 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天前)这个时间参数换成一个固定的起点since = 2026-04-23——效果是从 2026-04-23 一直查到脚本实际运行的那一天(不是只查 4 月 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 的额度一次性打满,影响到同一天其他实时功能(邮件分类、社媒生成等)。
为什么必须调用这个函数本身、不能自己重写——三条保护各举一个例子:
① contact_reset_at 保护,防止用旧联系人的拒绝信错杀新联系人——青山制造,3月的联系人 Tom 回信"不需要,别再联系了"(硬拒绝);6月爬虫在青山制造又抓到了新的联系人 Amy,系统给这家公司重新开了一轮(重置时间记在 contact_reset_at 里)。补录脚本翻到 3 月 Tom 那封拒绝信时,不能因为这封信就把"青山制造"整个公司标成不再联系——现在联系的是 Amy,Amy 什么都没拒绝过,不该被 Tom 三个月前的话连累。markClientReplied() 会先判断这封回信是在 contact_reset_at 之前还是之后,是之前的旧回信就不会用来误杀新联系人;自己重写逻辑很容易漏掉这一步。
② message-id 去重保护,防止同一封信被处理两次——补录脚本这次是一次性把几个月的旧邮件翻一遍处理掉,处理完之后系统还会照常继续巡检收件箱。如果漏了"记一个已处理标记"这一步,以后系统正常巡检万一又扫到同一封旧邮件,会误以为是新回信,把改状态、发通知那套逻辑再跑一遍——同一封信被处理两次,客户动态记录里会出现重复的两条。markClientReplied() 会用邮件自带的 message-id 记一条"处理过了"的标记,以后再扫到同一封信会自动跳过;自己重写脚本容易忘记打这个标记。
③ 清空 question_received_at,防止留下一个过时的"还在等回答"状态——客户之前问过一个问题(比如"价格怎么算"),系统记了一个"在等回答"的时间戳。后来客户又发来一封信把事情说清楚了(比如直接说不需要了),这个"等回答"的时间戳应该被清掉,不然界面会一直显示"客户还在等你回复问题",其实是个过时的假状态。markClientReplied() 处理新回信时会顺手清掉这类过时标记;自己单独写状态更新代码,很容易只改了状态字段,忘了这些连带的细节字段也要跟着清。
这三条都是"自己重写容易漏、用现成函数就自动不会漏"的坑,所以宁可让补录脚本调用这个函数本身,也不要照着逻辑抄一遍。
clients.status 会被正确更新到位。因为 cooling 状态有一条触发路径就是"客户回复了婉拒"(soft_rejection),这一步做完之后,那些原本应该进入冷却期的客户会自然跟着落进 cooling,re-engagement 的时间线也会跟着开始生效——不需要为 cooling / re-engagement 单独再做什么(原因见下方"为什么 warm/high_intent/cooling/re-engagement 不需要单独 backfill")。noreply@outhire.co)转发过来的,发件地址和 message-id 跟 Stella 实际发给 careers@redcrossblood.org.au 的那封邮件完全没有关联,常规的"按收件邮箱/按 message-id"匹配逻辑天然找不到这种情况。这种属于自动化覆盖不到的边界 case,不是 bug,人工确认是真实回复后,手动指定 client_id 跑了一次跟常规流程完全一样的处理链路(extractReplyBody→detectAutoReply→markClientReplied,没有另写一套特殊逻辑),结果正确写入。1) re-engagement 的冷却期时间线会比"理想情况"晚——如果今天(8月)才补上 5 月的一条历史回复,系统计算冷却到期日(
reengagement_date)是从"今天"往后推算的,不是从 5 月那个真实时间点往后推。这个没有完美解法,只能接受这个折中,不影响正确性,只是时间会晚一些。
2) 可能覆盖掉人工手动改过的状态——如果这几个月里你在界面上凭电话沟通等人工渠道,手动改过某个客户的状态,backfill 重放老邮件时有可能把这个手动修正覆盖掉,因为系统目前没有"这条是人工改的,不要自动覆盖"这种标记。建议 backfill 跑完之后,抽查一下你记得手动改过的那几个客户,确认状态没有被意外覆盖,需要的话手动改回来。
3) 4 月 23 日之前的极少数邮件——如果当时恰好撞上"③ 自动发信不建客户档案"那个窗口期(
client_id 先天缺失),现在因为已经加了兜底匹配(按回信邮箱直接查 clients.contact_email),backfill 理论上一样能正确接住,不需要额外处理,但值得在抽查阶段多留意一下这类特别早期的记录。
任务1 执行过程中顺带发现并修复的2个真实问题 2026-10
这两个不是另开的任务,而是在逐封核对那11封"自动匹配没认出来"的历史邮件时,顺带发现 checkInboxReplies()/analyseReplySentiment()/extractReplyBody() 本身也有缺口,当场一起修掉、一起跑进了 backfill:
| 问题 | 真实案例 | 修复 |
|---|---|---|
| ① 求职者自动回执被误判成客户回复 | Raiseit 那封回信内容其实是"感谢您的申请,我们会在未来几周内筛选……祝您求职顺利"——这是 Stella 发的开发信误投进了对方 ATS 系统的求职者收件箱,系统自动回的是"收到申请"这类话术,根本不是针对 Stella 这封开发信的回复,但当时没有专门的分类能识别这种情况,会被当成普通回复往下走分类流程 | 新增第5种回复分类 misdirected,专门识别"这是 ATS 自动回复求职者的,不是真人针对我们这封开发信的回复"这种情况,处理方式跟自动回复(isAutoReply)一样:只记日志,不改状态,避免被误判成软拒绝或硬拒绝 |
| ② 没有声明编码方式的邮件正文解析乱码 | Raiseit 这封回信的真实正文里埋着没有在邮件头声明编码方式的 =0D 这类转义残留(VTNZ 那封回信则是声明了 base64 但当时没解码),直接影响到①能不能正确读出回信的真实内容去做情绪判断 | extractPlainTextPart/extractReplyBody 补齐了三层解码:multipart 叶子节点声明了编码方式的按声明解码、非 multipart 兜底路径声明了编码方式的也按声明解码、新增一层"没声明任何编码方式,但正文里出现2次以上 =XX 这种转义残留特征"的频率判断兜底(专门应对像 Raiseit 这种发信方自己没声明编码、但正文里确实带着转义残留的情况) |
任务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]的标记前缀,方便以后区分这条是当时实时产生的,还是这次补录的。
email_log 里对应的 unsubscribed/bounced 原始记录对得上,没有发现异常。任务3 · brevo_message_id 格式修正(尖括号)2026-08-24 发现
跟上面两个任务的性质不太一样——不是"事件当时没被处理",而是"事件处理时认错了对象"。根因:Brevo 发信 API 返回、以及它自己后台展示的 Message ID,格式都带尖括号(比如 <202608240648.18253653303@smtp-relay.mailin.fr>),但 sendViaBrevo() 存进 email_log.brevo_message_id 时一直原样存的带尖括号的值;而 webhook 收到 Brevo 推送的打开/点击/退信事件时,会先把它带的 message-id 去掉尖括号再拿去数据库里查。两边格式对不上,WHERE brevo_message_id = ? 从来没有真正匹配成功过,每次都会静默退回到"这个邮箱最近发出的那一封"这个兜底方案。
大多数情况下这个兜底方案蒙对了结果(一个客户同一时间通常只有一封邮件在外,"最近发出的那封"碰巧就是对的),所以这个 bug 一直没被发现,直到 2026-08-24 测试"两封邮件同时在外"这类场景才真正暴露:两次打开事件都被记到了后发出的那封(Cold Restart)头上,先发出的 First Email 反而显示"从没打开过"。server.js 存库那一步已经改成存之前先去掉尖括号(不影响回复串联逻辑,那段代码本来就同时兼容带、不带尖括号两种输入),但这个改动只对代码上线之后新发的邮件生效,已经存在数据库里的旧记录格式不会自动被修正,需要单独跑一次。
- 确认这次改动过的
server.js已经部署、新进程已经在跑(跟本节步骤4一致)。 - 跑一次性 SQL,把已有的
brevo_message_id值批量去掉首尾尖括号:UPDATE email_log SET brevo_message_id = REPLACE(REPLACE(brevo_message_id, '<', ''), '>', '') WHERE brevo_message_id LIKE '<%>';
WHERE 条件保证已经是干净格式的行不会被重复处理)。跑完之后,任何正在进行中的真实客户(不管邮件是重启前发的还是重启后新发的),只要之后再发生打开/点击/退信事件,都能被正确匹配回真正触发这次事件的那一封邮件,而不再是"最近发出的那封"。brevo_message_id 的尖括号,没有报错。open_count/status 已经定型,这条 SQL 只解决格式问题,不会把已经写错的数据挪回正确的行。这类历史误记目前没有批量自动纠正的办法(Brevo 后台的 Email Activity 详情页可以按邮件逐条查到真实的打开/点击记录,需要的话只能个案对照手工修正)。多数客户不受影响——只有那些确实同时收到过不止一封在途邮件的客户,才可能撞上这个问题。执行顺序:这条 SQL 什么时候跑都可以,不依赖任务1/任务2,也不需要等它们完成,建议跟任务1/任务2一起,在 H1 完成之后一次性跑掉。
任务4 · 多联系人公司的 to_email 全链路追踪重构 2026-10 发现并修复
亲自去真实的 Preview Send Queue 里逐条看的时候发现的,不是靠查数据库发现的:Auckland Transport 出现了两条重复的 High Intent 排队邮件,同时好几家多联系人公司的称呼明显不对("Hi Dr"、"Hi me",或者称呼和实际收件邮箱对不上)。
根因:pickFollowupEmail(clientId, contactEmail) 解析的是"这家客户整体上该用哪个地址"(按互动强度:回复过 > 点击过 > 打开过,同等情况下按最近一次发信时间 tie-break),完全没有"这条跟进邮件具体是关于哪一封原始邮件"这个概念。Auckland Transport 真实有两个独立联系人(Rennie、Lynn),各自收到过自己的"第一封邮件"、各自点过自己那封的链接——但两个人都被这套逻辑解析成了同一个地址,其中一个人的互动记录被静默吞掉,完全没有任何报错或提示。
修复:给 followup_queue 加了 to_email 列,从候选查询到最终发送的整条链路都改成按这条跟进邮件具体对应哪一封原始邮件来追踪,不再是笼统的"这家公司的地址":
generateFollowupQueue()的候选查询,关联上lead_id拿到这条原始邮件对应的具体联系人数据;去重条件从"按client_id"改成"按(client_id, to_email)",两个联系人不会再互相顶替- INSERT
followup_queue时把to_email一起存下来,不再是发送那一刻才现算 sendFollowupQueueItem()找"上一封邮件"来拼 Subject/In-Reply-To 这些续接邮件串的信息时,按存下来的to_email精确限定,不再是不分青红皂白地挑这家公司最早的那一封- Preview Send Queue 接口、
redirectStalePendingToHighIntent()(点击链接后把排队邮件临时改成 High Intent 那条逻辑)、手动"重新生成"接口,全部同步改成优先用存下来的to_email,只有旧数据没有这个字段时才退回旧的pickFollowupEmail()兜底
lead_id 关联没问题"不等于"数据真的对",又往下挖了一层才找到真正根因(见下方任务5),两次真实数据验证都通过后才确认任务4完成。followup_queue 里躺着的旧记录,to_email 是空的,发送时还是会退回旧的 pickFollowupEmail() 逻辑。受影响的多联系人公司,需要手动 DELETE FROM followup_queue WHERE client_id = ? AND status = 'pending' 清掉旧记录,再触发一次 generateFollowupQueue() 重新生成,才能用上新逻辑——这一步已经在验证过程中对 Auckland Transport 等几家公司做过,大规模的一次性清理见下方任务5末尾的"942条全部重新生成"。任务5 · 邮件称呼(问候语)解析全面重构 2026-10 发现并修复,范围最大的一次
起因同样是亲自翻 Preview Send Queue 发现的,不是靠自动化测试发现的:陆续看到 Wellington City Mission"Hi Applications"、Sanitarium"Hi Talent"、Hierarchy Group"Hi Abby"、AFFCO"Hi Lucas"(收件地址明明是 brett.innes@)这类明显不对或者可疑的称呼。这是这一轮篇幅最长、反复最多的一次修复,这里只记最终结论和路上几个真正关键的发现,不复述每一轮排查细节。
· 排查路上走过的弯路(有意义的弯路,不是浪费时间)
- 第一反应——扩充黑名单:一开始思路是"遇到一个垃圾词就加一个"(
talent/acquisition/applications/human/resources/careers/cv/distribution/fabrication/managing/people/senior/supervisor等等),这套办法能堵住已经发现的具体案例,但本质是在打地鼠,发现一个修一个,不成体系。 - 公司名误当人名——"Hawkeswood Mining, Careers":发现光靠黑名单堵不住"contact_person 字段里直接写进了公司名本身"这类情况(比如这条真实数据,"Hawkeswood"被当成人名提取出来,因为它确实首字母大写、不在任何黑名单里)。加了一道结构性检查:候选词如果等于这家公司自己名字的首词,直接判定不是人名——比继续堵词更一次性。
- 升级成"行业标准的共享邮箱检测":查证了邮件验证行业本身怎么处理这类问题(ZeroBounce 等工具的"role-based email detection"),确认"维护一份已知角色前缀词表、逐段比对邮箱前缀"是业内标准做法,不是自己瞎想的;原来只看"邮箱前缀有没有分隔符"的简单判断换成了这套更可靠的词表比对,顺带堵住了
no-reply@、customer-service@这类带连字符但其实是共享邮箱的地址。
· 真正的转折点:contact_person 这个数据源本身不可信
用真实数据核实 AFFCO / Fresh Meats NZ / Papamoa Dental 这三条"称呼明显不对"的记录时发现:不是关联错了联系人,是同一条 lead 记录内部,姓名字段和邮箱字段从一开始就没对上——专门加了一个诊断字段去核实"这条 lead 自己的邮箱是不是真的等于实际收件地址",三条全部显示"是"(lead_email_matches_to_email: true),但姓名完全是另一个人(比如 lead #55583,contact_person="Lucas Bird",contact_email="brett.innes@affco.co.nz",这两个字段压根不是同一个人)。这说明问题出在最早的抓取/建档环节,不是查询关联的问题——就算关联链路完全正确,contact_person 这段抓取来的文本本身也可能是错的,靠"多核实一层关联"堵不住这类问题。
contact_person 这段抓取文本来生成称呼,只信收件地址本身——邮箱是共享/角色邮箱(命中角色词表) → 统一 "there";邮箱是"名.姓"这种个人格式、而且能解析出一个像样的名字 → 用这个名字;解析不出来(比如首字母缩写、部门代码、称谓词)→ 同样 "there"。这套逻辑统一应用到全部四处生成称呼的地方:首次冷邮件(buildEmailPrompt)、跟进队列生成与发送(resolveGreetingName)、重新激活邮件(直接固定 "there",不再有单联系人例外)、客户详情页的"Follow-up Draft"预览面板(predictFollowupType)——四处口径完全一致,不会出现"预览显示一个称呼、真实发送又是另一个"的情况。从收件地址解析名字本身,路上也找到并修了几个真实漏洞(全部是拿真实数据测出来的,不是凭空想的边界情况):
| 真实案例 | 问题 | 修复 |
|---|---|---|
| executive-recruitment@ffa.int → "Hi Executive" | 审计方法本身有盲区——只对比"称呼 vs 邮箱推断的名字"是否一致,当两边都错成同一个词时查不出来 | "executive"等职位词补进黑名单,同时按"职位/部门/招聘"这整个语义类别系统性列全,不再零散补词 |
| MR.T.THEBAKER@gmail.com → "Hi Mr" | 从 contact_person 文本提取名字时本来就会跳过"Mr/Dr"这类称谓,但从邮箱地址提取名字的那条代码路径漏了这个检查 | 邮箱解析也补上同样的称谓跳过逻辑;跳过称谓后如果剩下的部分太短(比如这个案例剩下的"T"),直接判定解析不出来 |
| NA.TA.Operations@averydennison.com → "Hi Na" | "NA"是地区/部门代码(North America),不是人名——真人的个人邮箱前缀几乎总是全小写,只有系统生成的代码类前缀才会保持原始大写 | 邮箱原文中某一段本来就是全大写、且长度≤3,判定为代码缩写,不当人名处理 |
| bd.mckee@outlook.co.nz → "Hi Bd" | "bd"读起来跟"姓名首字母缩写.姓"(B.D. McKee)一样合理,没有办法从这两个字母本身区分它是缩写还是真的短名字 | 能接受的最短长度从2位提到3位——代价是连带会把 Jo/Bo/El 这类本来可能是对的真实短名字也归到 "there",按"拿不准就 there"的原则,这个代价可以接受 |
followup_queue 里已经生成的 942 条待发记录,body 里存的是生成当时就定好的称呼(不是发送那一刻现渲染的),所以旧的错误称呼会一直原样躺在里面,直到被发送或者过期——不会因为代码改了而自动变好。全部 942 条已经清空、用最新逻辑重新生成过一遍。/api/admin/audit-followup-greetings 和 /api/admin/regenerate-and-audit-greetings,属于临时诊断用途,应在确认不再需要时一并删除(见"测试结束后记得复位")。为什么 warm / high_intent / cold_restart / re-engagement 不需要单独 backfill
这几类不是存起来的历史记录,而是 generateFollowupQueue() 每次运行时,根据 email_log 当前的 open_count / status,加上"距离首封邮件过了几天"现场算出来的,不依赖"当时是否被实时处理过"。只要满足两个条件:底层的 email_log.open_count / status 数据本身是对的(已确认——这块走的是 Brevo 的 opened/click webhook 事件,一直在正常记录,不受收件箱配错这个问题影响;但"正常记录"指的是事件本身没有被漏记,不代表一定记在了正确的那一封邮件上——如果某个客户当时恰好有两封邮件同时在外,可能撞上上面任务3那个 message-id 格式问题,记错了行,具体局限见任务3的说明),以及代码上线后 autopilot 的每小时 / 每日定时任务正常跑起来——那么任何当前真实符合 warm / high_intent / cold_restart 条件的客户,下一次定时任务运行时就会被自动、正确地捕捉到,不需要额外写脚本去补历史。re-engagement 同理,是靠 cooling.reengagement_date 到期后自动触发的,只要 cooling 状态被任务1正确同步了,re-engagement 会自然跟上,不需要单独处理。
H3 · 清理垃圾公司名 2026-08-28 发现
根因:crawler.js 抓 TradeMe 职位时,如果页面上专门放公司名的 CSS 选择器(.jb-listing__company-name 等)没抓到内容,会退而求其次,用一段正则从页面 meta 描述文字里"猜"一个公司名出来(crawler.js 里 jobData.meta_desc 那段兜底逻辑)。这段正则不设长度上限,遇到叙述性的长段描述文字,可能把一整句、甚至好几句话都当成"公司名"截下来,直接存进了 clients.company_name——不只是显示问题,这些"公司"会被当成正常线索走完整套自动化流程,已确认至少一条已经被真实发过一封跟进邮件。
测试库里当时已确认的两条真实例子:id 33529("Based in Twizel or Oamaru [换行] About the role [换行] Thi...",status=new,从未联系过)、id 19789("About the Role I am a 32-year-old woman who leads...",status=contacted,已经真发过一封跟进邮件)。代码层面的修复见下方"顺便:crawler.js 怎么改比较保险",这里只处理已经进了库的历史脏数据。
73608(从未联系过,按"第一种处理方式"直接删除)、5971(已经真发过邮件;但人工核对发现这条跟 19789 情况不同——这条的原始职位页面其实能找回真实公司名,不是彻底的垃圾数据,所以没有走原计划的两种处理方式,而是直接把 company_name 改成了正确的 "Bendigo Terrace Farming",比删除或停用更合适,算是执行过程中补出来的"第三种处理方式")、19789(到真正执行这一步时,发现 status 已经正确是 not_interested 了,不需要再额外处理——大概率是因为这条记录的真实订阅/退信事件已经被上面任务2的重新同步正确捕捉到,H3 执行时它已经不再是问题数据)。LENGTH(company_name) > 45 初筛,实际在测试库跑出19条结果,人工核对后只有2条是真垃圾,另外17条全是长度巧合超过45字符的真实公司名(比如 "Advanced Accounting & Business Advisory Limited"、"Wilson Contracting Ltd and Smiths Transport Ltd" 这种)。纯靠长度判断误伤率太高,不能作为筛选依据,下面改成按内容关键词识别。第一步:识别,只读不改
两条真垃圾数据都包含 "about the role" 这个词组(大小写不一),这是 TradeMe 职位描述页常见的标题用语,正常公司名几乎不可能出现这个短语;33529 那条另外还能看出里面混进了真实的换行符(\n),公司名同样不该出现换行。用这两个信号一起筛,比单纯看长度精确得多:
SELECT id, company_name, status, source, created_at FROM clients WHERE source = 'TradeMe' AND (LOWER(company_name) LIKE '%about the role%' OR company_name LIKE '%\n%') ORDER BY created_at DESC;
这依然只是初筛,不是最终判定,跑完必须人工过一遍结果——如果这次搜出来的除了已确认的33529、19789之外没有别的,就只处理这两条即可,不需要额外操作。
第二步:按有没有真实发过邮件分两种处理
不建议无差别一律 DELETE:如果这条垃圾"客户"已经有过真实的 email_log/followup_queue/client_activities 记录(比如已经真发过一封邮件),删掉客户本身会让这些记录变成指向不存在客户的孤儿数据,也会抹掉"确实发过一封邮件"这个本该保留的事实。已确认的这两条正好一边一个例子:
- 33529(从未联系过)——直接删除,加了保险条件,万一它其实有历史记录就不会被误删:
DELETE FROM clients WHERE id = 33529 AND id NOT IN (SELECT DISTINCT client_id FROM email_log WHERE client_id IS NOT NULL) AND id NOT IN (SELECT DISTINCT client_id FROM followup_queue WHERE client_id IS NOT NULL) AND id NOT IN (SELECT DISTINCT client_id FROM client_activities WHERE client_id IS NOT NULL);
- 19789(已经真发过邮件)——保留客户行,只是让它彻底停止参与任何自动化,先撤掉还在排队没发的,再把状态改成
not_interested(generateFollowupQueue之类的候选人查询都要求status = 'contacted',改完以后再也不会选中它),email_log/client_activities的历史记录原样保留:UPDATE followup_queue SET status = 'skipped' WHERE status = 'pending' AND client_id = 19789; UPDATE clients SET status = 'not_interested' WHERE id = 19789;
如果第一步的搜索结果里还有别的记录,人工确认是真垃圾之后,把上面两条 SQL 里的具体 id 换成对应的 id、按"有没有真实记录"套用同样的两种处理方式即可。
crawler.js 的代码已经修好了 2026-08-28
最早想的方案是"猜不到就老实存 'Unknown (via TradeMe)'",但这个想法有个漏洞:跟进邮件模板渲染那段代码(renderFollowupTemplate)本来就有 companyName || 'your company' 这种兜底,但这个兜底只在 companyName 真的是空值时才生效——'Unknown (via TradeMe)' 是个非空字符串,兜底不会触发,模板会把这串占位文字原样塞进邮件正文,比刚才发现的问题更离谱。改用了更干净的方案:公司名抓不到就直接不生成这条线索,跟其他来源(Seek/Indeed 等,本来就有 .filter(j => j.job_title && j.company_name))保持一致,不再单独给 TradeMe 硬凑一个公司名。没有真实公司名的线索本来就没法正常发信,与其到处打补丁处理"公司名是占位符"这种边界情况,不如从源头不让它进 b2b_leads/clients 表:
if (jobData.job_title && jobData.company_name) {
const tmContactInfo = extractContactInfo(jobData.descText || '');
trademeJobs.push({
job_title: jobData.job_title,
job_url: jobUrl,
company_name: jobData.company_name,
contact_person: jobData.contact_person || tmContactInfo.contactName || '',
contact_email: tmContactInfo.email || '',
contact_phone: tmContactInfo.phone || '',
location: jobData.location || ''
});
}
这条改动只影响以后新抓的数据,不会自动清理已经在库里的历史脏数据,历史部分还是要靠上面第一、二步手动跑一次。
H1-H3 全部任务确认完成、正式开关打开之后,要删掉的临时内容 2026-10
以下都是为了这一轮接管测试临时加进 server.js 的诊断/补录用接口,不是长期要保留的功能,确认不再需要之后应该整批清掉,不要带进长期生产代码:
| 接口 | 用途 | 所属任务 |
|---|---|---|
/api/admin/backfill-task1 | H2 任务1,回复情绪分类 backfill | 任务1 |
/api/admin/backfill-task2 | H2 任务2,Not Interested / Bounced 状态重新同步 | 任务2 |
/api/admin/backfill-manual-match | 手动指定 client_id,补录自动匹配抓不到的个案(比如 Lifeblood 那种第三方中转回信) | 任务1附带 |
/api/admin/run-real-followup-queue-now | 手动立即触发一次真实的 generateFollowupQueue(),不用等下一次整点/每日定时任务 | 任务4/5 验证用 |
/api/admin/audit-followup-greetings | 按称呼词聚合审计当前 followup_queue 里有没有可疑称呼,支持 ?word= 按词查明细 | 任务5 |
/api/admin/regenerate-and-audit-greetings | 清空 pending 队列 + 用最新逻辑重新生成 + 再审计一次,合并成一条命令 | 任务5 |
任务3(brevo_message_id 格式修正)和 H3 本身的脏数据清理,用的是直接在 phpMyAdmin 里手动跑的一次性 SQL,没有加临时接口,不需要额外清理代码;cleanup_garbled_contact_person.js(contact_person 乱码清理)是独立的维护脚本,不是 server.js 里的路由,留在项目目录里不影响什么,不需要跟上面这些接口一起删。
requireAuth 保护的只读/一次性操作,留着多观察几天没有额外风险。设置项 ↔ 代码对照表
| 界面字段 | 设置键名 | 默认值 | 被谁读取 |
|---|---|---|---|
| Daily Send Limit | autopilot_daily_limit | 20 | autopilotAutoSend |
| Follow-up Daily Limit 新增 | autopilot_followup_daily_limit | 250(最高 300) | generateFollowupQueue 结尾的「Capped, priority-ordered auto-send pass」——6 类 Follow-up 共用这一个每日上限,按 High Intent → Warm → Cold Restart → Follow-up 1 → Follow-up 2 → Re-engagement 顺序发放,跟上面的 Daily Send Limit(只管 First Email)完全独立 |
| Followup 1 after (days) | behavior_followup1_days | 14 | generateFollowupQueue 独家读取——2026-08-22 起含义是"Warm 发出后再等多久没回复才发 Followup 1",不再是"First Email 之后"。2026-08-24 起 Cold Restart 那条零打开路径原来由单独的 autopilotFollowUp() 直发处理、完全不读这个设置;那条路径已经下线,现在 Cold Restart→Followup2 也统一归 generateFollowupQueue 管,跟这个设置一样都不涉及(Cold Restart 直接进 Followup2,不经过 Followup 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 | 12026-08-22 起从2改成1 | generateFollowupQueue——打开次数≥这个值就进 Warm;Followup 1 不再是"打开次数=1"单独触发的分支,改成 Warm 发出后的下一步(见 A2 场景) |
| Warm send after (days) | behavior_warm_send_days | 3 | generateFollowupQueue |
| High Intent after (days, no reply) | behavior_high_intent_days | 7 | generateFollowupQueue |
| Cycle 1 cooldown (days) 改名,2026-08-24 起去掉"after rejection" | reengagement_cooldown_days | 60 | getCooldownDaysForCycle,仅第1轮冷却适用——原来的名字"Cooldown after rejection"不准确,进 cooling 的原因不只是拒绝,没回复(no_response)也会进 |
| 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) 这个共用函数,读取上面这几个设置项,几处用到冷却天数的代码都改成调用它,不再各自写一份——包括 sendFollowupQueueItem 的 Followup2/Re-engagement 收尾、autopilotRecycleReengagements(原名 autopilotFollowUp,2026-08-24 精简后现在只剩 cycle 2+ 收尾这一处)、以及 /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 场景里为了测试临时建的客户/线索记录。
- 2026-10 新增 删除 H2 任务1-5 过程中加进
server.js的临时诊断/补录接口(/api/admin/backfill-task1、/api/admin/backfill-task2、/api/admin/backfill-manual-match、/api/admin/run-real-followup-queue-now、/api/admin/audit-followup-greetings、/api/admin/regenerate-and-audit-greetings等),确认不再需要之后统一清掉,不要把一次性诊断路由带进长期生产代码。 - 确认没有遗留问题后,再手动打开 Auto Followup 1 / Auto Followup 2 / Auto Send New Emails / Auto Cold Restart / Auto Warm / Auto High Intent / Auto Reengagement 用于正式环境——测试期间这7个必须一直是关着的(见"规则4")。
清理 contact_person 乱码数据 一次性维护脚本,非常规测试场景
这不是一个要跑的测试场景,而是配合"规则5"里提到的另一处修复(2026-08-04)留下的一次性收尾工作,记在这里方便以后查。
背景:为什么会有乱码
upsertClient() 里合并联系人信息的逻辑,之前是"只要抓到新的联系人名字就往后追加",不会替换掉旧的。同一家公司只要被 Seek/Indeed/TradeMe/SJS 反复抓到(招聘信息本来就会重复出现),contact_person 就会不断变长,变成一长串逗号拼接的碎片——生产库里查到最长的一条超过 4500 字符。同一天还修了 extractContactInfo() 里人名抓取的正则本身(触发词大小写不敏感、人名部分强制要求真大写,避免把 "at"、"on" 这类词也当成人名的一部分抓进去,详见代码里的注释)。
修复之后:upsertClient() 改成"只保留最新一次抓到的名字,直接替换旧值",不再无限追加——contact_person 唯一的用途是邮件称呼(isLikelyName() 本来就会在名字不像真名时兜底显示 "there"),保留一个可能猜错的名字没问题,但让它无限累积成一段乱码不行。但这个修复只能防止以后再变乱,已经存在的乱码数据不会自动被清掉,所以需要跑一次一次性清理。
脚本做什么 — cleanup_garbled_contact_person.js(项目根目录)
连接数据库的方式和 server.js 完全一样(自动识别 DB_SOCKET_PATH:生产环境走 socket,本地走 host/port),所以不管在本地还是生产服务器上跑,只要用的是当前环境自己的 .env,就能连到对应的库,不需要额外配置。
逻辑很简单:找出 clients.contact_person 长度超过 60 字符的所有记录(真实的"名 姓"基本不可能超过这个长度),默认只打印报告、不改数据;加上 --apply 参数才会真正把这些记录的 contact_person 清空。清空之后不用管——下次这家公司被爬虫重新抓到,新的替换逻辑会自动填回一条干净的名字;一直没被重新抓到就一直空着,邮件称呼会兜底显示 "there",不影响发送。
怎么在生产服务器上跑
- 把
cleanup_garbled_contact_person.js上传到生产服务器的项目目录(跟server.js同一个目录),用你平时更新server.js的同一套方式(cPanel File Manager / FTP 等)。 - 进 cPanel → Terminal,
cd到项目目录。 - 先跑 dry-run,只看报告,不改任何数据:
node cleanup_garbled_contact_person.js
- 看一遍打印出来的清单(公司名 + 乱码内容预览),确认这些确实是垃圾数据,不是巧合的合法长文本。
- 确认没问题后,加上
--apply再跑一次,这次才会真正清空:node cleanup_garbled_contact_person.js --apply
--apply 在生产库执行:脚本输出确认清空了 397 条记录(跟 8 月那次 dry-run 的 396 条数字对不上是正常的——中间隔了两个月,这期间爬虫又抓到了新的乱码数据,多出的1条在预期之内)。contact_person 这一个字段,不碰其他任何字段,也不会删除客户记录本身——不影响状态流转、跟进队列或已发邮件历史。