1669 lines
62 KiB
Markdown
1669 lines
62 KiB
Markdown
|
|
# 询盘推送 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)
|