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 |
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)。
7. Cross-link 到其他设计文档¶
- 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 设计。