49c0e5559c
Co-Authored-By: Claude Code <noreply@anthropic.com>
219 lines
8.1 KiB
Python
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}
|