Flow 1: Lead Tracking

Repo: lead-tracking(独立 repo + CDK stack) · 触发: EventBridge 每 5 分钟 IMAP 轮询 · 架构: 3 Lambda(poller leads-only + neon-retry-processor DLQ 重试 + lead-processor contacts/tasks/timeline)· Neon 写入: poller prod/test 双写,retry path 只重试 prod

::: details 字段命名约定 accountId = RingCentral OAuth providerAccountId,对应 Neon 列 account_id(callytics-common 0.21.0 起从 siteId rename)。下文遗留的 siteId 字眼按 accountId 理解。storeId 是 store-level isolation 的唯一隔离键(UUID),accountId 仅作审计。

:::

数据流概览

不调用 AI,所有值来自 email 解析 [EMAIL] 或系统硬编码 [SYSTEM]

  EventBridge 5min


  ┌─────────────────────────────────────────────────────┐
  │  Poller Lambda (src/poller.ts)                      │
  │                                                     │
  │  IMAP 轮询 → Email lead 数据 [EMAIL]                │
  │  StoresV2 mapping → franchiseId/accountId/storeId   │
  │                                                     │
  │  ┌──────────────────────────────────┐               │
  │  │ [DDB] 过渡期 — 将来整块删除     │               │
  │  │   PutCommand leads              │               │
  │  │   QueryCommand phone dedup      │               │
  │  └──────────────────────────────────┘               │
  │                                                     │
  │  ① UPSERT leads (prod Neon) — 非阻塞                │
  │     ├─ 成功 → ② 发 LeadCreated                       │
  │     └─ 失败 → circuit breaker + 进 SQS DLQ           │
  │                                                     │
  │  ① UPSERT leads (test Neon) — fire-and-forget       │
  │                                                     │
  │  ② 发布 EventBridge LeadCreated 事件                 │
  │     前置条件: phone + franchiseId + accountId + storeId │
  │     缺任一 → 跳过, 只写 leads                        │
  └────────┬───────────────────────┬────────────────────┘
           │                       │
           │ ① 失败 走 DLQ          │ ② 成功 走 EventBridge
           ▼                       │ source='lead-tracking'
  ┌──────────────────┐             │ detail-type='LeadCreated'
  │ neon-write-dlq   │             │
  │ SQS, retain 7d   │             │
  │ visibility 2min  │             │
  └────────┬─────────┘             │
           │                       │
           ▼                       │
  ┌─────────────────────────────┐  │
  │ NeonRetryProcessor Lambda   │  │
  │ (src/neon-retry-processor)  │  │
  │ batchSize=5                 │  │
  │                             │  │
  │ ① 重试 persistLeadPipeline   │  │
  │   ├─ 失败 → 抛错 → SQS redrive │
  │   └─ 成功 → ② 发 LeadCreated  │  │
  │                             │  │
  │ ② 发 EventBridge LeadCreated │  │
  │   ├─ FailedEntryCount>0     │  │
  │   │  → 抛错 → SQS redrive    │  │
  │   └─ 成功 → 删除 SQS message  │  │
  │                             │  │
  │ maxReceiveCount=3 → 进终态 DLQ │
  └──────────┬──────────────────┘  │
             │ 3 次失败              │
             ▼                      │
  ┌──────────────────┐              │
  │ neon-write-      │              │
  │ final-dlq        │              │
  │ retain 14d       │              │
  │ 人工调查          │              │
  └──────────────────┘              │

                            ┌───────────────┐
                            │  SQS Queue    │
                            │ (lead-processor│
                            │  -queue)      │
                            │  batchSize=1  │
                            │  maxRetry=3   │──► DLQ (14天)
                            └───────┬───────┘


  ┌─────────────────────────────────────────────────────┐
  │  Lead Processor Lambda (src/lead-processor.ts)      │
  │  callytics-infrastructure repo, us-west-2           │
  │                                                     │
  │  db.batch() — 1 次 HTTP,原子:                       │
  │                                                     │
  │  ③ UPSERT contacts (轻量)                           │
  │     lifecycle='lead' · leadStatus='new'             │
  │     ON CONFLICT (phone, storeId): 不覆盖 lifecycle  │
  │                                                     │
  │  ④ INSERT tasks                                     │
  │     lead_outreach · high · dueAt=SLA(5min)          │
  │     ON CONFLICT DO NOTHING(dedup)                  │
  │                                                     │
  │  ⑤ INSERT timeline                                  │
  │     lead.created · occurredAt=receivedAt            │
  │     ON CONFLICT DO NOTHING (idempotencyKey)         │
  │                                                     │
  │  env Neon — 阻塞(失败 → SQS 重试)                 │
  └─────────────────────────────────────────────────────┘

