49c0e5559c
Co-Authored-By: Claude Code <noreply@anthropic.com>
238 lines
14 KiB
Markdown
238 lines
14 KiB
Markdown
# 询盘推送 UniService 系统 · 设计文档
|
|
|
|
- 日期:2026-09-28
|
|
- 状态:已评审确认(触发时机、契约适配、失败处理、推送范围、架构选型均经用户确认)
|
|
- 对接契约:UniService《AI 客服询盘推送接口 v1》(repo 内 `docs/AI_INQUIRY_PUSH_API.md`,2026-09-28 版)
|
|
- 关联现状文档:`docs/2026-08-18-contact-driven-lead-email-changelog.md`(询盘邮件链路)
|
|
|
|
## 1. 背景与目标
|
|
|
|
询盘邮件(`_maybe_send_lead_email`)触发的询盘,需在触发同时**主动推送**到 UniService 新系统归档。核心要求:
|
|
|
|
1. **同刻触发**:联系方式确认、询盘邮件触发的同一时刻推送,fire-and-forget
|
|
2. **不阻塞**:推送(含失败)绝不影响对话流和邮件发送
|
|
3. **结果可查**:每次推送落库记录状态、次数、错误
|
|
4. **跟随邮件范围**:首询盘 / 回访 / 更正三种邮件触发都推(带类型标记)
|
|
|
|
### 用户确认的决策记录
|
|
|
|
| 决策点 | 结论 |
|
|
|---|---|
|
|
| 触发时机 | 与询盘邮件同刻触发,fire-and-forget |
|
|
| 接口契约 | 对方已定,我方适配 |
|
|
| 失败处理 | 任务内自动重试(≤3 次指数退避),仍败标 failed |
|
|
| 推送范围 | 跟随邮件:每次邮件触发都推(lead / returning / correction) |
|
|
| 架构 | 方案 A:create_task 后台任务 + 推送日志表,零新基础设施 |
|
|
| 结果查看 | admin 查询接口 `GET /api/admin/push-logs`(第 9 节,用户评审时新增) |
|
|
| SMTP 失败时推送 | **照推**——与邮件发送成败解耦,只看"进入发信分支"(终审新增) |
|
|
|
|
## 2. 对接契约要点(UniService v1)
|
|
|
|
| 项 | 值 |
|
|
|---|---|
|
|
| 端点 | `POST https://uniservice.server.hnxinyouhe.com/open/v1/inquiry/push`(已开通,用户提供) |
|
|
| 鉴权 | 请求头 `X-Api-Key: <开通时分配>` |
|
|
| 格式 | JSON UTF-8,`{"messages": [...]}`,单次 ≤50 条(我方每次 1 条) |
|
|
| 幂等 | 按每条 `external_key`,重复推送不重复入库——**重试安全** |
|
|
| event | `new`=新询盘;`update`=按 external_key 更新同一条询盘 |
|
|
| 时间 | **一律正 8 区**(`2026-09-28T10:30:00+08:00`),不要发 UTC |
|
|
| contact 校验 | `phone` / `whatsapp` / `email` 至少一个非空且有效,否则整条被拒 |
|
|
| 响应 | HTTP 200 仍可能逐条 `failed`;`accepted` / `duplicated` / `failed[]` 逐条返回 |
|
|
| 错误码 | `401`/`400` 不可重试;`5xx`/超时可重试 |
|
|
| 联调 | 先推 1-2 条真实样本,双方核对 accepted/duplicated/failed 与归档内容 |
|
|
|
|
## 3. 总体架构与数据流
|
|
|
|
```
|
|
对话流 stream_process_message (conversation.py)
|
|
└─ _maybe_send_lead_email (conversation.py:416)
|
|
├─ [现有] 发邮件 (send_lead_email / send_returning_lead_email / send_correction_email)
|
|
├─ [新增①] dispatch_lead_push(): 与 evaluation 更新同事务写入 pending 推送日志(不依赖 SMTP 成败)
|
|
└─ [新增②] 事务提交后 asyncio.create_task(_run_push(...)) ← 纳秒级返回
|
|
└─ 后台任务(独立 DB session):
|
|
├─ contact 三选一预检 → 不满足则标 skipped(不发 HTTP)
|
|
├─ httpx POST 对方接口 (X-Api-Key)
|
|
├─ 5xx/超时/网络异常:指数退避重试 ≤3 次
|
|
├─ 401/400:立即 failed 不重试
|
|
└─ 解析 200 响应体逐条结果 → success/failed 落库
|
|
```
|
|
|
|
选型理由:项目无任何后台任务设施(无 create_task/APScheduler/队列,Redis 配而未用),方案 A 仅用标准库 asyncio 即满足"不阻塞 + 记结果 + 重试";可靠性短板(进程崩溃丢 in-flight 任务)用"启动清扫 stale pending"廉价补齐,不引入完整队列。
|
|
|
|
## 4. 数据模型:新表 `lead_push_logs`
|
|
|
|
加在 `app/models/database.py`(`Base.metadata.create_all` 启动自动建新表,不影响旧表旧数据):
|
|
|
|
| 字段 | 类型 | 说明 |
|
|
|---|---|---|
|
|
| id | String(36) UUID PK | 与既有四表主键风格一致(实施定案) |
|
|
| evaluation_id | FK → evaluations.id,索引 | 溯源到询盘评估 |
|
|
| conversation_id | FK → conversations.id,索引 | external_key 的构成来源 |
|
|
| push_type | String | 我方触发类型:`lead` / `returning` / `correction` |
|
|
| status | String,默认 `pending` | `pending` / `success` / `failed` / `skipped` |
|
|
| attempts | Integer,默认 0 | 实际尝试次数 |
|
|
| request_payload | JSON | 实际发出的 payload 快照(排障/未来补推用) |
|
|
| response_status | Integer,可空 | 对方返回 HTTP 状态码 |
|
|
| outcome | String,可空 | `accepted` / `duplicated`(200 时对方逐条结论) |
|
|
| error_message | Text,可空 | 最后一次错误(或对方 failed[].reason / skipped 原因) |
|
|
| created_at / updated_at | DateTime | |
|
|
|
|
索引:`status` 单列(筛选/清扫用)。
|
|
|
|
状态语义:
|
|
|
|
- `skipped`:wechat/skype-only 询盘,对方必然拒收"无有效联系方式",预检后直接跳过,但仍落日志留审计痕迹
|
|
- `success` + `outcome=duplicated`:不是错误,但运营上值得知道(对方已有该询盘)
|
|
- `failed`:含 401/400(配置/报文问题)、重试耗尽、对方逐条拒收
|
|
|
|
## 5. 配置项(`app/config.py`,照现有扁平大写风格,`.env.example` 同步)
|
|
|
|
```
|
|
PUSH_ENABLED=false # 总开关,联调前不推
|
|
PUSH_API_URL=https://uniservice.server.hnxinyouhe.com/open/v1/inquiry/push
|
|
PUSH_API_KEY= # X-Api-Key 头(对方开通时分配)
|
|
PUSH_EXTERNAL_KEY_PREFIX=ai-kefu # 幂等键前缀,防联调/生产环境撞键
|
|
PUSH_TIMEOUT_SECONDS=10
|
|
PUSH_MAX_ATTEMPTS=3
|
|
PUSH_RETRY_BACKOFF_BASE=1.0 # 尝试间隔 1s/2s(3 次尝试、2 次间隔)
|
|
```
|
|
|
|
## 6. 新服务 `app/services/lead_push_service.py`
|
|
|
|
### 6.1 `dispatch_lead_push(db, evaluation, push_type)`
|
|
|
|
触发点唯一入口:组 payload、写 pending、派任务。**整体 try/except,任何异常只记日志,绝不向上抛**——这是"不阻塞邮件主流程"的隔离保证。
|
|
|
|
触发条件 = **进入发信分支**(联系方式确认 + 24h 去重通过),与 SMTP 发送成败**完全解耦**——邮件失败询盘照推(归档不丢询盘,用户终审定案)。
|
|
|
|
### 6.2 `_build_payload(...) -> dict`
|
|
|
|
按第 7 节映射表组装。contact 三选一预检(phone/whatsapp/email 均空或无效)→ 写 `skipped` 日志行,不派任务不发 HTTP。
|
|
|
|
### 6.3 `_run_push(push_log_id, payload) -> None`
|
|
|
|
后台任务体:
|
|
|
|
- **自建独立 session**(严禁复用请求 session——请求结束即关闭;本接线的关键正确性点)
|
|
- session 经模块级 factory 获取(照 `chat_rest.async_session` 的引用模式),保持测试可 monkeypatch 替换——**不得私建 engine**
|
|
- `async with httpx.AsyncClient(timeout=PUSH_TIMEOUT_SECONDS)` 每次新建(流量小,不引入 lifespan 管理负担)
|
|
- 重试循环:仅对 5xx/超时/网络异常,`PUSH_MAX_ATTEMPTS` 次,退避 `base * 2**(n-1)`;每次尝试后更新 `attempts`/`error_message`
|
|
- 401/400:立即 `failed` 不重试,logger 高亮(密钥/报文问题需人工介入)
|
|
- 200:解析响应体,本条 `accepted`/`duplicated` → `success` + `outcome`;本条在 `failed[]` → `failed` + 对方 reason,不重试(数据问题)
|
|
- 全套 try/except 包裹,异常 `logger.exception`,绝不逃逸
|
|
- 任务引用存模块级 set,done callback 移除(防 GC,asyncio 官方要求)
|
|
|
|
## 7. 契约映射
|
|
|
|
### 7.1 external_key 与 event(核心规则)
|
|
|
|
`external_key = f"{PUSH_EXTERNAL_KEY_PREFIX}-{conversation_id}"`——询盘=一次会话,键随会话稳定。
|
|
|
|
| 我方邮件类型 | event | 规则 |
|
|
|---|---|---|
|
|
| lead(首询盘) | `new` | 新询盘入库 |
|
|
| returning(回访,新会话) | `new` | 同一人再次询盘=新询盘事件,对方按询盘归档,语义正确 |
|
|
| correction(更正) | 同会话此前有 success 推送 → `update`(同键更新);否则 `new` | **保护规则**:查我方 `lead_push_logs` 判断,不依赖对方"update 未知键"的未定义行为 |
|
|
|
|
### 7.2 字段映射表
|
|
|
|
| 对方字段 | 我方来源 |
|
|
|---|---|
|
|
| `event` / `external_key` / `occurred_at` | 按 7.1 生成;occurred_at = 推送派发时刻(内存取 now;`evaluated_at` 无 onupdate、更正场景滞留旧值,不用——实施定案);统一 +08:00 格式化 |
|
|
| `contact` | `evaluations.contact_info` 快照(name/wechat/skype 可空直传;skype 我方无则 null) |
|
|
| `intent_level` | `interested→high`、`pending→medium`、`not_interested→low`(映射取"销售跟进优先级"解读:明确无意向=最低优先级,适配对方归档+销售导入用途;用户终审定案),模块级 dict 常量,便于调整 |
|
|
| `intent_reason` | `evaluations.reasoning` 透传(database.py:119 定义,conversation.py:368 已持久化) |
|
|
| `profile` | `customer_profiles.raw_info` 原样透传 + IP→国家(iptocc)+ source/语言(对方声明任意 JSON 原样归档) |
|
|
| `conversation` | 该会话全部 messages:role `user→customer`、`assistant→ai`,text=content,at=+08:00 |
|
|
| `summary` | **不传**(我方无摘要生成,选填) |
|
|
| `attachments` | **不传**(我方无附件体系,选填) |
|
|
|
|
## 8. 触发接线(`app/services/conversation.py`)
|
|
|
|
`_maybe_send_lead_email`(L416-463)三个发信分支各加一行 dispatch,传对应 `push_type`。合计约 10 行。`email_service.py` **不动**(单一职责)。
|
|
|
|
dispatch 的调用位置在**分支进入处**,不依赖 `send_*` 返回值;实施时核对 `email_sent` 置位逻辑,确保 pending 写入不被误绑到邮件成功路径。
|
|
|
|
### 8.1 已知坑:事务边界与后台任务竞争
|
|
|
|
`create_task` 立即执行,而后台任务若读库会撞上主事务未提交。设计定为:
|
|
|
|
- **首选**:payload 内存传递,任务不读库只写结果;`create_task` 派生点放在主事务提交之后。实施时确认 `_maybe_send_lead_email` 返回后的 commit 边界(stream_process_message 的提交时机),将派生挪到提交后
|
|
- **备选**(代码结构不便时):任务首步以短退避重读该行直至可见(上限 2s)
|
|
|
|
### 8.2 崩溃恢复
|
|
|
|
`app/main.py` lifespan 启动清扫:pending 且 `updated_at` 超时(建议 30 分钟,可与退避上限联动)→ 标 `failed`,error_message 记 "interrupted by restart"。约 10 行。清扫自身失败则应用启动失败(fail-fast 定案:任何真实 DB 故障也会让紧邻的 create_all 先失败,属既有启动语义;未清扫的行下次成功启动时按时间窗补扫)。
|
|
|
|
## 9. Admin 查询接口
|
|
|
|
`GET /api/admin/push-logs`(加在 `app/api/routes/admin.py`,router 已在 main.py 挂载,无需改注册):
|
|
|
|
| 项 | 设计 |
|
|
|---|---|
|
|
| 鉴权 | 复用现有 `_check_auth` cookie 会话(与 conversations 接口同套路) |
|
|
| 查询参数 | `status`(pending/success/failed/skipped,可空)、`conversation_id`(可空)、`page`(默认 1)、`size`(默认 20,与 /conversations 一致) |
|
|
| 排序 | `created_at DESC`(最新在前;id 为 UUID 无时序,不用 id 排序) |
|
|
| 响应 | 照 `GET /conversations` 现有风格(items + total);**列表不返回 `request_payload`**(含完整对话,体积大),只返回核心字段:id / push_type / status / attempts / outcome / response_status / error_message / created_at / updated_at / evaluation_id / conversation_id |
|
|
|
|
## 10. 错误处理矩阵
|
|
|
|
| 故障 | 行为 |
|
|
|---|---|
|
|
| 对方 5xx / 超时 / 网络异常 | 指数退避重试 ≤3 次 → `failed` + 错误详情 |
|
|
| 对方 401 / 400 | 立即 `failed` 不重试,日志高亮(配置/报文问题) |
|
|
| 200 + 本条在 failed[] | `failed` + 对方 reason,不重试 |
|
|
| 200 + accepted/duplicated | `success` + outcome |
|
|
| contact 三选一不满足 | 预检 `skipped`,不发 HTTP |
|
|
| dispatch 自身异常(写库失败等) | 吞掉记日志,**邮件照发、对话不受影响** |
|
|
| 进程重启丢 in-flight 任务 | lifespan 启动清扫 stale pending → failed |
|
|
| SQLite 写锁竞争 | 后台写结果套在重试/异常兜底内,小事务快进快出 |
|
|
|
|
对方建议的"24 小时重试窗口"**本期不做**(需常驻队列);`failed` 行已存 payload 快照 + 幂等键保证将来补推绝对安全,需要时加重推入口即可。
|
|
|
|
## 11. 测试计划
|
|
|
|
复用 conftest 现有范式(Fake 桩 + monkeypatch + pytest asyncio auto):
|
|
|
|
1. 三种邮件触发 → 各写一条 pending,push_type 正确
|
|
2. contact 预检:wechat-only → `skipped`,无 HTTP 调用
|
|
3. 桩 httpx 成功(accepted / duplicated)→ `success` + outcome + response_status 落库
|
|
4. 桩连续 5xx → attempts==3、`failed`、错误落库;退避序列正确(monkeypatch sleep 记录)
|
|
5. 桩 401 → 立即 `failed`,attempts==1,不重试
|
|
6. 桩 200+failed[](本条被拒)→ `failed` + 对方 reason,不重试
|
|
7. dispatch 内部炸 → 邮件照发、无异常上抛(不阻塞验证)
|
|
8. 慢推送(假 sleep)不影响 `stream_process_message` 总耗时(断言 create_task 被调用而非 await)
|
|
9. PUSH_ENABLED=false → 无日志无任务
|
|
10. external_key/event 规则:同会话第二次 correction 推送 event=update;新会话 returning event=new
|
|
11. 时区:所有出站时间字段均带 +08:00(非 UTC、非 naive)
|
|
12. admin 查询接口:未登录被拒、status 筛选与分页正确、列表响应不含 request_payload
|
|
13. 桩 `send_lead_email` 返回 False(SMTP 失败)→ 推送照常派发(解耦验证)
|
|
|
|
## 12. 实施文件清单(三阶段)
|
|
|
|
| 阶段 | 内容 | 文件 |
|
|
|---|---|---|
|
|
| 1 | 表 + 配置 + 服务(纯新增,独立可测) | `app/models/database.py`、`app/config.py`、`app/services/lead_push_service.py`(新)、`requirements.txt`(httpx 转正式依赖)、`.env.example` |
|
|
| 2 | 触发接线 + 不阻塞验证 | `app/services/conversation.py`(~10 行) |
|
|
| 3 | admin 查询接口 + 启动清扫 + changelog | `app/api/routes/admin.py`(GET /push-logs)、`app/main.py`、`docs/2026-09-28-lead-push-changelog.md` |
|
|
|
|
## 13. 明确不做(YAGNI)
|
|
|
|
- ❌ 不引入 Redis / Celery / APScheduler / 队列
|
|
- ❌ 不做推送去重(邮件逻辑已去重,跟随即可;对方幂等键再兜一层)
|
|
- ❌ 不做手动重推接口(查询接口见第 9 节;补推有幂等键保证安全,需要时再加)
|
|
- ❌ 不做 24h 持久重试窗口
|
|
- ❌ 不传 summary / attachments(无数据来源)
|
|
- ❌ 不动 `email_service.py` 和现有邮件逻辑
|
|
|
|
## 14. 风险清单
|
|
|
|
| 风险 | 应对 |
|
|
|---|---|
|
|
| **时区(最高)**:DB 写入为 UTC-aware(`datetime.now(timezone.utc)`,database.py:54 等),但 SQLite 读回可能退化为 naive 字符串;发 UTC 会被对方拒 | 实施第一步实测读回值;出站统一按"UTC 语义 → +08:00"转换,helper 单点收口 |
|
|
| 对方"update 未知键"行为未定义 | 7.1 保护规则:仅同会话已 success 才发 update |
|
|
| SQLite 并发写锁 | 小事务 + 异常兜底;必要时 WAL |
|
|
| 联调环境键冲突 | PUSH_EXTERNAL_KEY_PREFIX 可配 |
|
|
| phone/whatsapp 区号格式未核验:对方要求带国际区号且"有效",我方 contact_info 快照格式未核对 | 联调真实样本首先验证号码格式,不符会被整条拒收 |
|
|
| 推送 HTTP 成功但结果写回失败(SQLite 锁)→ 行停留 pending → 清扫误标 failed | 已知限制:标签不准确但幂等键保证补推安全(对方返回 duplicated);概率极低,接受 |
|