docs: IP猜国+两段式号码确认设计文档(需求方逐点确认版)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-08 15:00:59 +08:00
parent cbf234d9d2
commit b68bd192ca
@@ -0,0 +1,119 @@
# 设计:IP 猜国 + 两段式号码确认 — 设计文档
- 日期:2026-10-08
- 前置:2026-10-08-dual-contact-and-country-code-design.md 及其墙层增补
(2026-10-08-cc-enforcement-wall-design.md)——本设计替换其中的
"请客户重发完整号"路径为更低摩擦的确认式流程
- 状态:已与需求方逐点确认(含"补全号发给客户确认"与"猜错直接要完整号")
## 1. 需求
客户裸号(无国家代码)时:
1. 用客户 IP 猜国家,**反问客户**"是不是在这个国家"(确认式,非索取式)
2. 客户答 Yes → 服务端补全号码 → **把补全号发给客户确认**("reach you at
+966 5x…— is that right?")→ 客户再 Yes → 完结,邮件/推送用补全号
3. 客户答 No(IP 猜错/VPN)→ **直接请客户给带码完整号**(一步到位,一次为限)
4. 任何一步卡住/不理睬 → 裸号照发(现状底线,不阻塞线索交付)
## 2. 资产盘点(实测)
| 能力 | 现状 | 结论 |
|---|---|---|
| WS 路由真实 IP | chat.py:24-27 已处理 X-Forwarded-For 第一段 | ✅ 复用 |
| REST 路由真实 IP | chat_rest.py:107 仅 request.client.host(反代下=127.0.0.1) | ⚠️ 前置补 XFF(一行) |
| IP→国家 | ip_service.resolve_country(iptocc,返回中文/ISO) | ✅ 复用(内部用 ISO code) |
| 国家→电话前缀 | 无 | ❌ 新建映射表 |
| CC 强制墙骨架 | conversation.py cc_wall 缓冲/替换/Important-2 closing | ✅ 复用(换文案) |
| 出站管道 | email_service + lead_push._build_payload | ✅ 挂补全点 |
## 3. 状态机(核心)
```
裸号在场且未处理
→ [_cc_pend] ①国家确认轮:"Are you in {英文国名}?"(墙保证必出)
├─ Yes(确定性判定)→ 生成补全号,存 _cc_full
│ → [_cc_conf] ②号码确认轮:"…reach you at {补全号} — is that right?"
│ (墙白名单放行该精确号码;确认句墙保证必出)
│ ├─ Yes → [_cc_done] 完结:出站用补全号(带标注)
│ └─ No → 请给完整号(一次)→ [_cc_done 或超限]
├─ No(猜错)→ 请给带码完整号(一次)→ 得到则 [_cc_done]
└─ 无法判定(长消息/不理睬)→ 下轮再给一次确认机会(计数)
→ 超限(确认机会共 2 次)→ 请完整号一次 → 仍无 → 裸号照发
```
状态键(raw_info,`_` 前缀族):`_cc_pend`(含猜的 ISO code)、`_cc_full`
(补全号字符串)、`_cc_done`(完结标记)、`_cc_tries`(已用确认机会数)。
## 4. 组件设计
### 4.1 国家前缀表(新建 `app/services/phone_prefix.py`)
- `CC_PREFIX: dict[str, str]`:ISO alpha-2 → 拨号前缀,覆盖 COUNTRY_NAMES
全量 + 主要外贸国(SA+966/AE+971/KE+254/NG+234/GH+233/EG+20/…约 40 国)
- `CC_EN_NAME: dict[str, str]`:ISO → 英文国名(对话展示用,海外客户读英文)
- `complete_number(raw: str, iso: str) -> str | None`:补全 = 前缀 + 去前导 0
(E.164 trunk 规则,绝大多数国家适用;意大利等特例返回 None → 走兜底);
已带 +/00 前缀的号直接返回原样(幂等)
- 未覆盖 ISO / 补全失败 → None → 调用方走"请完整号"兜底
### 4.2 Yes/No 判定器(conversation.py 模块级纯函数)
- `_cc_reply_intent(text) -> "yes" | "no" | None`:确定性关键词
(yes/yeah/yep/sure/correct/right/exactly/对/是的/没错/嗯 ↔ no/nope/not
really/wrong/不是/不对/错了)
- **适用条件**(防误判,调用方守卫):仅 `_cc_pend`/`_cc_conf` 的**紧邻下一轮**、
且客户消息**短**(≤30 字符,纯应答形态);长消息/含产品内容 → None(视为未
应答,转兜底)
- 与项目 doctrine 一致:判定不交给 GLM
### 4.3 提醒与墙(复用骨架,换文案)
- ①国家确认轮:reminder 注入逐字句(英文 "Are you in {EN}?" / 中文"你是在
{中文国名}吗?"),CC 墙检测词从 "country code" 改为 **本轮注入的英文国名
在场**(或中文国名),不在则替换——缓冲机制不变
- ②号码确认轮:reminder 注入逐字句含补全号;**墙白名单**:`no_contact_value`
新增参数 `allow_number: str | None`——数字串匹配(digits_only 归一后比对)
该精确号码则放行,其余照拦(安全边界不扩大:只放行系统注入的那一个号)
- 确认句墙同款缓冲:检测补全号在场,不在则替换
### 4.4 出站补全(email_service + lead_push_service)
- 消费点:渲染联系方式处(邮件 HTML/主题、push 的 contact.phone/whatsapp)
- 规则:`_cc_done` 且 `_cc_full` 在场 → 用 `_cc_full` 替换裸号展示,**带标注**
(如 `+966501234567(原号 0501234567,据 IP+客户确认为沙特,已补国家码)`)
- ⚠️ 推送侧交互:`_build_payload` 现过滤 `_` 前缀键——补全消费必须在过滤
**之前**(读 raw_info → 生成补全 contact → 过滤画像)
- DB 不改客户原文;聊天里仅②确认轮展示补全号(白名单放行)
### 4.5 REST 路由 XFF 前置修复
chat_rest.py:107 补 WS 同款 XFF 第一段逻辑(app 源客户 IP 真实化)
## 5. 防死缠与兜底链
- 确认机会共 **2 次**(①轮后未应答可再给一次);"请完整号"**1 次**
- 超限/全程不理睬 → 裸号照常 confirmed 发邮件/推送(现状行为,零阻塞)
- IP 无效(本地/未知)/ISO 无前缀/补全失败 → 直接走"请完整号"(跳过①)
- 与邮箱轻补的串行:号码确认流全部完结(_cc_done 或超限)后才可能注入邮箱
轻补(沿用 §4.3b 串行规则,判定条件相应更新)
## 6. 测试策略
1. 前缀表单测:补全(含去 0/幂等/特例 None)/英文国名/覆盖表
2. 判定器单测:yes/no 词表×短长消息×中英
3. 状态机集成(FakeAI 流程):①Yes→②Yes 全链(补全号落库、白名单放行、
出站带标注);①No→请完整号→给号完结;长消息→再给一次→超限裸号发;
IP 无效→跳①直接要完整号
4. 墙测试:白名单精确放行(同号放/异号拦/无参数照拦);确认句替换
5. 出站测试:邮件/push 的补全与标注;`_` 键过滤与消费顺序
6. 真实 LLM 验收:全流程三路径 + 误判对照(长消息含 Yes 不触发)
## 7. 风险
| 风险 | 等级 | 缓解 |
|---|---|---|
| IP 误判(VPN/出口) | 中 | ①确认式反问本身就是纠错机制;No→要完整号 |
| 长消息误判 yes/no | 中 | 短消息守卫 + 兜底链 + 出站标注透明 |
| 前缀表覆盖不足 | 低 | 未覆盖走旧兜底;表可持续补 |
| 墙白名单被滥用扩大 | 低 | 精确串匹配参数化,评审断言锁死"只放行注入号" |
| 对话轮次变多 | 低 | 全程 yes/no 为主;每步一次为限 |