跳转至

Phone Normalization Strategy

所有系统中的电话号码必须以 E.164 格式存储和查询(canonical SoT)。本文档定义为什么、怎么做、迁移路径。

实施溯源 + Phase 状态
  • Issue: callytics-infrastructure#688
  • 执行计划: .claude/specs/2026-04-30-phone-normalization-plan.md
  • Phase 0 ✅ Audit done (2026-04-30 live SQL on callytics-test main)
  • Phase 1 ✅ Helper shipped — callytics-common PR #86 merged
  • Phase 2-4, 7 ⏳ — Beads tasks callytics-infrastructure-81w/z57/6mm/gnu/88y 按 dep graph 推进
  • Phase 5-6 ❌ Backfill superseded by #454(DDB→Neon migration)— 见 § 5
  • 2026-04-30 live audit 结果: 4 phone 列 65,567 行总脏率 0.26%(161 行),其中 99% 是垃圾值(Unknown / anonymous / 内部分机)而非格式问题。libphonenumber-js 会 reject 这些垃圾,Phase 2-4 写端 normalize 后根本写不进去。真"格式不一致"问题在生产基本不存在,不需要复杂 backfill。

1. 背景:为什么需要 normalize

retaintive 系统每次接到通话,核心问题是"这通电话是哪个店家的?"。回答方式是拿 phone string 去 DynamoDB PhoneStoreAssignments 表查 — DDB query 是字符串字面相等,是号码语义相等。

同一个号在不同写法下完全不 match:

写法 DDB 查询结果
+13125559821 match
13125559821 silent miss
+1 312-555-9821 silent miss
(312) 555-9821 silent miss

当前为什么没爆: 美国 + RingCentral 单一来源,RC 给的全是 +1XXXXXXXXXX 格式,巧合下 query 一致。

何时爆: 1. 国际化(港 / 加拿大 / 欧洲号格式更乱) 2. 加新 phone source(studio-api 用户手填、CSV import) 3. RC 改 API contract(他们没保证永远不变) 4. 任何 backfill / migration 产生 inconsistent format


2. 现状:3 个 fragmented normalizer

在哪 函数 美国号 国际号 Bug
lead-tracking/src/parsers.ts:15-28 normalizePhone ✅ 10/11 digit → +1XXX ❌ silent return '' 国际号丢失
studio-website-monorepo/apps/api/src/utils/phone.ts:18-23 normalizeToE164 ❌ 国际号瞎拼 +digits 无 validation,silent false positive
callytics-infrastructure/lambda/shared/utils/phone-identity.ts:118-119 (无) ❌ raw query ❌ raw query 不 normalize
callytics-common/src/utils/ (无) 架构空缺 — 应该在这里有 canonical

写入端: studio-website-monorepo/apps/api/src/repositories/phone-store-assignments.ts:56-104 完全 raw 写 DDB(无 normalization 也无 uniqueness 约束)。

真正源头: RC OAuth callback (studio-website-monorepo/apps/api/src/routes/oauth/callback.ts:100-113,162-175) 把 RC 给的电话原样写 PhoneNumbers 表。PhoneStoreAssignments 是从 PhoneNumbers 派生的(stores/create.ts:74-86 exact-match 验证)。


3. 目标:一个 canonical helper + 全系统统一

3.1 Helper 设计(三方 cross-validate 后)

位置: callytics-common/src/utils/phone.ts(从 @retaintive/common/utils import)

API:

import parsePhoneNumber, { type CountryCode } from 'libphonenumber-js/min';

interface NormalizePhoneOptions {
  defaultCountry?: CountryCode;
}

export function normalizePhoneE164(
  phone: string | null | undefined,
  options: NormalizePhoneOptions = {},
): string | null {
  if (!phone) return null;
  try {
    const parsed = parsePhoneNumber(phone, options.defaultCountry ?? 'US');
    if (!parsed?.isValid()) return null;
    return parsed.format('E.164');
  } catch {
    return null;
  }
}

export function normalizePhoneTo10Digit(
  phone: string | null | undefined,
  options: NormalizePhoneOptions = {},
): string | null {
  const e164 = normalizePhoneE164(phone, options);
  if (!e164 || !e164.startsWith('+1')) return null;
  return e164.slice(2);
}

3.2 关键决策(三方一致)