3 Lambda 拆分理由:

  • Poller 和 lead-processor 拆分:leads 写入失败(circuit breaker + DLQ)和 downstream 写入失败(SQS retry)解耦,避免互相阻塞。
  • NeonRetryProcessor 拆分:poller 走 fail-open 不阻塞 IMAP SEEN 标记;真正的 Neon 重试由独立 Lambda 异步消费 DLQ,降低 poller 延迟和复杂度。
  • retry path 和 happy path 必须发同一份 LeadCreated 事件(issue #201),否则 DLQ 救回的 leads 永远没有下游 contacts/tasks/timeline。

错误处理

Poller(src/poller.ts)

阶段失败时行为重试机制
DDB PutCommand抛错 → email 不 mark SEEN下次 5min poll 重拉同一封 email
leads UPSERT (prod Neon)catch → 进 SQS neon-write-dlq, email 仍 mark SEENNeonRetryProcessor 异步消费
Circuit breaker 触发连续 5 次失败 → 暂停 15 分钟,期间所有 Neon write 直接 → DLQ暂停期满 + 单次成功 → reset counter
EventBridge publish (happy path)catch → 进 SQS neon-write-dlq, email 仍 mark SEENNeonRetryProcessor 异步重发事件; leads UPSERT 幂等
Test Neoncatch → console.warn不重试,不影响 prod

NeonRetryProcessor(src/neon-retry-processor.ts)— 本节由 issue #201 引入

阶段失败时行为重试机制
persistLeadPipeline 重试RetryStageError(stage='neon') → SQS redrivemaxReceiveCount=3 → 进 neon-write-final-dlq(14d 保留)
EventBridge publishRetryStageError(stage='eventbridge') → SQS redrive同上
PutEvents 返回 FailedEntryCount > 0显式检测后抛错 → SQS redriveAWS SDK 在 200 OK + per-entry failure 时不抛,必须显式检查,否则消息被删除 = 静默丢失事件
字段缺失(phone/franchiseId/accountId/storeId)publish 跳过(logger.info)+ retry 不算失败不重试

下游 lead-processor(callytics-infrastructure)

阶段失败时行为重试机制
③④⑤ batchthrow → SQS 重试最多 3 次,之后进 lead-processor-dlq(14d)

Duplicate LeadCreated 风险与下游 idempotency

NeonRetryProcessor 写 Neon 成功后再发 LeadCreated。若 EventBridge publish 失败 → 整条 SQS 消息 redrive → 第二次执行 persistLeadPipeline + 重发 LeadCreated → 下游可能收到 2 次同一 lead 的 LeadCreated

下游 lead-processor 靠 3 个 idempotency 抗住:

  • contacts ON CONFLICT (phone, storeId) DO UPDATE — UPSERT 幂等
  • tasks ON CONFLICT (sourceLeadId) DO NOTHING(uq_tasks_source_lead)— 同 lead 不创建第 2 个 task
  • contact_timeline ON CONFLICT (idempotencyKey) DO NOTHING — lead.created:${row.id} 作为 key

persistLeadPipeline 自己也是 idempotent(leads ON CONFLICT DO UPDATE),双写主要影响 syncedAt bump,无业务副作用。


读取数据

来源内容备注
IMAP 轮询Email lead 原始数据EventBridge 每 5 分钟触发
StoresV2 DynamoDBleadEmail → franchiseId/siteId/storeId冷启动 Scan,warm start 复用缓存

Step ①:UPSERT leads

Poller Lambda 执行 Drizzle upsert,PK 是 composite dedup key({email}#{phone}#{date}{email}#{receivedAt})。

INSERT 时写入

字段说明
idcomposite dedup keyPK,防 IMAP 重复轮询
leadEmailemail 发件人
leadTypeemail 解析Web Lead / Online Intro 等
firstNameemail body 解析(可空)
lastNameemail body 解析(可空)
phoneemail body 解析(可空)标准化为 E.164 格式(+1XXXXXXXXXX)
bookedDateemail body 解析(可空)已预约日期
bookedTimeemail body 解析(可空)
emailSubject原始 subject
emailFrom发件人地址
emailRecipient收件人地址
extractedTrackingId从收件人地址提取lead-tracking+{ID}@... → 映射 site
franchiseIdStoresV2 → parseBrandFromFranchise()解析 brand 名(如 "orangeTheory"),不含 store code
siteIdStoresV2.providerAccountIdRC 账户 ID
storeIdStoresV2.storeId (UUID)唯一隔离键(Store-Level Isolation)
rawBody完整 email body调试用
isForwardedBoolean,默认 false是否转发 email
forwardedOriginalFrom原始发件人(可空)转发 email 时
forwardedOriginalTo原始收件人(可空)转发 email 时
forwardedOriginalDate原始日期(可空)转发 email 时
processedBy'imap-poller'
receivedAtemail 接收时间
syncedAtdefaultNow()

ON CONFLICT (id) DO UPDATE

字段说明
leadType更新email 可能重新分类
franchiseId更新StoresV2 映射可能修正
siteId更新同上
storeIdCOALESCE(leads.store_id, EXCLUDED.store_id)Forward-only:不用 NULL 覆盖已有值
syncedAtNOW()

Step ②:发布 EventBridge LeadCreated 事件

leads UPSERT 成功后由 poller 发布。Event payload: {Source: 'lead-tracking', DetailType: 'LeadCreated', Detail: <LeadRow JSON>}

前置条件(全部非 null 才发布):

字段来源为什么需要
phoneemail 解析contacts PK 的一部分
franchiseIdStoresV2 映射tasks/审计需要
accountIdStoresV2 映射(RC OAuth providerAccountId)tasks/审计需要
storeIdStoresV2 映射(UUID)contacts PK 的一部分,store-level isolation 唯一隔离键

缺任一字段 → 跳过 EventBridge 发布,只写 leads 表,log [EVENTBRIDGE] Skipping — missing required fields

CDK 路由:LeadCreatedRule-${region} Rule → SQS → Lead Processor Lambda。


Step ③:UPSERT contacts(轻量)

确保 contact 记录存在 + 设置初始状态。Lead Processor 在 db.batch() 内与 ④⑤ 原子执行。

INSERT 时写入

字段说明
phonelead.phone复合 PK
storeIdlead.storeId复合 PK(Store-Level Isolation 终态)
franchiseIdlead.franchiseId
accountIdlead.accountIdRC providerAccountId(审计字段)
lifecycleStage'lead'标记为 lead
leadStatus'new'新 lead 初始状态。Contact Analysis 后续覆盖
firstNamelead.firstName(可空)Email 解析出的名字
lastNamelead.lastName(可空)同上
lastActivityAtlead.receivedAt用 lead 接收时间,不是 NOW()(防 Lambda 执行时间漂移)

ON CONFLICT (phone, storeId) DO UPDATE

ON CONFLICT 只补写身份和 activity 字段,不写 leadStatuslifecycleStage(这两个已有 contact 的字段由 Contact Analysis 管理)。

字段说明
lastActivityAtlastActivityAtForward(lead.receivedAt)forward-only 语义(COALESCE(GREATEST(existing, eventTime), eventTime)),乱序 lead 不能把已有 lastActivity 倒推。共享 helper 跨 5 个 contacts writer 一致
firstNameCOALESCE(contacts.first_name, lead.firstName)只补写,不覆盖已有值
lastNameCOALESCE(contacts.last_name, lead.lastName)同上
updatedAtnow(lead-processor 当前时间)

不写的字段

字段为什么不写
acquiredAt已移除,timeline lead.createdoccurredAt 已记录
leadTemperature已移除,是 NOW() - 到达时间 的派生值,timeline 已记录到达时间
customerSummary / purchaseIntent / goals 等 AI 字段Contact Analysis 负责
leadStatusReasonContact Analysis 负责
firstAttemptedAt / firstConnectedAtPer-Call Analysis 负责(通话事件,不是 lead 到达)

Step ④:INSERT tasks

Lead Processor 在 db.batch() 内与 ③⑤ 原子执行,创建 1 条 high-priority lead_outreach task。

字段说明
taskIdcrypto.randomUUID()客户端预生成
contactPhonelead.phone通过 buildContactPhoneIdentityFields() 写入 4-field 身份块
franchiseIdlead.franchiseId同上 helper
accountIdlead.accountId同上 helper
storeIdlead.storeId(已 guard 非空)同上 helper
taskType'lead_outreach'固定
typeCategory'lead_outreach'固定
sourceType'lead'
sourceLeadIdlead.id幂等 key
status'pending'
priority'high'固定
actionNeededtrue
actionNeededReasonNew ${leadType} from ${firstName || 'unknown'} ${lastName || ''}(trim 后)动态生成
suggestedActionsjsonb 模板1 项 high-priority action,提示员工"5 分钟黄金窗口内回拨新 lead"
dueAtcomputeDueAt('high', now, { slaMinutes: 5 })排除 quiet hours

去重保护(ON CONFLICT DO NOTHING)

撞任一 index 安全跳过,不报错。

Index保护场景
uq_tasks_source_lead (sourceLeadId)同一封 email 的 SQS 重试
uq_tasks_pending_contact_category (contactPhone, franchiseId, siteId, typeCategory) WHERE status='pending'同一 contact 已有 pending lead_outreach

Step ⑤:INSERT contact_timeline

Lead Processor 在 db.batch() 内与 ③④ 原子执行。所有 timeline writer 通过共享 buildTimelineValues() helper(@retaintive/common/db)写入,统一字段约定。

字段
contactPhonelead.phone
franchiseIdlead.franchiseId
accountIdlead.accountId
storeIdlead.storeId
eventType'lead.created'
eventCategory'lead'
entityType'lead'
entityIdlead.id
occurredAtlead.receivedAt(源记录时间, NOW())
actorType'lead_webhook'
newValue{leadType, firstName, lastName} jsonb
idempotencyKeylead.created:${lead.id}

ON CONFLICT DO NOTHING 通过 idempotencyKey 去重,SQS 重试幂等。


CDK 基础设施

资源定义在 lib/leadtracking-stack.ts(lead-tracking repo)和 lib/stacks/sqs-stack.ts / eventbridge-stack.ts(callytics-infrastructure repo)。

lead-tracking repo(us-east-1)

资源名称说明
LambdaImapPollerFunction-${region}IMAP 轮询 + leads 写入 + EventBridge 发布
LambdaNeonRetryProcessor-${region}消费 neon-write-dlq, 重试 persistLeadPipeline + 发 LeadCreated (#201 修复)
EventBridge RuleImapPollerSchedule-${region}每 5 分钟触发 poller
EventBridge RuleLeadTrackingForwarding-${region}LeadCreated 跨区转发 us-east-1 → us-west-2 default bus
SQS Queueneon-write-dlq-${region}poller 失败入口, retention=7d, visibility=2min, redrive 到 final-dlq
SQS Queueneon-write-final-dlq-${region}retention=14d, 人工调查
DynamoDBLeadTracking-v2-${region}过渡期主表,将来移除
IAMevents:PutEvents on default bus同时授给 poller 和 NeonRetryProcessor — 后者缺这条会 AccessDenied(#201 修复一并补)

callytics-infrastructure repo(us-west-2)

资源名称说明
LambdaLeadProcessorFunction-${region}SQS 消费 → contacts/tasks/timeline
EventBridge RuleLeadCreatedRule-${region}source=lead-tracking, detail-type=LeadCreated → SQS
SQS Queuelead-processor-queue-${region}visibilityTimeout=300s, retention=4d, maxRetry=3
SQS DLQlead-processor-dlq-${region}retention=14d

⚠️ 已知缺失:DLQ CloudWatch alarm

neon-write-dlqneon-write-final-dlq 当前没有 CloudWatch Alarm → 消息进入终态 DLQ 时静默,只能从 SQS console 看到。

对比 callytics-infrastructure lib/stacks/monitoring-stack.ts 已有完整 alarm pattern(LeadProcessorDLQAlarm / TranscribeDLQAlarm 等,threshold=1, 经 alarmTopic SNS → alarm-dispatcher Lambda → Lark 卡片)。lead-tracking 需要单独 issue 跟踪补 alarm。


Test/Prod 双写模式

Poller 仍同时写 prod 和 test Neon;NeonRetryProcessor 只消费 prod write DLQ 并重试 prod Neon。下游 lead-processor 在 callytics-infrastructure 里按当前 stack 的 environment 写对应 Neon,不是 prod Lambda 再 fire-and-forget 写 test。

组件Prod / 当前 env NeonTest Neon
Poller (leads happy path)非阻塞(失败 → neon-write-dlq, email still SEEN)fire-and-forget(失败只 log)
NeonRetryProcessor (leads retry path)阻塞当前 SQS message(失败 → SQS redrive → final-dlq)不写
Lead Processor (③④⑤)阻塞(失败 → SQS retry)由 test stack 单独写 test Neon,不是 prod fire-and-forget 双写

SSM 参数:

参数用途
/lead-tracking/neon-database-urlProd Neon 连接串
/lead-tracking/neon-database-url-testTest Neon 连接串