Files
ai-kefu-jc/docs/superpowers/specs/2026-09-28-lead-push-to-uniservice-design.md
T
2026-09-29 14:15:12 +08:00

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 新系统归档。核心要求:

  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);概率极低,接受