feat: chat integration, tenant admin spec, backend chat service, miniprogram updates, DEMO moved to tmp, XCX-TEST removed, migrations & docs

This commit is contained in:
Neo
2026-03-20 09:02:10 +08:00
parent 3d2e5f8165
commit beb88d5bea
388 changed files with 6436 additions and 25458 deletions

View File

@@ -0,0 +1,329 @@
# -*- coding: utf-8 -*-
"""
小程序 CHAT 路由 —— CHAT-1/2/3/4 端点。
替代原 xcx_ai_chat.py/api/ai/*),统一迁移到 /api/xcx/chat/* 路径。
端点清单:
- GET /api/xcx/chat/history — CHAT-1 对话历史列表
- GET /api/xcx/chat/{chat_id}/messages — CHAT-2a 通过 chatId 查询消息
- GET /api/xcx/chat/messages?contextType=&contextId= — CHAT-2b 通过上下文查询消息
- POST /api/xcx/chat/{chat_id}/messages — CHAT-3 发送消息(同步回复)
- POST /api/xcx/chat/stream — CHAT-4 SSE 流式端点
所有端点使用 require_approved() 权限检查。
"""
from __future__ import annotations
import json
import logging
import os
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import StreamingResponse
from app.ai.bailian_client import BailianClient
from app.auth.dependencies import CurrentUser
from app.database import get_connection
from app.middleware.permission import require_approved
from app.schemas.xcx_chat import (
ChatHistoryItem,
ChatHistoryResponse,
ChatMessageItem,
ChatMessagesResponse,
ChatStreamRequest,
MessageBrief,
ReferenceCard,
SendMessageRequest,
SendMessageResponse,
)
from app.services.chat_service import ChatService
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/xcx/chat", tags=["小程序 CHAT"])
# ── CHAT-1: 对话历史列表 ─────────────────────────────────────
@router.get("/history", response_model=ChatHistoryResponse)
async def list_chat_history(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
user: CurrentUser = Depends(require_approved()),
) -> ChatHistoryResponse:
"""CHAT-1: 查询当前用户的对话历史列表,按最后消息时间倒序。"""
svc = ChatService()
items, total = svc.get_chat_history(
user_id=user.user_id,
site_id=user.site_id,
page=page,
page_size=page_size,
)
return ChatHistoryResponse(
items=[ChatHistoryItem(**item) for item in items],
total=total,
page=page,
page_size=page_size,
)
# ── CHAT-2b: 通过上下文查询消息 ─────────────────────────────
# ⚠️ 必须在 /{chat_id}/messages 之前注册,否则 "messages" 会被当作 chat_id 路径参数
@router.get("/messages", response_model=ChatMessagesResponse)
async def get_chat_messages_by_context(
context_type: str = Query(..., alias="contextType"),
context_id: str = Query(..., alias="contextId"),
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=100),
user: CurrentUser = Depends(require_approved()),
) -> ChatMessagesResponse:
"""CHAT-2b: 通过上下文类型和 ID 查询消息(自动查找/创建对话)。"""
svc = ChatService()
# 按复用规则查找或创建对话
chat_id = svc.get_or_create_session(
user_id=user.user_id,
site_id=user.site_id,
context_type=context_type,
context_id=context_id if context_id else None,
)
messages, total, resolved_chat_id = svc.get_messages(
chat_id=chat_id,
user_id=user.user_id,
site_id=user.site_id,
page=page,
page_size=page_size,
)
return ChatMessagesResponse(
chat_id=resolved_chat_id,
items=[_to_message_item(m) for m in messages],
total=total,
page=page,
page_size=page_size,
)
# ── CHAT-2a: 通过 chatId 查询消息 ───────────────────────────
@router.get("/{chat_id}/messages", response_model=ChatMessagesResponse)
async def get_chat_messages(
chat_id: int,
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=100),
user: CurrentUser = Depends(require_approved()),
) -> ChatMessagesResponse:
"""CHAT-2a: 通过 chatId 查询对话消息列表,按 createdAt 正序。"""
svc = ChatService()
messages, total, resolved_chat_id = svc.get_messages(
chat_id=chat_id,
user_id=user.user_id,
site_id=user.site_id,
page=page,
page_size=page_size,
)
return ChatMessagesResponse(
chat_id=resolved_chat_id,
items=[_to_message_item(m) for m in messages],
total=total,
page=page,
page_size=page_size,
)
# ── CHAT-4: SSE 流式端点 ────────────────────────────────────
# ⚠️ 必须在 /{chat_id}/messages 之前注册,否则 "stream" 会被当作 chat_id 路径参数
@router.post("/stream")
async def chat_stream(
body: ChatStreamRequest,
user: CurrentUser = Depends(require_approved()),
) -> StreamingResponse:
"""CHAT-4: SSE 流式对话端点。
接收用户消息,通过百炼 API 流式返回 AI 回复。
SSE 事件类型message逐 token/ done完成/ error错误
chatId 归属验证:不属于当前用户返回 HTTP 403普通 JSON 错误,非 SSE
"""
if not body.content or not body.content.strip():
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="消息内容不能为空",
)
svc = ChatService()
content = body.content.strip()
# 归属验证(在 SSE 流开始前完成,失败时返回普通 HTTP 错误)
svc._verify_ownership(body.chat_id, user.user_id, user.site_id)
# 存入用户消息P5 PRD 合规:发送时即写入)
user_msg_id, user_created_at = svc._save_message(body.chat_id, "user", content)
async def event_generator():
"""SSE 事件生成器。
事件格式:
- event: message\\ndata: {"token": "..."}\\n\\n
- event: done\\ndata: {"messageId": ..., "createdAt": "..."}\\n\\n
- event: error\\ndata: {"message": "..."}\\n\\n
"""
full_reply_parts: list[str] = []
try:
bailian = _get_bailian_client()
# 获取历史消息作为上下文
messages = _build_ai_messages(body.chat_id)
# 流式调用百炼 API
async for chunk in bailian.chat_stream(messages):
full_reply_parts.append(chunk)
yield f"event: message\ndata: {json.dumps({'token': chunk}, ensure_ascii=False)}\n\n"
# 流结束:拼接完整回复并持久化
full_reply = "".join(full_reply_parts)
estimated_tokens = len(full_reply)
ai_msg_id, ai_created_at = svc._save_message(
body.chat_id, "assistant", full_reply, tokens_used=estimated_tokens,
)
svc._update_session_metadata(body.chat_id, full_reply)
# 发送 done 事件
done_data = json.dumps(
{"messageId": ai_msg_id, "createdAt": ai_created_at},
ensure_ascii=False,
)
yield f"event: done\ndata: {done_data}\n\n"
except Exception as e:
logger.error("SSE 流式对话异常: %s", e, exc_info=True)
# 如果已有部分回复,仍然持久化
if full_reply_parts:
partial = "".join(full_reply_parts)
try:
svc._save_message(body.chat_id, "assistant", partial)
svc._update_session_metadata(body.chat_id, partial)
except Exception:
logger.error("持久化部分回复失败", exc_info=True)
error_data = json.dumps(
{"message": "AI 服务暂时不可用"},
ensure_ascii=False,
)
yield f"event: error\ndata: {error_data}\n\n"
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
# ── CHAT-3: 发送消息(同步回复) ─────────────────────────────
@router.post("/{chat_id}/messages", response_model=SendMessageResponse)
async def send_message(
chat_id: int,
body: SendMessageRequest,
user: CurrentUser = Depends(require_approved()),
) -> SendMessageResponse:
"""CHAT-3: 发送用户消息并获取同步 AI 回复。
chatId 归属验证:不属于当前用户返回 HTTP 403。
AI 失败时返回错误提示消息HTTP 200
"""
if not body.content or not body.content.strip():
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="消息内容不能为空",
)
svc = ChatService()
result = await svc.send_message_sync(
chat_id=chat_id,
content=body.content.strip(),
user_id=user.user_id,
site_id=user.site_id,
)
return SendMessageResponse(
user_message=MessageBrief(**result["user_message"]),
ai_reply=MessageBrief(**result["ai_reply"]),
)
# ── 辅助函数 ─────────────────────────────────────────────────
def _to_message_item(msg: dict) -> ChatMessageItem:
"""将 chat_service 返回的消息 dict 转换为 ChatMessageItem。"""
ref_card = msg.get("reference_card")
reference_card = ReferenceCard(**ref_card) if ref_card and isinstance(ref_card, dict) else None
return ChatMessageItem(
id=msg["id"],
role=msg["role"],
content=msg["content"],
created_at=msg["created_at"],
reference_card=reference_card,
)
def _get_bailian_client() -> BailianClient:
"""从环境变量构建 BailianClient缺失时报错。"""
api_key = os.environ.get("BAILIAN_API_KEY")
base_url = os.environ.get("BAILIAN_BASE_URL")
model = os.environ.get("BAILIAN_MODEL")
if not api_key or not base_url or not model:
raise RuntimeError(
"百炼 API 环境变量缺失,需要 BAILIAN_API_KEY、BAILIAN_BASE_URL、BAILIAN_MODEL"
)
return BailianClient(api_key=api_key, base_url=base_url, model=model)
def _build_ai_messages(chat_id: int) -> list[dict]:
"""构建发送给 AI 的消息列表(含历史上下文)。"""
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT role, content FROM biz.ai_messages
WHERE conversation_id = %s
ORDER BY created_at ASC
""",
(chat_id,),
)
history = cur.fetchall()
finally:
conn.close()
messages: list[dict] = []
# 取最近 20 条
recent = history[-20:] if len(history) > 20 else history
for role, msg_content in recent:
messages.append({"role": role, "content": msg_content})
# 如果没有 system 消息,添加默认 system prompt
if not messages or messages[0]["role"] != "system":
system_prompt = {
"role": "system",
"content": json.dumps(
{"task": "你是台球门店的 AI 助手,根据用户的问题和当前页面上下文提供帮助。"},
ensure_ascii=False,
),
}
messages.insert(0, system_prompt)
return messages