> For AI agents: the complete documentation index is available at /llms.txt, the full documentation bundle is available at /llms-full.txt.

# 电话号码规范化策略

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

> 文中提到 "repository layer" / `phone-store-assignments.ts` 等 repository 层概念, 不熟悉 layered / hexagonal 分层看 [Backend 架构 Pattern](/system-design/backend-patterns.md)。

::: details 实施溯源 + Phase 状态

- **Issue**: [callytics-infrastructure#688](https://github.com/retaintive/callytics-infrastructure/issues/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](https://github.com/retaintive/callytics-common/pull/86) merged
- **Phase 2-4, 7 ⏳** — Beads tasks `callytics-infrastructure-81w/z57/6mm/gnu/88y` 按 dep graph 推进
- **Phase 5-6 ❌** Backfill superseded by [#454](https://github.com/retaintive/callytics-infrastructure/issues/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**:

```typescript
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 端必须双查:

```typescript
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](https://github.com/retaintive/callytics-infrastructure/issues/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`),不是格式问题:

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

跑一次 `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:

| 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](https://github.com/retaintive/callytics-common/pull/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)。

***

## 7. Cross-link 到其他设计文档

- **Store-level isolation**: [`store-level-isolation.md`](/system-design/store-level-isolation.md) — phone → storeId 解析路径 SoT
- **Call lifecycle tracking**: [`call-lifecycle-tracking.md`](/system-design/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 设计。