决策 选择 原因
Library libphonenumber-js/min(~80KB) Google 官方 libphonenumber 的 JS port,1350 万 weekly downloads(NPM 顶级 1%),业界事实标准
不用 google-libphonenumber 太重(~550KB) Lambda cold start 不友好
不用 hand-rolled regex 已证明会丢国际号或瞎拼 lead-tracking/parsers.ts:15-28 + studio-api/utils/phone.ts:18-23 是反例
Return type string \| null invalid 是数据值问题不是 exception flow;符合 retaintive 现有 null-or-skip pattern(phone-identity.ts:58-60)
Default country options object,默认 'US' hard union 'US' \| 'CA' \| 'GB' 制造假信心;CountryCode type 自动覆盖 200+ 国家
Duplicate detection 不放 helper helper 必须 pure;冲突检测留 repository layer(phone-store-assignments.ts:177-187 已有 findConflictingStoreForPhone)

3.3 命名

canonical name 是 normalizePhoneE164,不是 normalizePhone。原因:normalize 后必须明示 "to what" — E.164 是 ITU 国际标准,放函数名最清晰。


4. 迁移策略:Lenient at Input, Strict at Storage

业界 SaaS pattern(Twilio / Stripe / WhatsApp 一致):用户在 UI 输什么写法都接受,DB 永远只存 E.164。

   用户输入 (415) 555-2671 / 020 7183 8750
   ─── normalize ────────────────────────
   DB 只存 +14155552671 / +442071838750

4.1 5 个写入入口都要 normalize

入口 文件 优先级
RC OAuth callback (PhoneNumbers table 真上游) studio-website-monorepo/.../callback.ts:100-113,162-175 最高 — 不修这里下游全脏
PhoneNumbers writer studio-website-monorepo/.../phone-numbers.ts:79-85
PhoneStoreAssignments writers studio-website-monorepo/.../phone-store-assignments.ts:56-104
Store create/update Zod schema studio-website-monorepo/.../routes/stores/create.ts:74-86, update.ts:71-74,171-188(用现成 zod-schemas.ts:23-27 E.164 regex)
Neon writers (4 Lambda) transcribe-processor, ai-analysis-processor, message-processor, contacts-analyzer

4.2 读端 dual-read fallback

迁移期 read 端必须双查:

async function findStoreAssignment(phone: string, deps): Promise<string | null> {
  const normalized = normalizePhoneE164(phone);
  if (!normalized) return null;

  // Primary: normalized E.164
  let result = await ddb.query({ phoneNumber: normalized });
  if (result.Items?.length) return result.Items[0].storeId;

  // Fallback: legacy raw key (during migration window only)
  if (phone !== normalized) {
    result = await ddb.query({ phoneNumber: phone });
    if (result.Items?.length) {
      logger.warn('Found PSA via legacy non-normalized key', { phone, normalized });
      return result.Items[0].storeId;
    }
  }

  return null;
}

logger.warn 让 CloudWatch 监控:fallback hit 降到 0 = backfill 完成,可删 fallback 代码


5. Backfill 策略:不需要

Live audit 后(2026-04-30)结论: DDB backfill 部分 superseded,Neon backfill 不需要。原因如下:

5.1 DDB backfill — superseded by #454

PhoneStoreAssignments + PhoneNumbers 当前只在 DDB(Neon 没建表,verified by psql query 2026-04-30)。issue callytics-infrastructure#454 Step 3 计划 Q2 把这两张表(连同其他 9 张 studio-api DDB 表)迁到 Neon,新 schema 是 phone_store_assignments join table(phone_number_id UUID FK)。

对将要消失的 DDB 表做 backfill = 浪费工作: - 写端 normalize(Phase 2-4)止住新 dirty rows - 读端 dual-read fallback(Phase 4)兼容历史 raw rows - 历史 dirty rows 等 #454 Step 3 迁 Neon 时一次性清洗(那时是 SQL UPDATE,比 DDB TransactWriteItems 简单 10 倍)

Beads task dx2(Phase 5+6 backfill)已 closed 标 SUPERSEDED by #454

5.2 Neon backfill — not needed

Live audit 显示 Neon 4 phone 列总脏率 0.26%(161 / 65,567 行),且 99% 是真垃圾值(Unknown / anonymous / 内部分机 9/347/71),不是格式问题:

-- Sample dirty rows
contacts.phone:        Unknown / anonymous / 31720 / 89405
calls.to_phone_number: 9 / 347 / 71 / 1978 / 7810
messages.from:         22000 / 262966 / 31720

这些 normalize 不能修(libphonenumber 会 reject 返 null)。正确做法是 Phase 2-4 写端加 normalize 后,这些垃圾根本写不进去;历史 161 行可以人工 review 后单独 SQL DELETE / UPDATE,不需要专门 backfill phase。

5.3 当出现真"格式漂移"时怎么办

Live audit 没看到大量真"格式不一致"问题(没看到一堆 13125559821 vs +13125559821 同号不同写法)。但如果未来发现:

-- 一次性 SQL 修复(不需要复杂 phase)
UPDATE contacts SET phone = '+1' || regexp_replace(phone, '\D', '', 'g')
WHERE phone ~ '^[0-9]{10}$';

跑一次 mkdocs build --strict 量级的工作,不需要 Beads phase。


6. 5 个 Phase 的执行 timeline(post-audit 简化)

完整 task breakdown 见 docs/.claude/specs/2026-04-30-phone-normalization-plan.md,这里只是 high-level overview:

Phase Task IDs Repo 估时 状态
0. Audit ebc callytics-infrastructure (script) 0.5 sprint ✅ DONE 2026-04-30 (live SQL,见本文档顶部 audit 表)
1. Helper 6lo callytics-common 0.5 sprint ✅ DONE 2026-04-30 (PR #86 merged)
2a. studio-api 81w studio-website-monorepo 0.5-1 sprint open,depends 6lo
2b. lead-tracking + message-infra z57 lead-tracking, message-infra 0.5 sprint open,depends 6lo
3. Dual-write 6mm studio-website-monorepo 0.5 sprint open,depends 81w
4. Read + Lambda gnu callytics-infrastructure 1 sprint open,depends 6mm
5+6. Backfill dx2 ❌ CLOSED 2026-04-30 (superseded by #454,见 § 5)
7. Cleanup 88y all 0.5 sprint open,depends gnu

Total: 2-3 sprint(post-audit 简化,从 4-6 sprint 砍掉 backfill phase)。任意 phase 之间停下系统都正常 work(zero-downtime guarantee via dual-write + dual-read fallback)。


  • Store-level isolation: store-level-isolation.md — phone → storeId 解析路径 SoT
  • Call lifecycle tracking: call-lifecycle-tracking.md — § 7.2 storeId resolution 风险与本文档关联
  • Issue #688: 完整 cross-repo audit + Codex/Gemini cross-validate 记录

8. "做了会怎样 vs 不做会怎样 vs 做完后影响什么"

按 retaintive .claude/rules/execution-policy.md "Risk 三板斧"

做了会怎样

✅ 国际化解锁(港 / 加 / 欧洲号能正常用) ✅ 手填 / CSV import / 第三方 API 给的脏数据自动 normalize ✅ RC API 改格式不会爆炸(normalize 兜底) ✅ 一个 canonical helper,代码维护成本降低 ✅ Audit script surface 隐藏脏数据(本来不知道有问题) ⚠️ Lambda cold start +50ms(80KB 包加载,可接受) ⚠️ 短期内 DDB collision report 可能显示意料外的"重复 phone"(实际是同号被 ops 输错 N 次,本来 silent broken)

不做会怎样

❌ 国际化前必须停下,不能开新市场 ❌ 手填脏数据永久 silent broken ❌ 数据量越大 backfill 成本越贵(线性增长) ❌ 3 个 fragmented normalizer 继续 drift,新人不知道用哪个 ❌ 隐藏脏数据继续累积,某天爆了不知道根因

做完后影响什么

影响范围 性质
callytics-common 加 1 个 utility + 1 个 dep(libphonenumber-js) 新功能
5 个写入入口加 normalize 逻辑 行为变化(reject invalid)
4 个 Lambda 读端 dual-read 临时迁移逻辑(7 天后删)
历史 DDB + Neon 数据被改写到 E.164 一次性 migration
studio-api API contract 变化:non-E.164 input → 400 reject breaking change(但 UI 端 lenient,自动 normalize)
LeadTracking 10-digit GSI 路径保留 backwards compat

任何阶段停下都不破坏现有功能 — 见 § 4 dual-read / § 5 dual-write 设计。