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:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user