Files
ai-kefu-jc/docs/superpowers/plans/2026-09-28-lead-push-to-uniservice.md
T

1669 lines
62 KiB
Markdown
Raw Normal View History

# 询盘推送 UniService 实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 询盘邮件触发的同时,把询盘 fire-and-forget 推送到 UniService(`POST /open/v1/inquiry/push`),推送结果落库可查,不阻塞对话与邮件主流程。
**Architecture:** 方案 A(设计文档第 3 节):`_maybe_send_lead_email` 发信分支内 `dispatch_lead_push()` 与 evaluation 更新同事务写 `pending` 日志;主事务 commit 后 `fire_pending_pushes()` 用 `asyncio.create_task` 派生后台任务;任务内 httpx 调用 + 分类重试(5xx/超时重试 ≤3,401/400 立即失败,200 解析逐条结果),结果用独立 session 写回 `lead_push_logs`;lifespan 启动清扫 stale pending。admin 新增 `GET /api/admin/push-logs` 查询。
**Tech Stack:** FastAPI + SQLAlchemy 2.0 async(aiosqlite)+ httpx + pytest(asyncio auto,monkeypatch 桩风格)
**设计文档:** `docs/superpowers/specs/2026-09-28-lead-push-to-uniservice-design.md`(权威,含决策记录)
**契约文档:** `docs/AI_INQUIRY_PUSH_API.md`(UniService v1:幂等键 external_key、逐条结果、+08:00 时间、X-Api-Key)
**关键事实(实施者必读):**
1. `PUSH_ENABLED` 默认 `False`——所有既有测试零影响;联调时 `.env` 置 true。
2. 推送与 SMTP 成败**解耦**(设计 6.1):dispatch 在发信分支进入处调用,不看 `send_*` 返回值。
3. 后台任务**不读库只写结果**(设计 8.1):payload 内存传递;`create_task` 在主事务 commit 之后。
4. session factory 一律 `from app.main import async_session`(照 `chat_rest.py:20` 既有模式,conftest 注释已证明该循环导入安全),**测试用 monkeypatch 替换模块级绑定**。
5. 所有出站时间统一 `_fmt_cst()`:DB 写入是 UTC-aware(`datetime.now(timezone.utc)`),SQLite 读回可能 naive(按 UTC 补 tzinfo)→ 转 +08:00。对方明确拒收 UTC。
6. 测试风格照 `tests/test_lead_email_trigger.py`:**同步 def + `asyncio.run(...)`** + FakeAI 桩 + monkeypatch;`async_db` fixture 提供临时 SQLite sessionmaker。
7. 单文件事件循环注意:`asyncio.run` 每次新建 loop——**同 loop 内消费 create_task 任务**的测试必须把"驱动 stream + 等任务"放进同一个 `asyncio.run`。
---
### Task 1: `lead_push_logs` 表模型
**Files:**
- Modify: `app/models/database.py`(文件末尾,`Evaluation` 类之后追加)
- Test: `tests/test_lead_push_service.py`(新建)
- [ ] **Step 1: 写失败测试**
新建 `tests/test_lead_push_service.py`:
```python
"""询盘推送 UniService:模型/服务单元测试(设计 2026-09-28-lead-push-to-uniservice)。"""
import asyncio
from sqlalchemy import select
from app.models.database import (
Conversation, CustomerProfile, Evaluation, IntentLevel, LeadPushLog,
Message, MessageRole,
)
def test_lead_push_log_roundtrip(async_db):
async def run():
async with async_db() as db:
conv = Conversation(session_id="s1")
db.add(conv)
await db.flush()
ev = Evaluation(
conversation_id=conv.id,
intent_level=IntentLevel.pending, contact_info={},
)
db.add(ev)
await db.flush()
db.add(LeadPushLog(
evaluation_id=ev.id, conversation_id=conv.id,
push_type="lead", status="pending",
))
await db.commit()
row = (await db.execute(select(LeadPushLog))).scalar_one()
assert row.id and len(row.id) == 36 # UUID 主键,同既有四表
assert row.status == "pending" and row.attempts == 0
assert row.outcome is None and row.response_status is None
asyncio.run(run())
```
- [ ] **Step 2: 跑测试确认失败**
Run: `python -m pytest tests/test_lead_push_service.py -v`
Expected: FAIL — `ImportError: cannot import name 'LeadPushLog'`
- [ ] **Step 3: 加模型**
`app/models/database.py` 末尾(`Evaluation` 类之后)追加:
```python
class LeadPushLog(Base):
"""询盘推送 UniService 日志(设计 2026-09-28-lead-push-to-uniservice)。
status: pending → success/failed;skipped = contact 三选一(phone/whatsapp/
email)不满足,对方必拒,预检后不发 HTTP 只留审计痕迹。
outcome: 200 时对方逐条结论 accepted/duplicated(duplicated 不是错误)。
"""
__tablename__ = "lead_push_logs"
id: Mapped[str] = mapped_column(
String(36), primary_key=True, default=lambda: str(uuid.uuid4())
)
evaluation_id: Mapped[str] = mapped_column(
ForeignKey("evaluations.id", ondelete="CASCADE"), index=True
)
conversation_id: Mapped[str] = mapped_column(
ForeignKey("conversations.id", ondelete="CASCADE"), index=True
)
push_type: Mapped[str] = mapped_column(String(16)) # lead/returning/correction
status: Mapped[str] = mapped_column(String(16), default="pending", index=True)
attempts: Mapped[int] = mapped_column(Integer, default=0)
request_payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
response_status: Mapped[int | None] = mapped_column(Integer, nullable=True)
outcome: Mapped[str | None] = mapped_column(String(16), nullable=True)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(
default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
```
(启动时 `Base.metadata.create_all` 自动建表,老库无影响——`app/main.py:36` 既有机制。)
- [ ] **Step 4: 跑测试确认通过**
Run: `python -m pytest tests/test_lead_push_service.py -v`
Expected: 1 passed
- [ ] **Step 5: Commit**
```bash
git add app/models/database.py tests/test_lead_push_service.py
git commit -m "feat(push): LeadPushLog 推送日志表——询盘推送 UniService Task1"
```
---
### Task 2: PUSH_* 配置
**Files:**
- Modify: `app/config.py`(`ADMIN_PASSWORD` 之前插入)
- Modify: `.env.example`(末尾"应用配置"之前插入)
- Test: `tests/test_lead_push_service.py`(追加)
- [ ] **Step 1: 写失败测试**
`tests/test_lead_push_service.py` 追加:
```python
def test_push_settings_defaults():
from app.config import Settings
s = Settings()
assert s.PUSH_ENABLED is False
assert s.PUSH_API_URL == (
"https://uniservice.server.hnxinyouhe.com/open/v1/inquiry/push")
assert s.PUSH_API_KEY == ""
assert s.PUSH_EXTERNAL_KEY_PREFIX == "ai-kefu"
assert s.PUSH_TIMEOUT_SECONDS == 10.0
assert s.PUSH_MAX_ATTEMPTS == 3
assert s.PUSH_RETRY_BACKOFF_BASE == 1.0
```
- [ ] **Step 2: 跑测试确认失败**
Run: `python -m pytest tests/test_lead_push_service.py::test_push_settings_defaults -v`
Expected: FAIL — `AttributeError: 'Settings' object has no attribute 'PUSH_ENABLED'`
- [ ] **Step 3: 加配置**
`app/config.py` 在 `ADMIN_PASSWORD: str = "admin123"` 之前插入:
```python
# 询盘推送 UniService(docs/superpowers/specs/2026-09-28-lead-push-to-uniservice-design.md)
PUSH_ENABLED: bool = False
PUSH_API_URL: str = "https://uniservice.server.hnxinyouhe.com/open/v1/inquiry/push"
PUSH_API_KEY: str = ""
PUSH_EXTERNAL_KEY_PREFIX: str = "ai-kefu"
PUSH_TIMEOUT_SECONDS: float = 10.0
PUSH_MAX_ATTEMPTS: int = 3
PUSH_RETRY_BACKOFF_BASE: float = 1.0 # 尝试间隔 1s/2s(3 次尝试、2 次间隔)
```
`.env.example` 在 `# 应用配置` 之前插入:
```
# 询盘推送 UniService
PUSH_ENABLED=false
PUSH_API_URL=https://uniservice.server.hnxinyouhe.com/open/v1/inquiry/push
PUSH_API_KEY=your_uniservice_api_key_here
PUSH_EXTERNAL_KEY_PREFIX=ai-kefu
```
- [ ] **Step 4: 跑测试确认通过**
Run: `python -m pytest tests/test_lead_push_service.py -v`
Expected: 2 passed
- [ ] **Step 5: Commit**
```bash
git add app/config.py .env.example tests/test_lead_push_service.py
git commit -m "feat(push): PUSH_* 配置——UniService 端点/开关/重试参数 Task2"
```
---
### Task 3: 服务骨架 + 纯函数(`_fmt_cst` / `map_intent`)+ httpx 依赖转正
**Files:**
- Create: `app/services/lead_push_service.py`
- Modify: `requirements.txt`(httpx 转正式依赖)
- Test: `tests/test_lead_push_service.py`(追加)
- [ ] **Step 1: 写失败测试**
追加:
```python
def test_fmt_cst_converts_utc_and_naive():
from datetime import datetime, timezone
from app.services.lead_push_service import _fmt_cst
aware = datetime(2026, 9, 28, 2, 30, 0, tzinfo=timezone.utc)
assert _fmt_cst(aware) == "2026-09-28T10:30:00+08:00" # UTC→+08:00,绝不发 UTC
naive = datetime(2026, 9, 28, 2, 30, 0) # SQLite 读回形态,按 UTC 补
assert _fmt_cst(naive) == "2026-09-28T10:30:00+08:00"
def test_map_intent():
from app.services.lead_push_service import map_intent
assert map_intent("interested", True) == "high"
assert map_intent("pending", True) == "medium"
assert map_intent("not_interested", True) == "low" # 销售优先级解读,设计 7.2 定案
assert map_intent("pending", False) == "unknown" # 未评估≠待确认(D2 语义)
assert map_intent("bogus", True) == "unknown"
```
- [ ] **Step 2: 跑测试确认失败**
Run: `python -m pytest tests/test_lead_push_service.py::test_fmt_cst_converts_utc_and_naive -v`
Expected: FAIL — `ModuleNotFoundError: No module named 'app.services.lead_push_service'`
- [ ] **Step 3: 建服务骨架**
`app/services/lead_push_service.py`:
```python
"""询盘推送 UniService 服务(设计 2026-09-28-lead-push-to-uniservice)。
三个入口:
- dispatch_lead_push: 触发点调用(发信分支进入处),写 pending/skipped 日志。
红线: 整体吞异常,绝不影响邮件主流程。
- fire_pending_pushes: 主事务 commit 之后调用,create_task 派生后台任务。
- sweep_stale_pending: lifespan 启动清扫(崩溃遗留的 pending → failed)。
后台任务红线(设计 8.1): 不读库只写结果,payload 内存传递;
结果写入用独立 session(from app.main import async_session,照 chat_rest 模式)。
"""
import asyncio
import logging
from datetime import datetime, timedelta, timezone
import httpx
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.main import async_session
from app.models.database import (
Conversation, CustomerProfile, Evaluation, IntentLevel, LeadPushLog,
Message, MessageRole,
)
from app.services.ip_service import resolve_country
logger = logging.getLogger(__name__)
# 对方契约(docs/AI_INQUIRY_PUSH_API.md): phone/whatsapp/email 至少一个有效
_TRIO = ("phone", "whatsapp", "email")
_CST = timezone(timedelta(hours=8))
_INTENT_MAP = {
"interested": "high",
"pending": "medium",
"not_interested": "low",
}
def _fmt_cst(dt: datetime) -> str:
"""UTC(-aware 或 naive)→ +08:00 ISO 串。对方明确拒收 UTC(契约 §2)。"""
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(_CST).isoformat()
def map_intent(level: str | None, evaluated: bool) -> str:
"""我方 IntentLevel → 对方 high/medium/low/unknown。
evaluated=False(<3 条客户消息,D2)→ unknown: 未评估不是"待确认",
与邮件标签 [未评估]/[待确认] 的区分同源。
"""
if not evaluated or not level:
return "unknown"
return _INTENT_MAP.get(level, "unknown")
_sleep = asyncio.sleep # 模块级绑定,测试 monkeypatch 记录退避序列用
```
`requirements.txt` 在 `aiosmtplib>=3.0.0` 之后加一行:
```
httpx>=0.27.0
```
- [ ] **Step 4: 跑测试确认通过**
Run: `python -m pytest tests/test_lead_push_service.py -v`
Expected: 4 passed
- [ ] **Step 5: Commit**
```bash
git add app/services/lead_push_service.py requirements.txt tests/test_lead_push_service.py
git commit -m "feat(push): 推送服务骨架——+08:00 格式化/意向映射/httpx 转正 Task3"
```
---
### Task 4: `dispatch_lead_push`(开关 / 三选一预检 / payload 组装 / event 规则)
**Files:**
- Modify: `app/services/lead_push_service.py`
- Test: `tests/test_lead_push_service.py`(追加)
- [ ] **Step 1: 写失败测试**
追加(含共用 seed helper):
```python
def _seed_push_ctx(async_db, client_info=None):
"""建 conv/profile/ev 三件套,返回 (conv, profile, ev)。"""
from app.models.database import ConversationSource
async def run():
async with async_db() as db:
conv = Conversation(
session_id="s-push", source=ConversationSource.widget,
client_info=client_info or {"ip": "8.8.8.8", "lang": "en"},
)
db.add(conv)
await db.flush()
profile = CustomerProfile(
conversation_id=conv.id, raw_info={"目标国家": "肯尼亚"})
ev = Evaluation(
conversation_id=conv.id,
intent_level=IntentLevel.interested, contact_info={})
db.add_all([profile, ev])
await db.commit()
return conv.id, profile.id, ev.id
return asyncio.run(run())
def test_dispatch_disabled_returns_none(async_db, monkeypatch):
from app.services import lead_push_service as svc
monkeypatch.setattr(svc.settings, "PUSH_ENABLED", False)
_seed_push_ctx(async_db)
async def run():
async with async_db() as db:
from app.models.database import Conversation, CustomerProfile, Evaluation
conv = (await db.execute(select(Conversation))).scalar_one()
profile = (await db.execute(select(CustomerProfile))).scalar_one()
ev = (await db.execute(select(Evaluation))).scalar_one()
return await svc.dispatch_lead_push(
db, ev, conv, profile, {"phone": "+254722111117"},
"lead", {"intent_level": "interested"}, True)
assert asyncio.run(run()) is None
def test_dispatch_wechat_only_marks_skipped(async_db, monkeypatch):
"""wechat-only: 我方发邮件(wechat 算 confirmed)但对方必拒三选一 → skipped 留痕不派发。"""
from app.services import lead_push_service as svc
monkeypatch.setattr(svc.settings, "PUSH_ENABLED", True)
_seed_push_ctx(async_db)
async def run():
async with async_db() as db:
from app.models.database import Conversation, CustomerProfile, Evaluation
conv = (await db.execute(select(Conversation))).scalar_one()
profile = (await db.execute(select(CustomerProfile))).scalar_one()
ev = (await db.execute(select(Evaluation))).scalar_one()
result = await svc.dispatch_lead_push(
db, ev, conv, profile, {"wechat": "li_ming_88"},
"lead", {"intent_level": "interested"}, True)
await db.commit()
return result
assert asyncio.run(run()) is None
logs = asyncio.run(_select_logs(async_db))
assert len(logs) == 1
assert logs[0].status == "skipped"
assert "phone/whatsapp/email" in logs[0].error_message
def _select_logs(async_db):
async def run():
async with async_db() as db:
return list((await db.execute(select(LeadPushLog))).scalars())
return asyncio.run(run())
def test_dispatch_pending_payload_structure(async_db, monkeypatch):
from app.services import lead_push_service as svc
monkeypatch.setattr(svc.settings, "PUSH_ENABLED", True)
_seed_push_ctx(async_db)
async def run():
async with async_db() as db:
from app.models.database import Conversation, CustomerProfile, Evaluation
conv = (await db.execute(select(Conversation))).scalar_one()
profile = (await db.execute(select(CustomerProfile))).scalar_one()
ev = (await db.execute(select(Evaluation))).scalar_one()
db.add(Message(conversation_id=conv.id, role=MessageRole.user,
content="my whatsapp is +254722111117"))
await db.commit()
confirmed = {"phone": "+254722111117", "whatsapp": "+254722111117"}
log = await svc.dispatch_lead_push(
db, ev, conv, profile, confirmed, "lead",
{"intent_level": "interested", "reasoning": "询库存留号"}, True)
await db.commit()
return conv, log
conv, log = asyncio.run(run())
assert log is not None and log.status == "pending"
p = log.request_payload
assert p["event"] == "new"
assert p["external_key"] == f"ai-kefu-{conv.id}"
assert p["occurred_at"].endswith("+08:00")
assert p["contact"]["phone"] == "+254722111117"
assert p["contact"]["skype"] is None
assert p["intent_level"] == "high" and p["intent_reason"] == "询库存留号"
assert p["profile"]["目标国家"] == "肯尼亚"
assert p["profile"]["source"] == "widget"
assert p["profile"]["country"] # 8.8.8.8 → 国名非空
assert p["conversation"][0]["role"] == "customer"
assert p["conversation"][0]["at"].endswith("+08:00")
def test_dispatch_correction_event_rules(async_db, monkeypatch):
"""7.1 保护规则: 同会话有 success 前科 → update;没有 → new。"""
from app.services import lead_push_service as svc
monkeypatch.setattr(svc.settings, "PUSH_ENABLED", True)
_seed_push_ctx(async_db)
async def run(with_prior_success):
async with async_db() as db:
from app.models.database import Conversation, CustomerProfile, Evaluation
conv = (await db.execute(select(Conversation))).scalar_one()
profile = (await db.execute(select(CustomerProfile))).scalar_one()
ev = (await db.execute(select(Evaluation))).scalar_one()
if with_prior_success:
db.add(LeadPushLog(
evaluation_id=ev.id, conversation_id=conv.id,
push_type="lead", status="success"))
await db.flush()
log = await svc.dispatch_lead_push(
db, ev, conv, profile, {"phone": "+254700000001"},
"correction", {"intent_level": "interested"}, True)
await db.commit()
return log.request_payload["event"]
assert asyncio.run(run(False)) == "new"
assert asyncio.run(run(True)) == "update"
def test_dispatch_swallows_exceptions(async_db, monkeypatch):
"""红线: dispatch 内部炸(如 payload 组装抛错)绝不向上抛。"""
from app.services import lead_push_service as svc
monkeypatch.setattr(svc.settings, "PUSH_ENABLED", True)
_seed_push_ctx(async_db)
async def boom(*a, **kw):
raise RuntimeError("boom")
monkeypatch.setattr(svc, "_build_payload", boom)
async def run():
async with async_db() as db:
from app.models.database import Conversation, CustomerProfile, Evaluation
conv = (await db.execute(select(Conversation))).scalar_one()
profile = (await db.execute(select(CustomerProfile))).scalar_one()
ev = (await db.execute(select(Evaluation))).scalar_one()
return await svc.dispatch_lead_push(
db, ev, conv, profile, {"phone": "+254700000001"},
"lead", {}, True)
assert asyncio.run(run()) is None
```
- [ ] **Step 2: 跑测试确认失败**
Run: `python -m pytest tests/test_lead_push_service.py -v -k dispatch`
Expected: FAIL — `AttributeError: module ... has no attribute 'dispatch_lead_push'`
- [ ] **Step 3: 实现 `_build_payload` 与 `dispatch_lead_push`**
`app/services/lead_push_service.py` 的 `_sleep = asyncio.sleep` 之后追加:
```python
async def _build_payload(
db: AsyncSession, ev: Evaluation, conv: Conversation,
profile: CustomerProfile, confirmed: dict, push_type: str,
evaluation: dict, evaluated: bool,
) -> dict:
"""组装契约报文(设计 7.2 映射表)。仅读当前事务,不产生副作用。"""
# 7.1 event 规则: 仅更正可能 update,且必须同会话此前推送成功过
# (不依赖对方"update 未知键"的未定义行为)
event = "new"
if push_type == "correction":
prior = await db.execute(
select(func.count()).select_from(LeadPushLog).where(
LeadPushLog.conversation_id == conv.id,
LeadPushLog.status == "success",
))
if (prior.scalar() or 0) > 0:
event = "update"
result = await db.execute(
select(Message)
.where(Message.conversation_id == conv.id)
.order_by(Message.created_at)
)
msgs = result.scalars().all()
raw_info = profile.raw_info or {}
client_info = conv.client_info or {}
profile_out = dict(raw_info)
profile_out["country"] = resolve_country(client_info.get("ip", ""))
profile_out["source"] = conv.source.value
if client_info.get("lang"):
profile_out["语言"] = client_info["lang"]
reasoning = (evaluation.get("reasoning") or "") if evaluated else ""
return {
"event": event,
"external_key": f"{settings.PUSH_EXTERNAL_KEY_PREFIX}-{conv.id}",
"occurred_at": _fmt_cst(datetime.now(timezone.utc)),
"contact": {
"name": None, # 我方 contact_info 快照无称呼字段,契约可空
"phone": confirmed.get("phone"),
"whatsapp": confirmed.get("whatsapp"),
"email": confirmed.get("email"),
"wechat": confirmed.get("wechat"),
"skype": None,
},
"intent_level": map_intent(evaluation.get("intent_level"), evaluated),
"intent_reason": reasoning or None,
"profile": profile_out,
"conversation": [
{
"role": "customer" if m.role == MessageRole.user else "ai",
"text": m.content,
"at": _fmt_cst(m.created_at),
}
for m in msgs if m.role in (MessageRole.user, MessageRole.assistant)
],
}
async def dispatch_lead_push(
db: AsyncSession, ev: Evaluation, conv: Conversation,
profile: CustomerProfile, confirmed: dict, push_type: str,
evaluation: dict, evaluated: bool,
) -> LeadPushLog | None:
"""触发点唯一入口: 返回待派发的 pending 行(加入当前事务,随主事务提交)。
红线(设计 6.1): 整体吞异常——任何失败只记日志,绝不影响邮件主流程;
触发条件 = 调用方进入发信分支,与 SMTP 成败解耦。
"""
try:
if not settings.PUSH_ENABLED:
return None
def _add(**kw) -> LeadPushLog:
log = LeadPushLog(
evaluation_id=ev.id, conversation_id=conv.id,
push_type=push_type, **kw,
)
db.add(log)
return log
# 三选一预检(设计 §4 skipped 语义): 对方必拒,不发 HTTP 只留审计痕迹
if not any(confirmed.get(k) for k in _TRIO):
_add(status="skipped", attempts=0,
error_message="no valid contact (phone/whatsapp/email)")
return None
payload = await _build_payload(
db, ev, conv, profile, confirmed, push_type, evaluation, evaluated)
log = _add(status="pending", attempts=0, request_payload=payload)
await db.flush() # 取 id 供后台任务写结果
return log
except Exception:
logger.exception("dispatch_lead_push failed (type=%s, conv=%s)",
push_type, conv.id)
return None
```
- [ ] **Step 4: 跑测试确认通过**
Run: `python -m pytest tests/test_lead_push_service.py -v`
Expected: 全部 passed(dispatch 相关 5 个 + 之前 4 个)
- [ ] **Step 5: Commit**
```bash
git add app/services/lead_push_service.py tests/test_lead_push_service.py
git commit -m "feat(push): dispatch_lead_push——开关/三选一预检/payload组装/event规则 Task4"
```
---
### Task 5: `_do_post` + `_run_push`(分类重试 + 200 逐条结果解析)
**Files:**
- Modify: `app/services/lead_push_service.py`
- Test: `tests/test_lead_push_service.py`(追加)
- [ ] **Step 1: 写失败测试**
追加(共用 helper):
```python
def _mk_push_log(async_db, status="pending"):
async def run():
async with async_db() as db:
from app.models.database import ConversationSource
conv = Conversation(session_id=f"s-{status}-{id(async_db)}",
source=ConversationSource.widget)
db.add(conv)
await db.flush()
ev = Evaluation(conversation_id=conv.id,
intent_level=IntentLevel.pending, contact_info={})
db.add(ev)
await db.flush()
log = LeadPushLog(evaluation_id=ev.id, conversation_id=conv.id,
push_type="lead", status=status,
request_payload={"external_key": "ai-kefu-x",
"event": "new"})
db.add(log)
await db.commit()
return log.id
return asyncio.run(run())
def _run_push_case(async_db, monkeypatch, do_post, log_id):
"""注入 _do_post/_sleep/async_session 后驱动 _run_push,返回退避序列。"""
from app.services import lead_push_service as svc
sleeps = []
async def fake_sleep(s):
sleeps.append(s)
monkeypatch.setattr(svc, "_do_post", do_post)
monkeypatch.setattr(svc, "_sleep", fake_sleep)
monkeypatch.setattr(svc, "async_session", async_db)
async def run():
await svc._run_push(log_id, {"external_key": "ai-kefu-x", "event": "new"})
asyncio.run(run())
return sleeps
def _get_log(async_db, log_id):
async def run():
async with async_db() as db:
return await db.get(LeadPushLog, log_id)
return asyncio.run(run())
def _resp(status, body=None):
import httpx
return httpx.Response(status, json=body or {})
def test_run_push_success_accepted(async_db, monkeypatch):
async def ok(payload):
return _resp(200, {"success": True, "data": {
"accepted": 1, "duplicated": 0, "failed": []}}), None
lid = _mk_push_log(async_db)
_run_push_case(async_db, monkeypatch, ok, lid)
log = _get_log(async_db, lid)
assert log.status == "success" and log.outcome == "accepted"
assert log.response_status == 200 and log.attempts == 1
def test_run_push_success_duplicated(async_db, monkeypatch):
async def dup(payload):
return _resp(200, {"success": True, "data": {
"accepted": 0, "duplicated": 1, "failed": []}}), None
lid = _mk_push_log(async_db)
_run_push_case(async_db, monkeypatch, dup, lid)
log = _get_log(async_db, lid)
assert log.status == "success" and log.outcome == "duplicated"
def test_run_push_200_item_rejected_no_retry(async_db, monkeypatch):
"""200 但本条在 failed[](如联系方式无效): failed+对方 reason,不重试。"""
calls = []
async def rejected(payload):
calls.append(1)
return _resp(200, {"success": True, "data": {
"accepted": 0, "duplicated": 0,
"failed": [{"external_key": "ai-kefu-x", "reason": "无有效联系方式"}]}}), None
lid = _mk_push_log(async_db)
sleeps = _run_push_case(async_db, monkeypatch, rejected, lid)
log = _get_log(async_db, lid)
assert log.status == "failed" and "无有效联系方式" in log.error_message
assert len(calls) == 1 and sleeps == []
def test_run_push_5xx_retries_then_failed(async_db, monkeypatch):
calls = []
async def always_500(payload):
calls.append(1)
return _resp(500), None
lid = _mk_push_log(async_db)
sleeps = _run_push_case(async_db, monkeypatch, always_500, lid)
log = _get_log(async_db, lid)
assert len(calls) == 3 and log.attempts == 3 and log.status == "failed"
assert log.response_status == 500
assert sleeps == [1.0, 2.0] # 指数退避: 3 次尝试 2 次间隔
def test_run_push_401_fails_fast(async_db, monkeypatch):
calls = []
async def unauthorized(payload):
calls.append(1)
return _resp(401), None
lid = _mk_push_log(async_db)
sleeps = _run_push_case(async_db, monkeypatch, unauthorized, lid)
log = _get_log(async_db, lid)
assert len(calls) == 1 and log.attempts == 1 and log.status == "failed"
assert sleeps == []
def test_run_push_transport_error_then_success(async_db, monkeypatch):
calls = []
async def flaky(payload):
calls.append(1)
if len(calls) == 1:
return None, "ConnectError('timeout')"
return _resp(200, {"success": True, "data": {
"accepted": 1, "duplicated": 0, "failed": []}}), None
lid = _mk_push_log(async_db)
sleeps = _run_push_case(async_db, monkeypatch, flaky, lid)
log = _get_log(async_db, lid)
assert log.status == "success" and log.attempts == 2 and sleeps == [1.0]
def test_run_push_crash_guard(async_db, monkeypatch):
"""红线: _do_post 抛非 HTTP 异常也不得向外传播。"""
async def boom(payload):
raise RuntimeError("boom")
lid = _mk_push_log(async_db)
_run_push_case(async_db, monkeypatch, boom, lid) # 不 raise 即通过
log = _get_log(async_db, lid)
assert log.status == "pending" # 结果未写,留给启动清扫兜底
```
- [ ] **Step 2: 跑测试确认失败**
Run: `python -m pytest tests/test_lead_push_service.py -v -k run_push`
Expected: FAIL — `AttributeError: ... has no attribute '_do_post'`
- [ ] **Step 3: 实现**
`app/services/lead_push_service.py` 追加:
```python
async def _do_post(payload: dict) -> tuple:
"""实际 HTTP 调用(独立函数便于测试 monkeypatch)。
返回 (resp, transport_error): 传输层异常不抛出,转成错误串由重试矩阵消化。
"""
try:
async with httpx.AsyncClient(timeout=settings.PUSH_TIMEOUT_SECONDS) as client:
resp = await client.post(
settings.PUSH_API_URL,
json={"messages": [payload]}, # 契约 §2: 批量结构,我方单条
headers={"X-Api-Key": settings.PUSH_API_KEY},
)
return resp, None
except httpx.HTTPError as exc:
return None, repr(exc)
def _retryable(resp) -> bool:
"""契约 §3: 5xx/超时可重试;401/400/其他 4xx 重试无益。200 不在此判定。"""
return resp is None or resp.status_code >= 500
async def _update_log(push_log_id: str, **fields) -> None:
"""后台任务写结果——独立 session + 小事务(设计 6.3)。"""
async with async_session() as session:
await session.execute(
update(LeadPushLog)
.where(LeadPushLog.id == push_log_id)
.values(**fields)
)
await session.commit()
async def _run_push(push_log_id: str, payload: dict) -> None:
"""后台任务体: 分类重试 + 200 逐条结果解析(设计 6.3/§10)。
红线: 全套 try/except,异常只记日志绝不逃逸(fire-and-forget)。
"""
try:
attempts = 0
while True:
attempts += 1
resp, transport_error = await _do_post(payload)
if _retryable(resp) and attempts < settings.PUSH_MAX_ATTEMPTS:
await _update_log(
push_log_id, attempts=attempts,
error_message=transport_error
or (f"http {resp.status_code}" if resp is not None else "unknown"),
)
await _sleep(
settings.PUSH_RETRY_BACKOFF_BASE * 2 ** (attempts - 1))
continue
break
if resp is not None and resp.status_code == 200:
try:
data = (resp.json().get("data") or {})
except ValueError:
data = {}
failed_mine = [
f for f in (data.get("failed") or [])
if f.get("external_key") == payload["external_key"]
]
if failed_mine:
await _update_log(
push_log_id, attempts=attempts, status="failed",
response_status=200,
error_message=str(failed_mine[0].get("reason", "rejected")),
)
else:
# 单条推送: accepted/duplicated 计数即本条结论
outcome = ("duplicated"
if (data.get("duplicated") or 0) > 0 else "accepted")
await _update_log(
push_log_id, attempts=attempts, status="success",
response_status=200, outcome=outcome,
)
return
# 终态失败: 重试耗尽(5xx/传输)或不可重试(401/400/其他)
if resp is not None and resp.status_code in (400, 401):
logger.error("lead push rejected (log_id=%s, http %s) — "
"密钥/报文问题需人工介入,不重试",
push_log_id, resp.status_code)
await _update_log(
push_log_id, attempts=attempts, status="failed",
response_status=resp.status_code if resp is not None else None,
error_message=transport_error
or (f"http {resp.status_code}" if resp is not None else "unknown"),
)
except Exception:
logger.exception("lead push task crashed (log_id=%s)", push_log_id)
```
- [ ] **Step 4: 跑测试确认通过**
Run: `python -m pytest tests/test_lead_push_service.py -v`
Expected: 全部 passed
- [ ] **Step 5: Commit**
```bash
git add app/services/lead_push_service.py tests/test_lead_push_service.py
git commit -m "feat(push): _run_push 重试矩阵——5xx退避/401快败/200逐条解析 Task5"
```
---
### Task 6: `fire_pending_pushes`(commit 后派生后台任务)
**Files:**
- Modify: `app/services/lead_push_service.py`
- Test: `tests/test_lead_push_service.py`(追加)
- [ ] **Step 1: 写失败测试**
追加:
```python
def test_fire_pending_creates_tasks(monkeypatch):
"""create_task 派发(不阻塞语义的结构保证);非 pending 不派。"""
from types import SimpleNamespace
from app.services import lead_push_service as svc
pushed = []
async def fake_run(log_id, payload):
pushed.append((log_id, payload))
monkeypatch.setattr(svc, "_run_push", fake_run)
logs = [
SimpleNamespace(id="id-pending", status="pending",
request_payload={"event": "new"}),
SimpleNamespace(id="id-skipped", status="skipped",
request_payload=None),
]
async def run():
svc.fire_pending_pushes(logs)
await asyncio.sleep(0) # 同 loop 内让 create_task 跑一轮
asyncio.run(run())
assert pushed == [("id-pending", {"event": "new"})]
```
- [ ] **Step 2: 跑测试确认失败**
Run: `python -m pytest tests/test_lead_push_service.py::test_fire_pending_creates_tasks -v`
Expected: FAIL — `AttributeError: ... has no attribute 'fire_pending_pushes'`
- [ ] **Step 3: 实现**
追加(放在 `_run_push` 之后):
```python
# 后台任务引用集: 防 GC(asyncio 官方要求),done 即移除
_tasks: set[asyncio.Task] = set()
def fire_pending_pushes(pushes: list[LeadPushLog]) -> None:
"""主事务 commit 之后调用(设计 8.1): payload 内存传递,任务不读库。
同步函数、纳秒级返回——这就是"不阻塞"的派发点。
"""
for log in pushes:
if log.status != "pending":
continue
task = asyncio.create_task(_run_push(log.id, log.request_payload))
_tasks.add(task)
task.add_done_callback(_tasks.discard)
```
- [ ] **Step 4: 跑测试确认通过**
Run: `python -m pytest tests/test_lead_push_service.py -v`
Expected: 全部 passed
- [ ] **Step 5: Commit**
```bash
git add app/services/lead_push_service.py tests/test_lead_push_service.py
git commit -m "feat(push): fire_pending_pushes——commit 后 create_task 派发 Task6"
```
---
### Task 7: `sweep_stale_pending`(启动清扫)
**Files:**
- Modify: `app/services/lead_push_service.py`
- Test: `tests/test_lead_push_service.py`(追加)
- [ ] **Step 1: 写失败测试**
追加:
```python
def test_sweep_stale_pending(async_db, monkeypatch):
from datetime import datetime, timezone
from app.services import lead_push_service as svc
monkeypatch.setattr(svc, "async_session", async_db)
_seed_push_ctx(async_db)
async def run():
async with async_db() as db:
from app.models.database import Conversation, Evaluation
conv = (await db.execute(select(Conversation))).scalar_one()
ev = (await db.execute(select(Evaluation))).scalar_one()
stale = LeadPushLog(
evaluation_id=ev.id, conversation_id=conv.id,
push_type="lead", status="pending",
updated_at=datetime(2020, 1, 1, tzinfo=timezone.utc))
fresh = LeadPushLog(
evaluation_id=ev.id, conversation_id=conv.id,
push_type="lead", status="pending")
done = LeadPushLog(
evaluation_id=ev.id, conversation_id=conv.id,
push_type="lead", status="success",
updated_at=datetime(2020, 1, 1, tzinfo=timezone.utc))
db.add_all([stale, fresh, done])
await db.commit()
n = await svc.sweep_stale_pending(stale_minutes=30)
assert n == 1
async with async_db() as db:
logs = list((await db.execute(select(LeadPushLog))).scalars())
by = {(l.status, l.error_message or ""): l for l in logs}
assert ("failed", "interrupted by restart") in by
statuses = sorted(l.status for l in logs)
assert statuses == ["failed", "pending", "success"]
asyncio.run(run())
```
- [ ] **Step 2: 跑测试确认失败**
Run: `python -m pytest tests/test_lead_push_service.py::test_sweep_stale_pending -v`
Expected: FAIL — `AttributeError: ... has no attribute 'sweep_stale_pending'`
- [ ] **Step 3: 实现**
追加:
```python
async def sweep_stale_pending(stale_minutes: int = 30) -> int:
"""启动清扫(设计 8.2): 崩溃遗留的 pending → failed。
已知限制(设计 §14): 推送成功但结果写回失败也会被标 failed——
幂等键保证将来补推安全(对方返回 duplicated),概率极低,接受。
"""
cutoff = datetime.now(timezone.utc) - timedelta(minutes=stale_minutes)
async with async_session() as session:
result = await session.execute(
update(LeadPushLog).where(
LeadPushLog.status == "pending",
LeadPushLog.updated_at < cutoff,
).values(status="failed", error_message="interrupted by restart")
)
await session.commit()
return result.rowcount or 0
```
- [ ] **Step 4: 跑测试确认通过**
Run: `python -m pytest tests/test_lead_push_service.py -v`
Expected: 全部 passed
- [ ] **Step 5: Commit**
```bash
git add app/services/lead_push_service.py tests/test_lead_push_service.py
git commit -m "feat(push): sweep_stale_pending——崩溃遗留 pending 启动清扫 Task7"
```
---
### Task 8: `conversation.py` 触发接线(三类型跟随 / SMTP 解耦 / commit 后派发)
**Files:**
- Modify: `app/services/conversation.py`(imports、`stream_process_message` 两处、`_maybe_send_lead_email`、新增 `_collect_push`)
- Test: `tests/test_lead_push_wiring.py`(新建)
- [ ] **Step 1: 写失败测试**
新建 `tests/test_lead_push_wiring.py`:
```python
"""询盘推送接线集成测试: 跟随邮件三类型/SMTP解耦/commit后派发(设计 §8)。"""
import asyncio
from sqlalchemy import select
from app.models.database import ConversationSource, LeadPushLog
from app.services import email_service, lead_push_service
from app.services.conversation import ConversationManager
class FakeAI:
"""同 test_lead_email_trigger 的确定性桩。"""
def __init__(self, extract=None):
self._extract_seq = extract if isinstance(extract, list) else [extract or {}]
async def stream_chat(self, messages, context=""):
for token in ("ok ", "reply"):
yield token
async def extract_info(self, messages, existing_info):
idx = min(max(len(messages) // 2 - 1, 0), len(self._extract_seq) - 1)
return self._extract_seq[idx]
async def evaluate(self, profile, recent_messages):
return {"intent_level": "interested", "reasoning": "留号",
"contact_info": {}, "info_sufficient": True}
def _setup(async_db, monkeypatch, ai, lead_succeeds=True):
"""邮件桩 + 推送桩: fire 只收集不真发(避免测试触网)。"""
sent = []
async def fake_lead(customer_profile, contact_info, reasoning,
conversation_messages, intent_level=""):
sent.append(("lead", dict(contact_info)))
return lead_succeeds
async def fake_nudge(contact_info):
sent.append(("returning", dict(contact_info)))
return lead_succeeds
async def fake_fix(contact_info, count=1):
sent.append(("correction", dict(contact_info)))
return lead_succeeds
monkeypatch.setattr(email_service, "send_lead_email", fake_lead)
monkeypatch.setattr(email_service, "send_returning_lead_email", fake_nudge)
monkeypatch.setattr(email_service, "send_correction_email", fake_fix)
fired = []
monkeypatch.setattr(lead_push_service, "fire_pending_pushes",
lambda logs: fired.extend(logs))
monkeypatch.setattr(lead_push_service.settings, "PUSH_ENABLED", True)
async def run():
async with async_db() as db:
manager = ConversationManager(db, ai_service=ai)
conv = await manager.create_conversation(
source=ConversationSource.widget)
return manager, conv
return asyncio.run(run()), sent, fired
def _send(manager, sid, text):
async def run():
async for _ in manager.stream_process_message(sid, text):
pass
asyncio.run(run())
def _logs(async_db):
async def run():
async with async_db() as db:
return list((await db.execute(select(LeadPushLog))).scalars())
return asyncio.run(run())
def test_lead_email_triggers_pending_push(async_db, monkeypatch):
ai = FakeAI(extract={"联系方式": {"手机": "+254722111117"}})
(manager, conv), sent, fired = _setup(async_db, monkeypatch, ai)
_send(manager, conv.session_id, "my whatsapp is +254722111117")
assert [s[0] for s in sent] == ["lead"]
assert len(fired) == 1 and fired[0].status == "pending"
assert fired[0].push_type == "lead"
assert fired[0].request_payload["event"] == "new"
assert fired[0].request_payload["contact"]["phone"] == "+254722111117"
asyncio.run(manager.db.close())
def test_smtp_failure_still_pushes(async_db, monkeypatch):
"""设计定案: 推送与 SMTP 成败解耦——邮件失败询盘照推。"""
ai = FakeAI(extract={"联系方式": {"手机": "+254722111117"}})
(manager, conv), sent, fired = _setup(
async_db, monkeypatch, ai, lead_succeeds=False)
_send(manager, conv.session_id, "my whatsapp is +254722111117")
assert sent == [] or sent[0][0] == "lead" # fake 返回 False,状态不置位
assert len(fired) == 1 and fired[0].status == "pending"
asyncio.run(manager.db.close())
def test_wechat_only_push_marked_skipped(async_db, monkeypatch):
ai = FakeAI(extract={"联系方式": {"微信": "li_ming_88"}})
(manager, conv), sent, fired = _setup(async_db, monkeypatch, ai)
_send(manager, conv.session_id, "add my wechat li_ming_88")
assert [s[0] for s in sent] == ["lead"] # 我方邮件照发(wechat 算 confirmed)
assert fired == [] # 无后台任务
logs = _logs(async_db)
assert len(logs) == 1 and logs[0].status == "skipped"
asyncio.run(manager.db.close())
def test_correction_push_event_update_after_success(async_db, monkeypatch):
"""改号 → 更正推送;前次已 success(手动置位模拟 _run_push)→ event=update。"""
ai = FakeAI(extract=[{"联系方式": {"手机": "+254722111117"}},
{"联系方式": {"手机": "+254799999999"}}])
(manager, conv), sent, fired = _setup(async_db, monkeypatch, ai)
_send(manager, conv.session_id, "my whatsapp is +254722111117")
assert len(fired) == 1 and fired[0].push_type == "lead"
async def mark_success():
async with async_db() as db:
for row in (await db.execute(select(LeadPushLog))).scalars():
row.status = "success"
await db.commit()
asyncio.run(mark_success())
_send(manager, conv.session_id, "wrong, use +254799999999")
assert [s[0] for s in sent] == ["lead", "correction"]
assert len(fired) == 2
assert fired[1].push_type == "correction"
assert fired[1].request_payload["event"] == "update"
asyncio.run(manager.db.close())
def test_returning_push_follows_nudge(async_db, monkeypatch):
"""24h 内同值另已发过 → 回访轻提醒 → 推送 push_type=returning,event=new。"""
ai1 = FakeAI(extract={"联系方式": {"email": "same@x.com"}})
(m1, c1), _s1, f1 = _setup(async_db, monkeypatch, ai1)
_send(m1, c1.session_id, "mail same@x.com")
assert len(f1) == 1 and f1[0].push_type == "lead"
asyncio.run(m1.db.close())
ai2 = FakeAI(extract={"联系方式": {"email": "same@x.com"}})
(m2, c2), sent2, f2 = _setup(async_db, monkeypatch, ai2)
_send(m2, c2.session_id, "im back, same@x.com")
assert [s[0] for s in sent2] == ["returning"]
assert len(f2) == 1 and f2[0].push_type == "returning"
assert f2[0].request_payload["event"] == "new"
asyncio.run(m2.db.close())
def test_push_disabled_writes_nothing(async_db, monkeypatch):
"""PUSH_ENABLED=false(默认): 零日志零派发,既有行为完全不变。"""
monkeypatch.setattr(lead_push_service.settings, "PUSH_ENABLED", False)
ai = FakeAI(extract={"联系方式": {"手机": "+254722111117"}})
sent = []
async def fake_lead(customer_profile, contact_info, reasoning,
conversation_messages, intent_level=""):
sent.append("lead")
return True
monkeypatch.setattr(email_service, "send_lead_email", fake_lead)
fired = []
monkeypatch.setattr(lead_push_service, "fire_pending_pushes",
lambda logs: fired.extend(logs))
async def run():
async with async_db() as db:
manager = ConversationManager(db, ai_service=ai)
conv = await manager.create_conversation(
source=ConversationSource.widget)
return manager, conv
(manager, conv) = asyncio.run(run())
_send(manager, conv.session_id, "my whatsapp is +254722111117")
assert sent == ["lead"] and fired == [] and _logs(async_db) == []
asyncio.run(manager.db.close())
def test_fire_end_to_end_single_loop(async_db, monkeypatch):
"""真 fire + 假 _run_push: 同一事件循环内 stream 完成后任务确实被派发执行。"""
pushed = []
async def fake_run(log_id, payload):
pushed.append(log_id)
monkeypatch.setattr(lead_push_service, "_run_push", fake_run)
monkeypatch.setattr(lead_push_service.settings, "PUSH_ENABLED", True)
sent = []
async def fake_lead(customer_profile, contact_info, reasoning,
conversation_messages, intent_level=""):
sent.append("lead")
return True
monkeypatch.setattr(email_service, "send_lead_email", fake_lead)
ai = FakeAI(extract={"联系方式": {"手机": "+254722111117"}})
async def run():
async with async_db() as db:
manager = ConversationManager(db, ai_service=ai)
conv = await manager.create_conversation(
source=ConversationSource.widget)
async for _ in manager.stream_process_message(
conv.session_id, "my whatsapp is +254722111117"):
pass
await asyncio.sleep(0) # 让 create_task 的任务跑一轮
asyncio.run(run())
assert sent == ["lead"] and len(pushed) == 1
```
- [ ] **Step 2: 跑测试确认失败**
Run: `python -m pytest tests/test_lead_push_wiring.py -v`
Expected: FAIL — 各测试断言 `fired == []`(dispatch 未接线,无人调用)
- [ ] **Step 3: 接线(四处小改)**
**3a.** `app/services/conversation.py` L14 改 import,并扩展模型 import(L10-13):
```python
from app.models.database import (
Conversation, ConversationStatus, ConversationSource,
Message, MessageRole, CustomerProfile, Evaluation, IntentLevel,
LeadPushLog,
)
from app.services import email_service, lead_push_service
```
**3b.** `stream_process_message` 中 L353-356(邮件触发处)改为:
```python
# 邮件触发(D1): 与会话结束彻底解耦,联系方式到手即发
pending_pushes: list[LeadPushLog] = []
await self._maybe_send_lead_email(
conv, profile, evaluation, evaluated=evaluation_ran,
pending_pushes=pending_pushes,
)
```
**3c.** 同函数 L377 `await self.db.commit()` 之后、`yield {"type": "evaluated"...}` 之前插入:
```python
# 询盘推送(设计 8.1): 主事务提交后才派生后台任务——payload 内存
# 传递,任务不读库只写结果;fire 为同步函数,纳秒级返回不阻塞
lead_push_service.fire_pending_pushes(pending_pushes)
```
**3d.** `_maybe_send_lead_email` 签名加参数,三个发信分支各加一行 `_collect_push`,并新增 `_collect_push` 方法:
```python
async def _maybe_send_lead_email(
self, conv: Conversation, profile: CustomerProfile, evaluation: dict,
evaluated: bool = True,
pending_pushes: list[LeadPushLog] | None = None,
) -> None:
```
`if not ev.email_sent:` 分支内(注意: dispatch 在 send **之前**——与 SMTP 成败彻底解耦):
```python
if any(canon(str(v)) in seen for v in confirmed.values()):
await self._collect_push(
pending_pushes, ev, conv, profile, confirmed,
"returning", evaluation, evaluated)
sent = await email_service.send_returning_lead_email(confirmed)
else:
await self._collect_push(
pending_pushes, ev, conv, profile, confirmed,
"lead", evaluation, evaluated)
messages = await self._load_history(conv.id)
sent = await email_service.send_lead_email(
customer_profile=profile.raw_info or {},
contact_info=confirmed,
reasoning=evaluation.get("reasoning", ""),
conversation_messages=messages,
intent_level=evaluation.get("intent_level", "") if evaluated else "",
)
```
更正分支(`if ev.correction_count < 3:` 内、`count = ...` 之后):
```python
if ev.correction_count < 3:
count = ev.correction_count + 1
await self._collect_push(
pending_pushes, ev, conv, profile, confirmed,
"correction", evaluation, evaluated)
if await email_service.send_correction_email(confirmed, count=count):
```
(D4' 锁邮件分支**不推**——邮件未触发,不在推送范围内。)
新增方法(放在 `_maybe_send_lead_email` 之后):
```python
async def _collect_push(
self, pending, ev, conv, profile, confirmed,
push_type, evaluation, evaluated,
) -> None:
"""询盘推送派发(设计 §8): 触发条件=进入发信分支,与 SMTP 成败解耦。
dispatch 内部吞异常(失败只记日志),这里无需再兜。
"""
log = await lead_push_service.dispatch_lead_push(
self.db, ev, conv, profile, confirmed, push_type,
evaluation, evaluated,
)
if log is not None and pending is not None:
pending.append(log)
```
- [ ] **Step 4: 跑新测试 + 既有邮件测试回归**
Run: `python -m pytest tests/test_lead_push_wiring.py tests/test_lead_email_trigger.py -v`
Expected: 全部 passed(既有邮件测试不受影响——默认关推送时 dispatch 直接 return None)
- [ ] **Step 5: Commit**
```bash
git add app/services/conversation.py tests/test_lead_push_wiring.py
git commit -m "feat(push): 询盘推送接线——三类型跟随/SMTP解耦/commit后派发 Task8"
```
---
### Task 9: admin 查询接口 `GET /api/admin/push-logs`
> **▶ 恢复执行(用户 2026-09-28 定)**:推送链路(Task 1-8、10)已上线,本任务继续。
**Files:**
- Modify: `app/api/routes/admin.py`(imports + 新路由)
- Test: `tests/test_admin_push_logs.py`(新建)
- [ ] **Step 1: 写失败测试**
新建 `tests/test_admin_push_logs.py`:
```python
"""admin 推送日志查询接口(设计 §9): 鉴权/筛选/分页/不含 payload。"""
import asyncio
import os
import tempfile
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from sqlalchemy.pool import NullPool
from app.models.database import (
Base, Conversation, CustomerProfile, Evaluation, IntentLevel, LeadPushLog,
)
# admin 路由用 `from app.main import async_session` 的模块级绑定——
# 测试必须 patch admin 模块自己的绑定(client fixture 只 patch 了 chat_rest)
import app.api.routes.admin as admin_mod
@pytest.fixture
def admin_client(monkeypatch):
from app.main import app
fd, path = tempfile.mkstemp(suffix=".db")
os.close(fd)
engine = create_async_engine(
f"sqlite+aiosqlite:///{path}", poolclass=NullPool)
async def create_all():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
asyncio.run(create_all())
sm = async_sessionmaker(engine, expire_on_commit=False)
monkeypatch.setattr(admin_mod, "async_session", sm)
with TestClient(app) as c:
yield c, sm
asyncio.run(engine.dispose())
try:
os.remove(path)
except PermissionError:
pass
def _seed(sm):
async def run():
async with sm() as db:
conv = Conversation(session_id="s-admin")
db.add(conv)
await db.flush()
ev = Evaluation(conversation_id=conv.id,
intent_level=IntentLevel.pending, contact_info={})
db.add(ev)
await db.flush()
db.add_all([
LeadPushLog(evaluation_id=ev.id, conversation_id=conv.id,
push_type="lead", status="success", attempts=1,
outcome="accepted", response_status=200,
request_payload={"big": "x" * 1000}),
LeadPushLog(evaluation_id=ev.id, conversation_id=conv.id,
push_type="correction", status="failed", attempts=3,
response_status=500, error_message="http 500",
request_payload={"big": "y" * 1000}),
LeadPushLog(evaluation_id=ev.id, conversation_id=conv.id,
push_type="lead", status="skipped", attempts=0,
error_message="no valid contact",
request_payload=None),
])
await db.commit()
asyncio.run(run())
def test_push_logs_requires_auth(admin_client):
c, _ = admin_client
resp = c.get("/api/admin/push-logs")
assert resp.json() == {"error": "unauthorized"}
def test_push_logs_list_filter_pagination(admin_client):
c, sm = admin_client
_seed(sm)
assert c.post("/api/admin/login",
json={"password": "admin123"}).json()["ok"] is True
body = c.get("/api/admin/push-logs").json()
assert body["total"] == 3 and len(body["items"]) == 3
item = body["items"][0]
assert "request_payload" not in item # 设计 §9: 列表不回 payload
assert {i["status"] for i in body["items"]} == {"success", "failed", "skipped"}
only_failed = c.get("/api/admin/push-logs", params={"status": "failed"}).json()
assert only_failed["total"] == 1
assert only_failed["items"][0]["push_type"] == "correction"
assert only_failed["items"][0]["error_message"] == "http 500"
assert only_failed["items"][0]["attempts"] == 3
page2 = c.get("/api/admin/push-logs",
params={"page": 2, "size": 2}).json()
assert page2["total"] == 3 and len(page2["items"]) == 1
by_conv = c.get("/api/admin/push-logs",
params={"conversation_id": "no-such"}).json()
assert by_conv["total"] == 0
def test_push_logs_invalid_status_ignored(admin_client):
"""非法 status 值当未筛(与 conversations 的 intent 白名单风格一致)。"""
c, sm = admin_client
_seed(sm)
c.post("/api/admin/login", json={"password": "admin123"})
body = c.get("/api/admin/push-logs", params={"status": "bogus"}).json()
assert body["total"] == 3
```
- [ ] **Step 2: 跑测试确认失败**
Run: `python -m pytest tests/test_admin_push_logs.py -v`
Expected: FAIL — `404 Not Found`(路由不存在;鉴权用例则返回 404 而非 unauthorized)
- [ ] **Step 3: 实现路由**
`app/api/routes/admin.py`:
imports 处(`from app.models.database import (...)` 列表加 `LeadPushLog`):
```python
from app.models.database import (
Conversation, ConversationStatus, Message, CustomerProfile, Evaluation, IntentLevel,
LeadPushLog,
)
```
`GET /conversations` 路由之前(或 `login/logout` 之后任意位置)加:
```python
@router.get("/push-logs")
async def list_push_logs(
admin_token: str | None = Cookie(None),
status: str | None = None,
conversation_id: str | None = None,
page: int = 1,
size: int = 20,
):
"""询盘推送日志(设计 2026-09-28-lead-push-to-uniservice §9)。
列表不返回 request_payload(含完整对话,体积大)——排障看库。
"""
if not _check_auth(admin_token):
return {"error": "unauthorized"}
async with async_session() as db:
query = select(LeadPushLog).order_by(LeadPushLog.created_at.desc())
if status in ("pending", "success", "failed", "skipped"):
query = query.where(LeadPushLog.status == status)
if conversation_id:
query = query.where(LeadPushLog.conversation_id == conversation_id)
total_result = await db.execute(
select(func.count()).select_from(query.subquery()))
total = total_result.scalar() or 0
result = await db.execute(query.offset((page - 1) * size).limit(size))
logs = result.scalars().all()
items = [{
"id": log.id,
"evaluation_id": log.evaluation_id,
"conversation_id": log.conversation_id,
"push_type": log.push_type,
"status": log.status,
"attempts": log.attempts,
"outcome": log.outcome,
"response_status": log.response_status,
"error_message": log.error_message,
"created_at": log.created_at.isoformat() if log.created_at else None,
"updated_at": log.updated_at.isoformat() if log.updated_at else None,
} for log in logs]
return {"total": total, "page": page, "size": size, "items": items}
```
- [ ] **Step 4: 跑测试确认通过**
Run: `python -m pytest tests/test_admin_push_logs.py -v`
Expected: 3 passed
- [ ] **Step 5: Commit**
```bash
git add app/api/routes/admin.py tests/test_admin_push_logs.py
git commit -m "feat(push): admin 推送日志查询——筛选/分页/无payload Task9"
```
---
### Task 10: lifespan 启动清扫接线 + changelog + 全量回归
**Files:**
- Modify: `app/main.py`(lifespan 内加 2 行)
- Create: `docs/2026-09-28-lead-push-changelog.md`
- Test: 既有 `tests/test_smoke.py` 覆盖应用可启动
- [ ] **Step 1: 接线清扫**
`app/main.py` lifespan 中、`async with engine.begin()` 块**结束之后**、`yield` 之前加(sweep 自带独立 session,不能嵌在 DDL 事务里,否则启动期 SQLite 写锁竞争):
```python
# 询盘推送(设计 8.2): 清扫崩溃遗留的 stale pending → failed
from app.services.lead_push_service import sweep_stale_pending
await sweep_stale_pending()
```
(import 放函数内是刻意的: 避免 main 顶部循环导入——`lead_push_service` 反向 `from app.main import async_session`。)
- [ ] **Step 2: 写 changelog**
`docs/2026-09-28-lead-push-changelog.md`:
```markdown
# 询盘推送 UniService · 改动记录(2026-09-28)
设计: docs/superpowers/specs/2026-09-28-lead-push-to-uniservice-design.md
契约: docs/AI_INQUIRY_PUSH_API.md(UniService v1)
## 改动面
- 新表 `lead_push_logs`(app/models/database.py): 推送审计,状态
pending/success/failed/skipped,outcome 记 accepted/duplicated
- 新服务 app/services/lead_push_service.py: dispatch(写日志,吞异常)→
fire(commit 后 create_task)→ _run_push(httpx + 分类重试 + 200 逐条解析)
- 接线 app/services/conversation.py: `_maybe_send_lead_email` 三发信分支
各调一次 dispatch(与 SMTP 成败解耦);主事务 commit 后 fire
- admin: GET /api/admin/push-logs(status/conversation_id 筛选 + 分页)
- 启动清扫: main.py lifespan → sweep_stale_pending(30min stale → failed)
- 配置: PUSH_ENABLED(默认 false)/PUSH_API_URL/PUSH_API_KEY/
PUSH_EXTERNAL_KEY_PREFIX/超时与重试参数(.env.example 同步)
## 上线/联调步骤
1. `.env` 填 `PUSH_API_KEY`(UniService 开通分配),`PUSH_ENABLED=true`
2. 产生 1-2 条真实询盘(留手机号/邮箱)→ 查 `GET /api/admin/push-logs`
3. 与 UniService 核对 accepted/duplicated/failed 与归档内容
(首验: 手机号国际区号格式——对方要求带区号,不符整条被拒)
4. 核对无误转正式运行
```
- [ ] **Step 3: 全量回归**
Run: `python -m pytest -q`
Expected: 全部 passed,0 failed(含既有 16 个测试文件;Windows 下 aiosqlite 的 access-violation stderr 噪音是已知无害现象,见 conftest 注释)
- [ ] **Step 4: Commit**
```bash
git add app/main.py docs/2026-09-28-lead-push-changelog.md
git commit -m "feat(push): lifespan 启动清扫接线+changelog——询盘推送收尾 Task10"
```
---
## 验收核对表(对照设计)
- [ ] 三种邮件触发各推一次,push_type 正确(设计 §1)
- [ ] SMTP 失败照推(决策记录)
- [ ] wechat-only → skipped 留痕不发 HTTP(§4)
- [ ] 5xx/超时退避 3 次 → failed;401/400 快败;200 解析逐条(§10)
- [ ] duplicated 记 success+outcome(契约 §3)
- [ ] 时间全部 +08:00(契约 §2,`_fmt_cst` 单点收口)
- [ ] external_key=prefix-conv.id;更正仅同会话已 success 才 update(§7.1)
- [ ] PUSH_ENABLED=false 零侵入(既有测试全绿为证)
- [ ] admin 可查、无 payload 外泄(§9)
- [ ] 崩溃遗留 pending 启动清扫(§8.2)