diff --git a/docs/superpowers/specs/2026-10-08-cc-ip-country-confirm-design.md b/docs/superpowers/specs/2026-10-08-cc-ip-country-confirm-design.md new file mode 100644 index 0000000..6568b35 --- /dev/null +++ b/docs/superpowers/specs/2026-10-08-cc-ip-country-confirm-design.md @@ -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 为主;每步一次为限 |