"""数据视图 API(Phase 2 Task 8): /api/platform/tenants/{slug}/conversations* 与 /push-logs——查询语义自 jc 旧单租户 admin.py 移植,路由形态多租户化。 移植要点(相对 jc 的差异均为多租户化所需,行为语义保持): - 数据跑在该租户 runtime 的 session_factory 上(各自租户库,天然隔离); runtime 按 slug 经 registry.runtimes() 匹配 + ensure_ready——resolve 系列 需要域名,而平台后台只有 slug;runtimes() 是加载期快照,ensure_ready 内部 的现势检查保证 reload 竞态下也不会产出孤儿 engine。未懒建租户(创建后 从未被域名 resolve)在此触发建库(ensure_schema 幂等),首次查询不炸。 - 鉴权 _tenant_guard(slug)(super 或本租户管理员;他租户 403,依赖先于 处理器跑,404 检查在其后——与 tenant_routes 同序)。 - 列表: 只收至少一条消息的会话(jc 语义,消息计数子查询 INNER JOIN)、 created_at 倒序、intent 筛选(evaluations 左联 + where)、分页 page/page_size(≤100)。新增 site 筛选(client_info["site"],WS init 与 REST ?site= 的会话站点标)与日期区间 from/to(ISO 日期或日期时间; 日期-only 的 to 视为含当日全天——边界含)。total 对筛选后的集合计数 (jc 会话列表的 total 不随 intent 筛选变,系旧缺陷;jc push-logs 的 total 就是筛选后计数,此处对齐后者)。 - 意向/推送状态非法值显性 400(jc 静默忽略 bogus 筛选,用户以为滤了 实则全量——改为显性拒绝)。 - 每行 IP 归国复用 ip_service.resolve_country(中文国名)。 - 详情: 会话元数据 + client_info 原样 + 画像 chips(raw_info 去 _ 前缀 键,_cc/_cc_asked 内部态不上桌——与 lead_push_service 的展示过滤同款) + 消息全量(空内容滤除,jc 语义)+ 意向块。 - 删除: jc 原序 evaluation → profile → message → conversation 显式逐表 (SQLite FK 强制默认关,ondelete=CASCADE 不会自动兑现),并补齐 lead_push_logs(jc 删除时该表会后遗孤儿行指向已删会话;FK 声明本就是 CASCADE,此处显式兑现声明语义)。不存在 → 404(jc 恒 {"ok": true}, 平台侧区分"删过了"与"删成功")。 - push-logs: load_only 排除 request_payload 大列(含完整对话,一页几十条 大 JSON 的 I/O 与反序列化都省掉);响应带 payload_size(func.length 服务端算,内容不出库)作省略标记。status/conversation_id 筛选与分页 同 jc。 """ import re from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, Query, Request from sqlalchemy import delete, func, select from sqlalchemy.orm import load_only from app.engine.models import ( Conversation, CustomerProfile, Evaluation, IntentLevel, LeadPushLog, Message, MessageRole, ) from app.engine.services.ip_service import resolve_country from app.platform.registry import TenantRuntime from app.platform.routes.tenant_routes import _tenant_guard router = APIRouter(prefix="/api/platform/tenants", tags=["platform-data"]) PAGE_SIZE_MAX = 100 INTENTS = ("interested", "not_interested", "pending") PUSH_STATUSES = ("pending", "success", "failed", "skipped") _DATE_ONLY = re.compile(r"^\d{4}-\d{2}-\d{2}$") # ---- 助手 ------------------------------------------------------------------------- async def _runtime_by_slug(request: Request, slug: str) -> TenantRuntime: """slug → 就绪 runtime:registry.runtimes() 快照里匹配 config.slug, 再 ensure_ready(懒建 engine + ensure_schema;未懒建租户在此触发建库)。 不走 resolve(需要域名,平台后台只有 slug)。ensure_ready 的现势检查 (条目被 reload 换掉则按新条目重走)与 per-slug 锁串行在此同样生效, 无孤儿 engine 窗口。registry 未加载该 slug → 404(DB 源下租户经创建 API/启动 load_all 进 registry,创建即可用)。""" registry = request.app.state.registry rt = next((r for r in registry.runtimes() if r.config.slug == slug), None) if rt is None: raise HTTPException(status_code=404, detail=f"租户不存在: {slug!r}") return await registry.ensure_ready(rt) def _page_params(page: int, page_size: int) -> tuple[int, int]: """分页参数校验: page ≥ 1,1 ≤ page_size ≤ 100(非法显性 400)。""" if page < 1: raise HTTPException(status_code=400, detail="page 须为正整数") if not 1 <= page_size <= PAGE_SIZE_MAX: raise HTTPException( status_code=400, detail=f"page_size 须为 1-{PAGE_SIZE_MAX}(实际 {page_size})") return page, page_size def _parse_dt(raw: str, param: str, *, end_of_day: bool = False) -> datetime: """ISO 日期/日期时间 → naive UTC(与库内存储同一对比空间)。 日期-only 的 to 视为含当日全天(23:59:59.999999)——边界含;带时区的 输入归一 UTC 后去 tzinfo。无法解析 → 400。""" try: dt = datetime.fromisoformat(raw) except (ValueError, TypeError) as exc: raise HTTPException( status_code=400, detail=f"{param}: 无法解析的 ISO 日期/日期时间: {raw!r}") from exc if end_of_day and _DATE_ONLY.fullmatch(raw.strip()): dt = dt.replace(hour=23, minute=59, second=59, microsecond=999999) if dt.tzinfo is not None: dt = dt.astimezone(timezone.utc).replace(tzinfo=None) return dt def _iso(value: datetime | None) -> str | None: return value.isoformat() if value else None def _enum_value(value): return value.value if value is not None else None # ---- 会话: 列表 / 详情 / 删除 ------------------------------------------------------- @router.get("/{slug}/conversations") async def list_conversations( slug: str, request: Request, intent: str | None = None, site: str | None = None, from_dt: str | None = Query(default=None, alias="from"), to_dt: str | None = Query(default=None, alias="to"), page: int = 1, page_size: int = 20, user=Depends(_tenant_guard)): """会话列表(jc admin 查询语义移植): 只有至少一条消息的会话、created_at 倒序、每行含意向/联系方式/email_sent/IP 归国。筛选 intent/site/日期 区间(边界含),分页 page/page_size(≤100)。""" page, size = _page_params(page, page_size) if intent is not None and intent not in INTENTS: raise HTTPException( status_code=400, detail=f"intent 只允许 {INTENTS}(实际 {intent!r})") bound_from = _parse_dt(from_dt, "from") if from_dt else None bound_to = _parse_dt(to_dt, "to", end_of_day=True) if to_dt else None rt = await _runtime_by_slug(request, slug) async with rt.session_factory() as db: # 消息计数 + 首条用户消息(预览)子查询——INNER JOIN 即"至少一条消息" msg_subq = ( select( Message.conversation_id, func.count().label("mc"), func.min(Message.content).filter( Message.role == MessageRole.user).label("first_user_msg"), ).group_by(Message.conversation_id).subquery()) stmt = ( select(Conversation, msg_subq.c.mc, msg_subq.c.first_user_msg, Evaluation) .join(msg_subq, msg_subq.c.conversation_id == Conversation.id) .outerjoin(Evaluation, Evaluation.conversation_id == Conversation.id) .order_by(Conversation.created_at.desc())) if intent: stmt = stmt.where(Evaluation.intent_level == IntentLevel(intent)) if site: stmt = stmt.where( Conversation.client_info["site"].as_string() == site) if bound_from is not None: stmt = stmt.where(Conversation.created_at >= bound_from) if bound_to is not None: stmt = stmt.where(Conversation.created_at <= bound_to) total = (await db.execute( select(func.count()).select_from(stmt.subquery()))).scalar() or 0 rows = (await db.execute( stmt.offset((page - 1) * size).limit(size))).all() items = [] for conv, msg_count, first_user_msg, evaluation in rows: client_info = conv.client_info or {} ip = client_info.get("ip", "") items.append({ "id": conv.id, "session_id": conv.session_id, "status": _enum_value(conv.status), "source": _enum_value(conv.source), "site": client_info.get("site"), "ip": ip, "device": client_info.get("user_agent", ""), "country": resolve_country(ip), "intent_level": _enum_value( evaluation.intent_level if evaluation else None), "reasoning": evaluation.reasoning if evaluation else None, "contact_info": evaluation.contact_info if evaluation else None, "email_sent": evaluation.email_sent if evaluation else False, "message_count": msg_count, "preview": (first_user_msg or "")[:50], "created_at": _iso(conv.created_at), }) return {"total": total, "page": page, "page_size": size, "items": items} @router.get("/{slug}/conversations/{conv_id}") async def conversation_detail( slug: str, conv_id: str, request: Request, user=Depends(_tenant_guard)): """会话详情: 元数据 + client_info 原样 + 画像 chips(raw_info 去 _ 前缀键)与 summary + 意向块 + 消息全量(时间升序,空内容滤除)。""" rt = await _runtime_by_slug(request, slug) async with rt.session_factory() as db: conv = (await db.execute( select(Conversation).where(Conversation.id == conv_id) )).scalar_one_or_none() if conv is None: raise HTTPException( status_code=404, detail=f"会话不存在: {conv_id!r}") messages = (await db.execute( select(Message) .where(Message.conversation_id == conv_id, Message.content != "") .order_by(Message.created_at))).scalars().all() profile = (await db.execute( select(CustomerProfile) .where(CustomerProfile.conversation_id == conv_id) )).scalar_one_or_none() evaluation = (await db.execute( select(Evaluation) .where(Evaluation.conversation_id == conv_id) )).scalar_one_or_none() client_info = conv.client_info or {} ip = client_info.get("ip", "") raw_info = (profile.raw_info if profile else None) or {} return { "id": conv.id, "session_id": conv.session_id, "status": _enum_value(conv.status), "source": _enum_value(conv.source), "client_info": client_info, "site": client_info.get("site"), "ip": ip, "device": client_info.get("user_agent", ""), "country": resolve_country(ip), "created_at": _iso(conv.created_at), "updated_at": _iso(conv.updated_at), "evaluation": None if evaluation is None else { "intent_level": _enum_value(evaluation.intent_level), "reasoning": evaluation.reasoning, "contact_info": evaluation.contact_info, "email_sent": evaluation.email_sent, "correction_count": evaluation.correction_count, "evaluated_at": _iso(evaluation.evaluated_at), }, "profile": None if profile is None else { "chips": {k: v for k, v in raw_info.items() if not str(k).startswith("_")}, "summary": profile.summary, }, "messages": [ {"role": _enum_value(m.role), "content": m.content, "time": _iso(m.created_at)} for m in messages if m.content and m.content.strip()], } @router.delete("/{slug}/conversations/{conv_id}") async def delete_conversation( slug: str, conv_id: str, request: Request, user=Depends(_tenant_guard)): """删除会话: jc 原序逐表显式删(evaluation → profile → [补齐 lead_push_logs] → message → conversation)——SQLite FK 强制默认关, ondelete=CASCADE 不自动兑现,显式删才不留孤儿。不存在 → 404。""" rt = await _runtime_by_slug(request, slug) async with rt.session_factory() as db: found = (await db.execute( select(Conversation.id).where(Conversation.id == conv_id) )).first() if found is None: raise HTTPException( status_code=404, detail=f"会话不存在: {conv_id!r}") await db.execute( delete(Evaluation).where(Evaluation.conversation_id == conv_id)) await db.execute( delete(CustomerProfile) .where(CustomerProfile.conversation_id == conv_id)) await db.execute( delete(LeadPushLog) .where(LeadPushLog.conversation_id == conv_id)) await db.execute( delete(Message).where(Message.conversation_id == conv_id)) await db.execute( delete(Conversation).where(Conversation.id == conv_id)) await db.commit() return {"ok": True} # ---- 推送日志 --------------------------------------------------------------------- @router.get("/{slug}/push-logs") async def list_push_logs( slug: str, request: Request, status: str | None = None, conversation_id: str | None = None, page: int = 1, page_size: int = 20, user=Depends(_tenant_guard)): """询盘推送日志列表(jc admin 语义移植): load_only 排除 request_payload 大列(含完整对话)——内容不出库,响应带 payload_size(func.length 服务端算)作省略标记,排障看库。status/conversation_id 筛选。""" page, size = _page_params(page, page_size) if status is not None and status not in PUSH_STATUSES: raise HTTPException( status_code=400, detail=f"status 只允许 {PUSH_STATUSES}(实际 {status!r})") rt = await _runtime_by_slug(request, slug) async with rt.session_factory() as db: stmt = ( select(LeadPushLog, func.length(LeadPushLog.request_payload) .label("payload_size")) .options(load_only( LeadPushLog.id, LeadPushLog.evaluation_id, LeadPushLog.conversation_id, LeadPushLog.push_type, LeadPushLog.status, LeadPushLog.attempts, LeadPushLog.outcome, LeadPushLog.response_status, LeadPushLog.error_message, LeadPushLog.created_at, LeadPushLog.updated_at)) .order_by(LeadPushLog.created_at.desc())) if status: stmt = stmt.where(LeadPushLog.status == status) if conversation_id: stmt = stmt.where(LeadPushLog.conversation_id == conversation_id) total = (await db.execute( select(func.count()).select_from(stmt.subquery()))).scalar() or 0 rows = (await db.execute( stmt.offset((page - 1) * size).limit(size))).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, "payload_size": payload_size, "created_at": _iso(log.created_at), "updated_at": _iso(log.updated_at), } for log, payload_size in rows] return {"total": total, "page": page, "page_size": size, "items": items}