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:
Neo
2026-04-06 00:03:48 +08:00
parent 70324d8542
commit 6f8f12314f
515 changed files with 76604 additions and 7456 deletions

View File

@@ -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