Files
ai-kefu-admin/app/engine/services/lead_push_service.py
T
浅念 49e2b476f5 fix: 配置接线审查修复(push数值哨兵化/恒真测试修正/哨兵防漂移/种子映射消双路径)
Important-1: push 数值字段(timeout/max_attempts/retry_backoff_base)
出厂默认改 None 哨兵(与 ai.model 同款),resolve_push_settings 对 None
回退 env——根治 truthy 出厂默认永久遮住 env 覆盖;恒真测试修正(env 数值
patch 成 7.5/5/2.0 与出厂区分)+租户显式数值优先断言;ai.provider 遮
env 的边界记入 docstring。Minor-2: 出厂 model 哨兵与 DEFAULT_CONFIG
一致性测试 + defaults.py 双源注释(model/rate_limit_per_hour)。
Minor-4: allowed_seed_keys → context_seed_map(返回映射,单一取值路径)。
Minor-6/7/3/8: WS 配置冻结时机注释/name_to_region 查表序注释改准确/
chat_rest limit 0 语义注释/resolver None 路径仅供测试注释。404 全绿。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-12 21:57:57 +08:00

379 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""询盘推送 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——session_factory 由派发方传入(Task 5b 起无全局
单例,写回派发它的那个租户的库;Task 9 收口: 转必填,漏传在调用点即
TypeError,不再拖到任务深处)。
Task 9 配置接线: PUSH 组(开关/URL/密钥/前缀/超时/重试)经
resolve_push_settings 从租户 config 解析,空槽回退 env settings——
各入口收 config_data(租户配置 dict),None = 平台默认(env)。
"""
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.engine.config import settings
from app.engine.models import (
Conversation, CustomerProfile, Evaluation, IntentLevel, LeadPushLog,
Message, MessageRole,
)
from app.engine.services.cc_completion import cc_state_for, completion_note, outbound_e164
from app.engine.services.ip_service import resolve_country
logger = logging.getLogger(__name__)
def resolve_push_settings(config_data: dict | None) -> dict:
"""租户 config.push → 推送参数(Task 9 接线; env settings 回退)。
空槽回退哨兵(审查 Important-1, 与 ai.model 同款纪律):
- 布尔/字符串: falsy 出厂默认(False/空串)视同"未设置"→ 回退 env
PUSH_*——部署级 .env 开关是平台默认集的一部分,租户未配置时继承,
不静默关停;
- 数值字段(timeout/max_attempts/retry_backoff_base): 出厂 None =
"未设置"→ 回退 env(settings 自带默认 10.0/3/1.0,最终行为与出厂
truthy 默认时一致),租户显式数值(含显式 0)优先于 env。
config_data=None 路径仅供测试直调,生产调用一律传 runtime.config.data。
已知边界: env 开 + 租户显式 false 无法区分于未配置(布尔空槽只有
False),Phase 2 admin 落地时以"平台清 env、配置显式写 false"解决。
"""
p = ((config_data or {}).get("push")) or {}
return {
"enabled": bool(p.get("enabled") or settings.PUSH_ENABLED),
"url": p.get("url") or settings.PUSH_API_URL,
"api_key": p.get("api_key") or settings.PUSH_API_KEY,
"prefix": p.get("prefix") or settings.PUSH_EXTERNAL_KEY_PREFIX,
# 数值哨兵: None(出厂/显式清空)→ env;显式数值优先
"timeout": (p.get("timeout") if p.get("timeout") is not None
else settings.PUSH_TIMEOUT_SECONDS),
"max_attempts": (p.get("max_attempts")
if p.get("max_attempts") is not None
else settings.PUSH_MAX_ATTEMPTS),
"retry_backoff_base": (p.get("retry_backoff_base")
if p.get("retry_backoff_base") is not None
else settings.PUSH_RETRY_BACKOFF_BASE),
}
# 对方契约(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)
dt = dt.replace(microsecond=0) # 归一整秒: 契约示例为整秒,亚秒精度无益且冒严格解析器拒收风险
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 记录退避序列用
async def _build_payload(
db: AsyncSession, ev: Evaluation, conv: Conversation,
profile: CustomerProfile, confirmed: dict, push_type: str,
evaluation: dict, evaluated: bool,
config_data: dict | None = None,
) -> 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 {}
# Task 6(spec §5 先消费后过滤,顺序钉死): 先读 raw_info 的 _cc 派生——
# contact 的 phone/whatsapp 换成对应渠道 e164(首封即补全号,确认与否都给;
# 无 e164 态用原文),标注进"号码补全说明"派生键;之后才做 _ 前缀过滤,
# _cc 本体绝不进 payload,派生键(无 _ 前缀)存活。canon 不匹配的残留态
# 在 cc_state_for 内判 None——旧补全永不跟随新号。
cc_map = raw_info.get("_cc") or {}
contact_out: dict = {}
cc_notes: list[str] = []
for k, v in (confirmed or {}).items():
if k in ("phone", "whatsapp") and v:
state = cc_state_for(cc_map, k, str(v))
contact_out[k] = outbound_e164(str(v), state)
note = completion_note(str(v), state)
if note:
cc_notes.append(note)
else:
contact_out[k] = v
# _ 前缀内部状态(一次性计数等)不进推送画像(spec §4.3——渲染侧
# 三处有过滤(enrich/reminder/email_service),推送端曾整体拷贝泄漏)
profile_out = {k: v for k, v in raw_info.items()
if not str(k).startswith("_")}
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"]
if cc_notes:
profile_out["号码补全说明"] = ";".join(cc_notes)
reasoning = (evaluation.get("reasoning") or "") if evaluated else ""
return {
"event": event,
"external_key": f"{resolve_push_settings(config_data)['prefix']}-{conv.id}",
"occurred_at": _fmt_cst(datetime.now(timezone.utc)),
"contact": {
"name": None, # 我方 contact_info 快照无称呼字段,契约可空
"phone": contact_out.get("phone"),
"whatsapp": contact_out.get("whatsapp"),
"email": contact_out.get("email"),
"wechat": contact_out.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,
config_data: dict | None = None,
) -> LeadPushLog | None:
"""触发点唯一入口: 返回待派发的 pending 行(加入当前事务,随主事务提交)。
红线(设计 6.1): 整体吞异常——任何失败只记日志,绝不影响邮件主流程;
触发条件 = 调用方进入发信分支,与 SMTP 成败解耦。
DB 层失败用 SAVEPOINT 隔离: 只回滚推送写入,不毒化调用方事务
(否则 flush 失败 → PendingRollbackError → email_sent 落不了库 → 重复发邮件)。
config_data = 租户配置(Task 9): 开关/前缀经 resolve_push_settings,
None 走 env settings(平台默认)。
"""
if not resolve_push_settings(config_data)["enabled"]:
return None
try:
async with db.begin_nested():
# 三选一预检(设计 §4 skipped 语义): 对方必拒,不发 HTTP 只留审计痕迹
if not any(confirmed.get(k) for k in _TRIO):
db.add(LeadPushLog(
evaluation_id=ev.id, conversation_id=conv.id,
push_type=push_type, 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, config_data)
log = LeadPushLog(
evaluation_id=ev.id, conversation_id=conv.id,
push_type=push_type, status="pending", attempts=0,
request_payload=payload,
)
db.add(log)
await db.flush() # 取 id 供后台任务写结果
return log
except Exception:
logger.exception("dispatch_lead_push failed (type=%s, conv=%s)",
push_type, conv.id)
return None
async def _do_post(payload: dict,
config_data: dict | None = None) -> tuple[httpx.Response | None, str | None]:
"""实际 HTTP 调用(独立函数便于测试 monkeypatch)。
返回 (resp, transport_error): 传输层异常不抛出,转成错误串由重试矩阵消化。
URL/密钥/超时经 resolve_push_settings(Task 9: 租户配置, None 回退 env)。
"""
cfg = resolve_push_settings(config_data)
try:
async with httpx.AsyncClient(
timeout=cfg["timeout"],
follow_redirects=True) as client: # 307/308 不落终态失败
resp = await client.post(
cfg["url"],
json={"messages": [payload]}, # 契约 §2: 批量结构,我方单条
headers={"X-Api-Key": cfg["api_key"]},
)
return resp, None
except httpx.HTTPError as exc:
return None, repr(exc)
def _retryable(resp: httpx.Response | None) -> bool:
"""契约 §3: 5xx/超时可重试;401/400/其他 4xx 重试无益。200 不在此判定。"""
return resp is None or resp.status_code >= 500
async def _update_log(session_factory, push_log_id: str, **fields) -> None:
"""后台任务写结果——独立 session + 小事务(设计 6.3)。
session_factory: 派发租户的工厂(Task 5b),写回同一租户库。"""
async with session_factory() 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,
session_factory, config_data: dict | None = None) -> None:
"""后台任务体: 分类重试 + 200 逐条结果解析(设计 6.3/§10)。
红线: 全套 try/except,异常只记日志绝不逃逸(fire-and-forget)。
session_factory 必填(Task 9 收口): 派发租户的工厂,写回同一租户库。
重试矩阵参数(上限/退避)经 resolve_push_settings(租户配置, None 回退 env)。
"""
cfg = resolve_push_settings(config_data)
try:
attempts = 0
while True:
attempts += 1
resp, transport_error = await _do_post(payload, config_data)
if _retryable(resp) and attempts < cfg["max_attempts"]:
await _update_log(
session_factory, push_log_id, attempts=attempts,
error_message=transport_error
or (f"http {resp.status_code}" if resp is not None else "unknown"),
)
await _sleep(
cfg["retry_backoff_base"] * 2 ** (attempts - 1))
continue
break
if resp is not None and resp.status_code == 200:
try:
raw = resp.json()
except ValueError:
# 非 JSON 的 200(如网关重定向到 HTML 登录页)——
# 无法确认对方已接受,诚实落 failed(幂等键保证补推安全)
await _update_log(session_factory, push_log_id, attempts=attempts,
status="failed",
response_status=200,
error_message="non-JSON 200 body")
return
data = raw.get("data") if isinstance(raw, dict) else None
data = data if isinstance(data, dict) else {}
failed_mine = [
f for f in (data.get("failed") or [])
if f.get("external_key") == payload["external_key"]
]
if failed_mine:
await _update_log(
session_factory, push_log_id, attempts=attempts,
status="failed", response_status=200,
error_message=str(failed_mine[0].get("reason") or "rejected"),
)
else:
# 单条推送: accepted/duplicated 计数即本条结论
outcome = ("duplicated"
if (data.get("duplicated") or 0) > 0 else "accepted")
await _update_log(
session_factory, 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(
session_factory, 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)
# 后台任务引用集: 防 GC(asyncio 官方要求),done 即移除
_tasks: set[asyncio.Task] = set()
def fire_pending_pushes(pushes: list[LeadPushLog], session_factory,
config_data: dict | None = None) -> None:
"""主事务 commit 之后调用(设计 8.1): payload 内存传递,任务不读库。
同步函数、纳秒级返回——这就是"不阻塞"的派发点。session_factory
必填(Task 9 收口): 派发租户的工厂,随任务传递(后台写结果落回同一
租户库);config_data 同随任务传递(重试参数走租户配置)。
"""
for log in pushes:
if log.status != "pending":
continue
task = asyncio.create_task(
_run_push(log.id, log.request_payload, session_factory,
config_data=config_data))
_tasks.add(task)
task.add_done_callback(_tasks.discard)
async def sweep_stale_pending(session_factory, stale_minutes: int = 30) -> int:
"""启动清扫(设计 8.2): 崩溃遗留的 pending → failed。
session_factory 必填(Task 9 收口): 目标租户的工厂(lifespan 逐租户调用)。
已知限制(设计 §14): 推送成功但结果写回失败也会被标 failed——
幂等键保证将来补推安全(对方返回 duplicated),概率极低,接受。
"""
cutoff = datetime.now(timezone.utc) - timedelta(minutes=stale_minutes)
async with session_factory() 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