chore: 从汽车AI客服项目拷贝骨架并清理(blog页/redis)
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
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}
|
||||
@@ -0,0 +1,102 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
|
||||
from app.main import async_session
|
||||
from app.models.database import ConversationSource, ConversationStatus
|
||||
from app.services.conversation import ConversationManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.websocket("/ws/chat")
|
||||
async def ws_chat(websocket: WebSocket):
|
||||
await websocket.accept()
|
||||
session_id = None
|
||||
|
||||
try:
|
||||
async with async_session() as db:
|
||||
manager = ConversationManager(db)
|
||||
|
||||
# Collect client info
|
||||
client_ip = websocket.client.host if websocket.client else ""
|
||||
forwarded = websocket.headers.get("x-forwarded-for", "")
|
||||
if forwarded:
|
||||
client_ip = forwarded.split(",")[0].strip()
|
||||
user_agent = websocket.headers.get("user-agent", "")
|
||||
client_info = {"ip": client_ip, "user_agent": user_agent}
|
||||
|
||||
# First message: init or resume
|
||||
init_data = await websocket.receive_text()
|
||||
init_msg = json.loads(init_data)
|
||||
|
||||
if "session_id" in init_msg:
|
||||
conv = await manager.get_by_session(init_msg["session_id"])
|
||||
if conv and conv.status == ConversationStatus.active:
|
||||
session_id = conv.session_id
|
||||
await websocket.send_text(json.dumps({
|
||||
"type": "resumed",
|
||||
"session_id": session_id,
|
||||
}))
|
||||
else:
|
||||
session_id = None
|
||||
|
||||
# page_token: reuse active conversation from same page session
|
||||
if not session_id and "page_token" in init_msg and init_msg["page_token"]:
|
||||
conv = await manager.get_by_page_token(init_msg["page_token"])
|
||||
if conv:
|
||||
session_id = conv.session_id
|
||||
await websocket.send_text(json.dumps({
|
||||
"type": "resumed",
|
||||
"session_id": session_id,
|
||||
}))
|
||||
|
||||
if not session_id:
|
||||
source = ConversationSource(
|
||||
init_msg.get("source", "standalone")
|
||||
)
|
||||
merged_info = {**client_info, **(init_msg.get("client_info") or {})}
|
||||
conv = await manager.create_conversation(
|
||||
source=source,
|
||||
client_info=merged_info,
|
||||
page_token=init_msg.get("page_token"),
|
||||
)
|
||||
session_id = conv.session_id
|
||||
await websocket.send_text(json.dumps({
|
||||
"type": "init",
|
||||
"session_id": session_id,
|
||||
}))
|
||||
|
||||
# Chat loop
|
||||
while True:
|
||||
data = await websocket.receive_text()
|
||||
msg = json.loads(data)
|
||||
content = msg.get("content", "").strip()
|
||||
if not content:
|
||||
continue
|
||||
|
||||
ended = False
|
||||
|
||||
async for event in manager.stream_process_message(session_id, content):
|
||||
if event["type"] == "error":
|
||||
await websocket.send_text(json.dumps({
|
||||
"type": "error",
|
||||
"message": event["message"],
|
||||
}))
|
||||
break
|
||||
|
||||
await websocket.send_text(json.dumps(event, ensure_ascii=False))
|
||||
|
||||
if event["type"] == "evaluated" and event.get("ended"):
|
||||
ended = True
|
||||
break
|
||||
|
||||
if ended:
|
||||
break
|
||||
|
||||
except WebSocketDisconnect:
|
||||
logger.info("Client disconnected: %s", session_id)
|
||||
except Exception:
|
||||
logger.exception("WebSocket error for session %s", session_id)
|
||||
@@ -0,0 +1,142 @@
|
||||
"""REST shell for the HESU app (non-streaming, JSON-in/JSON-out).
|
||||
|
||||
These routes wrap `ConversationManager`, shared with the existing `/ws/chat`
|
||||
widget path. The app uses HTTP because it lacks a WebSocket client and is fine
|
||||
with a 3-8s "AI is typing" affordance covering the round trip.
|
||||
|
||||
Red lines (see spec):
|
||||
- WS path (`app/api/routes/chat.py`) shares `ConversationManager` with these
|
||||
routes; both paths stay drift-free.
|
||||
- `context` seeding is conditional + whitelist (handled in
|
||||
`ConversationManager.create_conversation`).
|
||||
- The message route consumes `stream_process_message`; lead email is
|
||||
triggered inside `ConversationManager` when contact info is detected
|
||||
(contact-driven-lead-email plan).
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.main import async_session
|
||||
from app.models.database import ConversationSource, ConversationStatus, Message
|
||||
from app.services.conversation import ConversationManager
|
||||
from app.services.rate_limit import check as rate_check
|
||||
|
||||
router = APIRouter(prefix="/api/chat", tags=["chat-rest"])
|
||||
|
||||
|
||||
class SessionRequest(BaseModel):
|
||||
session_id: str | None = None
|
||||
context: dict | None = None
|
||||
client_info: dict | None = None
|
||||
|
||||
|
||||
@router.post("/session")
|
||||
async def chat_session(req: SessionRequest):
|
||||
"""Resume an active conversation by session_id, or create a new app one.
|
||||
|
||||
`context` flows into `create_conversation` where it is whitelisted into
|
||||
`raw_info` (only when non-None). `client_info` is stored for analytics only
|
||||
(ip/ua/device-id/lang) and never fed to the AI prompt.
|
||||
"""
|
||||
async with async_session() as db:
|
||||
manager = ConversationManager(db)
|
||||
if req.session_id:
|
||||
conv = await manager.get_by_session(req.session_id)
|
||||
if conv and conv.status == ConversationStatus.active:
|
||||
return {
|
||||
"session_id": conv.session_id,
|
||||
"is_new": False,
|
||||
"ended": False,
|
||||
}
|
||||
conv = await manager.create_conversation(
|
||||
source=ConversationSource.app,
|
||||
client_info=req.client_info,
|
||||
context=req.context,
|
||||
)
|
||||
return {
|
||||
"session_id": conv.session_id,
|
||||
"is_new": True,
|
||||
"ended": False,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{session_id}/messages")
|
||||
async def chat_history(session_id: str):
|
||||
"""Public history endpoint for session replay. App-source only, to narrow
|
||||
the read surface; an unknown or non-app session yields 404.
|
||||
"""
|
||||
async with async_session() as db:
|
||||
manager = ConversationManager(db)
|
||||
conv = await manager.get_by_session(session_id)
|
||||
if not conv or conv.source != ConversationSource.app:
|
||||
raise HTTPException(status_code=404, detail="session not found")
|
||||
result = await db.execute(
|
||||
select(Message)
|
||||
.where(Message.conversation_id == conv.id)
|
||||
.order_by(Message.created_at)
|
||||
)
|
||||
msgs = result.scalars().all()
|
||||
return [
|
||||
{
|
||||
"role": m.role.value,
|
||||
"content": m.content,
|
||||
"created_at": m.created_at.isoformat(),
|
||||
}
|
||||
for m in msgs
|
||||
]
|
||||
|
||||
|
||||
class MessageRequest(BaseModel):
|
||||
session_id: str
|
||||
content: str
|
||||
|
||||
|
||||
@router.post("/message")
|
||||
async def chat_message(req: MessageRequest, request: Request):
|
||||
"""Process one app turn: stream-collect the AI reply, then evaluate.
|
||||
|
||||
🔴 Consumes `stream_process_message` so the REST path shares one code
|
||||
path with the WS widget and never drifts. Lead email is triggered inside
|
||||
`ConversationManager` when contact info is detected
|
||||
(contact-driven-lead-email plan) — this route carries no email logic.
|
||||
|
||||
A light in-memory rate limit (per ip + X-Device-Id, 30/h) guards GLM
|
||||
spend; only this route is throttled.
|
||||
"""
|
||||
ip = request.client.host if request.client else ""
|
||||
dev = request.headers.get("X-Device-Id", "")
|
||||
if not rate_check(ip, dev):
|
||||
raise HTTPException(status_code=429, detail="rate limit exceeded")
|
||||
|
||||
async with async_session() as db:
|
||||
manager = ConversationManager(db)
|
||||
conv = await manager.get_by_session(req.session_id)
|
||||
if not conv:
|
||||
raise HTTPException(status_code=404, detail="session not found")
|
||||
if conv.status != ConversationStatus.active:
|
||||
raise HTTPException(status_code=409, detail="conversation ended")
|
||||
|
||||
reply_parts: list[str] = []
|
||||
ended = False
|
||||
intent_level = "pending"
|
||||
async for event in manager.stream_process_message(
|
||||
req.session_id, req.content
|
||||
):
|
||||
t = event.get("type")
|
||||
if t == "stream_chunk":
|
||||
reply_parts.append(event.get("content", ""))
|
||||
elif t == "evaluated":
|
||||
ended = bool(event.get("ended"))
|
||||
intent_level = event.get("intent_level", "pending")
|
||||
elif t == "error":
|
||||
msg = event.get("message", "error")
|
||||
raise HTTPException(
|
||||
status_code=409 if "ended" in msg else 404, detail=msg
|
||||
)
|
||||
|
||||
return {
|
||||
"reply": "".join(reply_parts),
|
||||
"ended": ended,
|
||||
"intent_level": intent_level,
|
||||
}
|
||||
Reference in New Issue
Block a user