# 询盘推送 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);概率极低,接受 |