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>
This commit is contained in:
浅念
2026-10-12 21:37:48 +08:00
parent 8453d3cc56
commit 32b582aac8
15 changed files with 676 additions and 164 deletions
+65 -37
View File
@@ -8,7 +8,12 @@
后台任务红线(设计 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
@@ -28,11 +33,26 @@ from app.engine.services.ip_service import resolve_country
logger = logging.getLogger(__name__)
# Task 5b 装配切换: app.main 全局单例已删,写库入口改为显式传 session_factory
# (租户正确: 后台任务写结果必须落回派发租户的库)。模块级 async_session 仅作
# 无工厂上下文时的兜底(测试直接驱动 _run_push/sweep_stale_pending 时注入),
# 生产装配不设值——装配线(main.lifespan/ConversationManager)总是显式传。
async_session = None
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")
@@ -70,6 +90,7 @@ 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,且必须同会话此前推送成功过
@@ -125,7 +146,7 @@ async def _build_payload(
return {
"event": event,
"external_key": f"{settings.PUSH_EXTERNAL_KEY_PREFIX}-{conv.id}",
"external_key": f"{resolve_push_settings(config_data)['prefix']}-{conv.id}",
"occurred_at": _fmt_cst(datetime.now(timezone.utc)),
"contact": {
"name": None, # 我方 contact_info 快照无称呼字段,契约可空
@@ -153,6 +174,7 @@ 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 行(加入当前事务,随主事务提交)。
@@ -160,8 +182,10 @@ async def dispatch_lead_push(
触发条件 = 调用方进入发信分支,与 SMTP 成败解耦。
DB 层失败用 SAVEPOINT 隔离: 只回滚推送写入,不毒化调用方事务
(否则 flush 失败 → PendingRollbackError → email_sent 落不了库 → 重复发邮件)。
config_data = 租户配置(Task 9): 开关/前缀经 resolve_push_settings,
None 走 env settings(平台默认)。
"""
if not settings.PUSH_ENABLED:
if not resolve_push_settings(config_data)["enabled"]:
return None
try:
async with db.begin_nested():
@@ -174,7 +198,8 @@ async def dispatch_lead_push(
))
return None
payload = await _build_payload(
db, ev, conv, profile, confirmed, push_type, evaluation, evaluated)
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,
@@ -189,19 +214,22 @@ async def dispatch_lead_push(
return None
async def _do_post(payload: dict) -> tuple[httpx.Response | None, str | 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=settings.PUSH_TIMEOUT_SECONDS,
timeout=cfg["timeout"],
follow_redirects=True) as client: # 307/308 不落终态失败
resp = await client.post(
settings.PUSH_API_URL,
cfg["url"],
json={"messages": [payload]}, # 契约 §2: 批量结构,我方单条
headers={"X-Api-Key": settings.PUSH_API_KEY},
headers={"X-Api-Key": cfg["api_key"]},
)
return resp, None
except httpx.HTTPError as exc:
@@ -226,26 +254,27 @@ async def _update_log(session_factory, push_log_id: str, **fields) -> None:
async def _run_push(push_log_id: str, payload: dict,
session_factory=None) -> None:
session_factory, config_data: dict | None = None) -> None:
"""后台任务体: 分类重试 + 200 逐条结果解析(设计 6.3/§10)。
红线: 全套 try/except,异常只记日志绝不逃逸(fire-and-forget)。
session_factory 缺省回退模块级 async_session(测试注入缝)。
session_factory 必填(Task 9 收口): 派发租户的工厂,写回同一租户库。
重试矩阵参数(上限/退避)经 resolve_push_settings(租户配置, None 回退 env)。
"""
factory = session_factory or async_session
cfg = resolve_push_settings(config_data)
try:
attempts = 0
while True:
attempts += 1
resp, transport_error = await _do_post(payload)
if _retryable(resp) and attempts < settings.PUSH_MAX_ATTEMPTS:
resp, transport_error = await _do_post(payload, config_data)
if _retryable(resp) and attempts < cfg["max_attempts"]:
await _update_log(
factory, push_log_id, attempts=attempts,
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(
settings.PUSH_RETRY_BACKOFF_BASE * 2 ** (attempts - 1))
cfg["retry_backoff_base"] * 2 ** (attempts - 1))
continue
break
@@ -255,7 +284,7 @@ async def _run_push(push_log_id: str, payload: dict,
except ValueError:
# 非 JSON 的 200(如网关重定向到 HTML 登录页)——
# 无法确认对方已接受,诚实落 failed(幂等键保证补推安全)
await _update_log(factory, push_log_id, attempts=attempts,
await _update_log(session_factory, push_log_id, attempts=attempts,
status="failed",
response_status=200,
error_message="non-JSON 200 body")
@@ -268,8 +297,8 @@ async def _run_push(push_log_id: str, payload: dict,
]
if failed_mine:
await _update_log(
factory, push_log_id, attempts=attempts, status="failed",
response_status=200,
session_factory, push_log_id, attempts=attempts,
status="failed", response_status=200,
error_message=str(failed_mine[0].get("reason") or "rejected"),
)
else:
@@ -277,8 +306,8 @@ async def _run_push(push_log_id: str, payload: dict,
outcome = ("duplicated"
if (data.get("duplicated") or 0) > 0 else "accepted")
await _update_log(
factory, push_log_id, attempts=attempts, status="success",
response_status=200, outcome=outcome,
session_factory, push_log_id, attempts=attempts,
status="success", response_status=200, outcome=outcome,
)
return
@@ -288,7 +317,7 @@ async def _run_push(push_log_id: str, payload: dict,
"密钥/报文问题需人工介入,不重试",
push_log_id, resp.status_code)
await _update_log(
factory, push_log_id, attempts=attempts, status="failed",
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"),
@@ -301,34 +330,33 @@ async def _run_push(push_log_id: str, payload: dict,
_tasks: set[asyncio.Task] = set()
def fire_pending_pushes(pushes: list[LeadPushLog], session_factory=None) -> None:
def fire_pending_pushes(pushes: list[LeadPushLog], session_factory,
config_data: dict | None = None) -> None:
"""主事务 commit 之后调用(设计 8.1): payload 内存传递,任务不读库。
同步函数、纳秒级返回——这就是"不阻塞"的派发点。session_factory =
派发租户的工厂,随任务传递(后台写结果落回同一租户库);缺省回退
模块级 async_session(测试注入缝)。
同步函数、纳秒级返回——这就是"不阻塞"的派发点。session_factory
必填(Task 9 收口): 派发租户的工厂,随任务传递(后台写结果落回同一
租户库);config_data 同随任务传递(重试参数走租户配置)。
"""
factory = session_factory or async_session
for log in pushes:
if log.status != "pending":
continue
task = asyncio.create_task(
_run_push(log.id, log.request_payload, factory))
_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(stale_minutes: int = 30, session_factory=None) -> int:
async def sweep_stale_pending(session_factory, stale_minutes: int = 30) -> int:
"""启动清扫(设计 8.2): 崩溃遗留的 pending → failed。
session_factory: 目标租户的工厂(lifespan 逐租户调用);缺省回退
模块级 async_session(测试注入缝)。
session_factory 必填(Task 9 收口): 目标租户的工厂(lifespan 逐租户调用)。
已知限制(设计 §14): 推送成功但结果写回失败也会被标 failed——
幂等键保证将来补推安全(对方返回 duplicated),概率极低,接受。
"""
factory = session_factory or async_session
cutoff = datetime.now(timezone.utc) - timedelta(minutes=stale_minutes)
async with factory() as session:
async with session_factory() as session:
result = await session.execute(
update(LeadPushLog).where(
LeadPushLog.status == "pending",