电话号码规范化策略
所有系统中的电话号码必须以 E.164 格式存储和查询(canonical SoT)。本文档定义为什么、怎么做、迁移路径。
文中提到 "repository layer" /
phone-store-assignments.ts等 repository 层概念, 不熟悉 layered / hexagonal 分层看 Backend 架构 Pattern。
::: details 实施溯源 + 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:
当前为什么没爆: 美国 + RingCentral 单一来源,RC 给的全是 +1XXXXXXXXXX 格式,巧合下 query 一致。
何时爆:
- 国际化(港 / 加拿大 / 欧洲号格式更乱)
- 加新 phone source(studio-api 用户手填、CSV import)
- RC 改 API contract(他们没保证永远不变)
- 任何 backfill / migration 产生 inconsistent format
2. 现状:3 个 fragmented normalizer
写入端: 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:
3.2 关键决策(三方一致)
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。
4.1 5 个写入入口都要 normalize
4.2 读端 dual-read fallback
迁移期 read 端必须双查:
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),不是格式问题:
这些 normalize 不能修(libphonenumber 会 reject 返 null)。正确做法是 Phase 2-4 写端加 normalize 后,这些垃圾根本写不进去;历史 161 行可以人工 review 后单独 SQL DELETE / UPDATE,不需要专门 backfill phase。
5.3 当出现真"格式漂移"时怎么办
Live audit 没看到大量真"格式不一致"问题(没看到一堆 13125559821 vs +13125559821 同号不同写法)。但如果未来发现:
跑一次 npm run docs:build 量级的工作,不需要 Beads phase。
6. 5 个 Phase 的执行 timeline(post-audit 简化)
完整 task breakdown 见 docs/.claude/specs/2026-04-30-phone-normalization-plan.md,这里只是 high-level overview:
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,新人不知道用哪个 ❌ 隐藏脏数据继续累积,某天爆了不知道根因
做完后影响什么
任何阶段停下都不破坏现有功能 — 见 § 4 dual-read / § 5 dual-write 设计。