import hashlib import secrets from datetime import datetime, timezone from fastapi import APIRouter, Cookie, Request, Response from sqlalchemy import delete, select, func from sqlalchemy.orm import load_only from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.main import async_session from app.models.database import ( Conversation, ConversationStatus, Message, CustomerProfile, Evaluation, IntentLevel, LeadPushLog, ) from app.services.ip_service import resolve_country router = APIRouter(prefix="/api/admin") _sessions: dict[str, float] = {} SESSION_TTL = 86400 def _check_auth(session_token: str | None) -> bool: if not session_token or session_token not in _sessions: return False if _sessions[session_token] < datetime.now(timezone.utc).timestamp() - SESSION_TTL: _sessions.pop(session_token, None) return False return True @router.post("/login") async def login(request: Request, response: Response): body = await request.json() password = body.get("password", "") if password != settings.ADMIN_PASSWORD: return {"ok": False, "error": "密码错误"} token = secrets.token_hex(16) _sessions[token] = datetime.now(timezone.utc).timestamp() response.set_cookie("admin_token", token, httponly=True, max_age=SESSION_TTL) return {"ok": True} @router.post("/logout") async def logout(response: Response, admin_token: str | None = Cookie(None)): if admin_token and admin_token in _sessions: _sessions.pop(admin_token) response.delete_cookie("admin_token") return {"ok": True} @router.get("/conversations") async def list_conversations( admin_token: str | None = Cookie(None), intent: str | None = None, page: int = 1, size: int = 20, ): if not _check_auth(admin_token): return {"error": "unauthorized"} async with async_session() as db: # Only show conversations that have at least 1 message msg_subq = ( select(Message.conversation_id, func.count().label("mc"), func.min(Message.content).filter(Message.role == "user").label("first_user_msg")) .group_by(Message.conversation_id) .subquery() ) query = ( select(Conversation, msg_subq.c.mc, msg_subq.c.first_user_msg) .join(msg_subq, msg_subq.c.conversation_id == Conversation.id) .order_by(Conversation.created_at.desc()) ) if intent and intent in ("interested", "not_interested", "pending"): query = query.join(Evaluation, Evaluation.conversation_id == Conversation.id, isouter=True) query = query.where(Evaluation.intent_level == intent) total_q = select(func.count()).select_from(msg_subq) total_result = await db.execute(total_q) total = total_result.scalar() or 0 result = await db.execute(query.offset((page - 1) * size).limit(size)) rows = result.all() items = [] for conv, msg_count, first_msg in rows: client_info = conv.client_info or {} ip = client_info.get("ip", "") country = resolve_country(ip) eval_result = await db.execute( select(Evaluation).where(Evaluation.conversation_id == conv.id) ) evaluation = eval_result.scalar_one_or_none() preview = (first_msg or "")[:50] items.append({ "id": conv.id, "session_id": conv.session_id, "status": conv.status.value, "source": conv.source.value, "ip": ip, "device": client_info.get("user_agent", ""), "country": country, "intent_level": evaluation.intent_level.value 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": preview, "created_at": conv.created_at.isoformat() if conv.created_at else None, }) return {"total": total, "page": page, "size": size, "items": items} @router.get("/push-logs") async def list_push_logs( admin_token: str | None = Cookie(None), status: str | None = None, conversation_id: str | None = None, page: int = 1, size: int = 20, ): """询盘推送日志(设计 2026-09-28-lead-push-to-uniservice §9)。 列表不返回 request_payload(含完整对话,体积大)——排障看库。 """ if not _check_auth(admin_token): return {"error": "unauthorized"} async with async_session() as db: # load_only: 列表不含 request_payload(含完整对话,体积大)—— # 直接不加载该列,省一页 ~20 条大 JSON 的 I/O 与反序列化 query = select(LeadPushLog).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 in ("pending", "success", "failed", "skipped"): query = query.where(LeadPushLog.status == status) if conversation_id: query = query.where(LeadPushLog.conversation_id == conversation_id) total_result = await db.execute( select(func.count()).select_from(query.subquery())) total = total_result.scalar() or 0 result = await db.execute(query.offset((page - 1) * size).limit(size)) logs = result.scalars().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, "created_at": log.created_at.isoformat() if log.created_at else None, "updated_at": log.updated_at.isoformat() if log.updated_at else None, } for log in logs] return {"total": total, "page": page, "size": size, "items": items} @router.get("/conversations/{conv_id}/messages") async def get_messages(conv_id: str, admin_token: str | None = Cookie(None)): if not _check_auth(admin_token): return {"error": "unauthorized"} async with async_session() as db: result = await db.execute( select(Message) .where(Message.conversation_id == conv_id, Message.content != "") .order_by(Message.created_at) ) messages = result.scalars().all() profile_result = await db.execute( select(CustomerProfile).where(CustomerProfile.conversation_id == conv_id) ) profile = profile_result.scalar_one_or_none() return { "messages": [ { "role": m.role.value, "content": m.content, "time": m.created_at.isoformat() if m.created_at else None, } for m in messages if m.content and m.content.strip() ], "profile": profile.raw_info if profile else {}, } @router.delete("/conversations/{conv_id}") async def delete_conversation(conv_id: str, admin_token: str | None = Cookie(None)): if not _check_auth(admin_token): return {"error": "unauthorized"} async with async_session() as db: 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(Message).where(Message.conversation_id == conv_id)) await db.execute(delete(Conversation).where(Conversation.id == conv_id)) await db.commit() return {"ok": True}