Files
ai-kefu-jc/app/api/routes/admin.py
T
2026-09-29 14:15:12 +08:00

219 lines
8.1 KiB
Python

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}