feat: 累积功能变更 — 聊天集成、租户管理、小程序更新、ETL 增强、迁移脚本
包含多个会话的累积代码变更: - backend: AI 聊天服务、触发器调度、认证增强、WebSocket、调度器最小间隔 - admin-web: ETL 状态页、任务管理、调度配置、登录优化 - miniprogram: 看板页面、聊天集成、UI 组件、导航更新 - etl: DWS 新任务(finance_area_daily/board_cache)、连接器增强 - tenant-admin: 项目初始化 - db: 19 个迁移脚本(etl_feiqiu 11 + zqyy_app 8) - packages/shared: 枚举和工具函数更新 - tools: 数据库工具、报表生成、健康检查 - docs: PRD/架构/部署/合约文档更新 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -18,12 +18,12 @@ 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.ai.config import AIConfig
|
||||
from app.ai.dashscope_client import DashScopeClient
|
||||
from app.auth.dependencies import CurrentUser
|
||||
from app.database import get_connection
|
||||
from app.middleware.permission import require_approved
|
||||
@@ -39,6 +39,14 @@ from app.schemas.xcx_chat import (
|
||||
SendMessageResponse,
|
||||
)
|
||||
from app.services.chat_service import ChatService
|
||||
from app.trace.decorators import trace_service
|
||||
from app.trace.sse_wrapper import (
|
||||
record_ai_call,
|
||||
record_ai_error,
|
||||
record_sse_end,
|
||||
record_sse_start,
|
||||
record_sse_token,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -49,6 +57,7 @@ router = APIRouter(prefix="/api/xcx/chat", tags=["小程序 CHAT"])
|
||||
|
||||
|
||||
@router.get("/history", response_model=ChatHistoryResponse)
|
||||
@trace_service("查询对话历史", "List chat history")
|
||||
async def list_chat_history(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
@@ -75,6 +84,7 @@ async def list_chat_history(
|
||||
|
||||
|
||||
@router.get("/messages", response_model=ChatMessagesResponse)
|
||||
@trace_service("通过上下文查询消息", "Get messages by context")
|
||||
async def get_chat_messages_by_context(
|
||||
context_type: str = Query(..., alias="contextType"),
|
||||
context_id: str = Query(..., alias="contextId"),
|
||||
@@ -111,6 +121,7 @@ async def get_chat_messages_by_context(
|
||||
|
||||
|
||||
@router.get("/{chat_id}/messages", response_model=ChatMessagesResponse)
|
||||
@trace_service("查询对话消息", "Get chat messages")
|
||||
async def get_chat_messages(
|
||||
chat_id: int,
|
||||
page: int = Query(1, ge=1),
|
||||
@@ -140,6 +151,7 @@ async def get_chat_messages(
|
||||
|
||||
|
||||
@router.post("/stream")
|
||||
@trace_service("SSE 流式对话", "Chat stream SSE")
|
||||
async def chat_stream(
|
||||
body: ChatStreamRequest,
|
||||
user: CurrentUser = Depends(require_approved()),
|
||||
@@ -174,16 +186,74 @@ async def chat_stream(
|
||||
- event: done\\ndata: {"messageId": ..., "createdAt": "..."}\\n\\n
|
||||
- event: error\\ndata: {"message": "..."}\\n\\n
|
||||
"""
|
||||
import time as _time
|
||||
|
||||
full_reply_parts: list[str] = []
|
||||
tokens_total = 0
|
||||
_sse_start_ts = _time.perf_counter()
|
||||
|
||||
# SSE trace: 流开始
|
||||
record_sse_start(
|
||||
endpoint="/api/xcx/chat/stream",
|
||||
user_id=user.user_id,
|
||||
chat_id=str(body.chat_id),
|
||||
)
|
||||
|
||||
try:
|
||||
bailian = _get_bailian_client()
|
||||
client = _get_dashscope_client()
|
||||
config = AIConfig.from_env()
|
||||
|
||||
# 获取历史消息作为上下文
|
||||
messages = _build_ai_messages(body.chat_id)
|
||||
# 构建 prompt(最近 20 条历史 + 当前消息已在历史中)
|
||||
prompt = _build_prompt(body.chat_id)
|
||||
|
||||
# 流式调用百炼 API
|
||||
async for chunk in bailian.chat_stream(messages):
|
||||
# 构建 biz_params(用户身份信息)
|
||||
biz_params = {
|
||||
"User_ID": str(user.user_id),
|
||||
"Role": getattr(user, "role", "coach"),
|
||||
"Nickname": getattr(user, "nickname", ""),
|
||||
}
|
||||
|
||||
# 看板入口:注入页面上下文到 prompt
|
||||
if body.source_page:
|
||||
try:
|
||||
from app.ai.data_fetchers import build_page_text
|
||||
|
||||
filters = {}
|
||||
if body.page_context:
|
||||
filters = body.page_context
|
||||
context_id = filters.pop("contextId", None)
|
||||
page_text = await build_page_text(
|
||||
source_page=body.source_page,
|
||||
context_id=context_id,
|
||||
site_id=user.site_id,
|
||||
filters=filters if filters else None,
|
||||
)
|
||||
if page_text:
|
||||
prompt = f"[页面上下文: {body.source_page}]\n{page_text}\n\n{prompt}"
|
||||
except Exception:
|
||||
logger.warning("页面上下文注入失败: source_page=%s", body.source_page, exc_info=True)
|
||||
|
||||
# 获取 session_id(对话复用)
|
||||
session_id = svc.get_session_id(body.chat_id) if hasattr(svc, "get_session_id") else None
|
||||
|
||||
# SSE trace: AI 调用
|
||||
record_ai_call(
|
||||
app_id=config.app_id_1_chat,
|
||||
prompt_length=len(prompt),
|
||||
session_id=session_id or "",
|
||||
)
|
||||
|
||||
# 流式调用 DashScope Application API
|
||||
async for chunk in client.call_app_stream(
|
||||
app_id=config.app_id_1_chat,
|
||||
prompt=prompt,
|
||||
session_id=session_id,
|
||||
biz_params=biz_params,
|
||||
):
|
||||
full_reply_parts.append(chunk)
|
||||
tokens_total += 1
|
||||
# SSE trace: 每 10 个 token 记录一次
|
||||
record_sse_token(token_count=1, total_tokens=tokens_total)
|
||||
yield f"event: message\ndata: {json.dumps({'token': chunk}, ensure_ascii=False)}\n\n"
|
||||
|
||||
# 流结束:拼接完整回复并持久化
|
||||
@@ -202,9 +272,23 @@ async def chat_stream(
|
||||
)
|
||||
yield f"event: done\ndata: {done_data}\n\n"
|
||||
|
||||
# SSE trace: 流正常结束
|
||||
_sse_elapsed = (_time.perf_counter() - _sse_start_ts) * 1000
|
||||
record_sse_end(
|
||||
total_tokens=tokens_total,
|
||||
total_duration_ms=_sse_elapsed,
|
||||
completed=True,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("SSE 流式对话异常: %s", e, exc_info=True)
|
||||
|
||||
# SSE trace: AI 错误
|
||||
record_ai_error(
|
||||
error_type=type(e).__name__,
|
||||
message=str(e),
|
||||
)
|
||||
|
||||
# 如果已有部分回复,仍然持久化
|
||||
if full_reply_parts:
|
||||
partial = "".join(full_reply_parts)
|
||||
@@ -220,6 +304,14 @@ async def chat_stream(
|
||||
)
|
||||
yield f"event: error\ndata: {error_data}\n\n"
|
||||
|
||||
# SSE trace: 流异常结束
|
||||
_sse_elapsed = (_time.perf_counter() - _sse_start_ts) * 1000
|
||||
record_sse_end(
|
||||
total_tokens=tokens_total,
|
||||
total_duration_ms=_sse_elapsed,
|
||||
completed=False,
|
||||
)
|
||||
|
||||
return StreamingResponse(
|
||||
event_generator(),
|
||||
media_type="text/event-stream",
|
||||
@@ -235,6 +327,7 @@ async def chat_stream(
|
||||
|
||||
|
||||
@router.post("/{chat_id}/messages", response_model=SendMessageResponse)
|
||||
@trace_service("发送消息", "Send message")
|
||||
async def send_message(
|
||||
chat_id: int,
|
||||
body: SendMessageRequest,
|
||||
@@ -280,27 +373,25 @@ def _to_message_item(msg: dict) -> ChatMessageItem:
|
||||
)
|
||||
|
||||
|
||||
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 _get_dashscope_client() -> DashScopeClient:
|
||||
"""从环境变量构建 DashScopeClient,缺失时报错。"""
|
||||
config = AIConfig.from_env()
|
||||
return DashScopeClient(api_key=config.api_key, workspace_id=config.workspace_id)
|
||||
|
||||
|
||||
def _build_ai_messages(chat_id: int) -> list[dict]:
|
||||
"""构建发送给 AI 的消息列表(含历史上下文)。"""
|
||||
def _build_prompt(chat_id: int) -> str:
|
||||
"""构建发送给 DashScope Application 的 prompt。
|
||||
|
||||
从 ai_messages 取最近 20 条历史,拼接为文本 prompt。
|
||||
百炼 Application API 的 System Prompt 在控制台配置,此处只传用户对话内容。
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT role, content FROM biz.ai_messages
|
||||
WHERE conversation_id = %s
|
||||
WHERE conversation_id = %s AND role != 'system'
|
||||
ORDER BY created_at ASC
|
||||
""",
|
||||
(chat_id,),
|
||||
@@ -309,21 +400,22 @@ def _build_ai_messages(chat_id: int) -> list[dict]:
|
||||
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)
|
||||
# 如果只有一条(刚发送的用户消息),直接返回内容
|
||||
if len(recent) == 1:
|
||||
return recent[0][1]
|
||||
|
||||
return messages
|
||||
# 多条历史:拼接为对话格式,最后一条为当前用户消息
|
||||
parts: list[str] = []
|
||||
for role, msg_content in recent[:-1]:
|
||||
label = "用户" if role == "user" else "AI"
|
||||
parts.append(f"{label}: {msg_content}")
|
||||
|
||||
# 最后一条是当前用户消息,作为主 prompt
|
||||
current_msg = recent[-1][1] if recent else ""
|
||||
if parts:
|
||||
context = "\n".join(parts)
|
||||
return f"[历史对话]\n{context}\n\n[当前问题]\n{current_msg}"
|
||||
return current_msg
|
||||
|
||||
Reference in New Issue
Block a user