Files
ai-kefu-admin/app/engine/services/lead_push_service.py
T
浅念 32b582aac8 feat: 服务层配置接线——email/push/rate_limit/AI凭据/行为开关/映射表/种子键
Task 9: 服务层对全局 settings 的业务引用清零,每个 DEFAULT_CONFIG 组
都有真实消费方。resolve_*_settings(email/push/ai)以空槽回退 env
(平台默认层);contact 行为开关/种子白名单/国名扩表/限流上限经
manager 的 runtime.config 接线。收口: lead_push session_factory 必填、
config.py 死代码清理、PromptProvider frozen、no_contact 视图直取、
AIService wall 参数定型 CompiledWall。403 测试全绿(389+14)。

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

368 lines
16 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 回退)。
出厂默认的空槽(False/空串)视同"未设置"→ 回退 env PUSH_*——部署级
.env 开关是平台默认集的一部分,租户未配置时继承,不静默关停。
已知边界: 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,
"timeout": p.get("timeout") or settings.PUSH_TIMEOUT_SECONDS,
"max_attempts": p.get("max_attempts") or settings.PUSH_MAX_ATTEMPTS,
"retry_backoff_base": (p.get("retry_backoff_base")
or 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