Co-Authored-By: Claude Code <noreply@anthropic.com>
14 KiB
询盘推送 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 新系统归档。核心要求:
- 同刻触发:联系方式确认、询盘邮件触发的同一时刻推送,fire-and-forget
- 不阻塞:推送(含失败)绝不影响对话流和邮件发送
- 结果可查:每次推送落库记录状态、次数、错误
- 跟随邮件范围:首询盘 / 回访 / 更正三种邮件触发都推(带类型标记)
用户确认的决策记录
| 决策点 | 结论 |
|---|---|
| 触发时机 | 与询盘邮件同刻触发,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):
- 三种邮件触发 → 各写一条 pending,push_type 正确
- contact 预检:wechat-only →
skipped,无 HTTP 调用 - 桩 httpx 成功(accepted / duplicated)→
success+ outcome + response_status 落库 - 桩连续 5xx → attempts==3、
failed、错误落库;退避序列正确(monkeypatch sleep 记录) - 桩 401 → 立即
failed,attempts==1,不重试 - 桩 200+failed→
failed+ 对方 reason,不重试 - dispatch 内部炸 → 邮件照发、无异常上抛(不阻塞验证)
- 慢推送(假 sleep)不影响
stream_process_message总耗时(断言 create_task 被调用而非 await) - PUSH_ENABLED=false → 无日志无任务
- external_key/event 规则:同会话第二次 correction 推送 event=update;新会话 returning event=new
- 时区:所有出站时间字段均带 +08:00(非 UTC、非 naive)
- admin 查询接口:未登录被拒、status 筛选与分页正确、列表响应不含 request_payload
- 桩
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);概率极低,接受 |