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:
@@ -24,12 +24,12 @@ from app import config
|
||||
# CHANGE 2026-02-26 | member_birthday 路由替换为 member_retention_clue(维客线索重构)
|
||||
# CHANGE 2026-02-26 | 新增 admin_applications 路由(管理端申请审核)
|
||||
# CHANGE 2026-02-27 | 新增 xcx_tasks / xcx_notes 路由(小程序核心业务)
|
||||
# CHANGE 2026-03-09 | 新增 xcx_ai_chat 路由(AI SSE 对话 + 历史对话)
|
||||
# CHANGE 2026-03-09 | 新增 xcx_ai_chat 路由(AI SSE 对话 + 历史对话)→ 2026-03-20 迁移为 xcx_chat(/api/xcx/chat/*)
|
||||
# CHANGE 2026-03-09 | 新增 xcx_ai_cache 路由(AI 缓存查询)
|
||||
# CHANGE 2026-03-18 | 新增 xcx_customers 路由(CUST-1 客户详情、CUST-2 客户服务记录)
|
||||
# CHANGE 2026-03-19 | 新增 xcx_coaches 路由(COACH-1 助教详情)
|
||||
# CHANGE 2026-03-19 | 新增 xcx_board / xcx_config 路由(RNS1.3 三看板 + 技能类型配置)
|
||||
from app.routers import auth, execution, schedules, tasks, env_config, db_viewer, etl_status, xcx_test, wx_callback, member_retention_clue, ops_panel, xcx_auth, admin_applications, business_day, xcx_tasks, xcx_notes, xcx_ai_chat, xcx_ai_cache, xcx_performance, xcx_customers, xcx_coaches, xcx_board, xcx_config
|
||||
from app.routers import auth, execution, schedules, tasks, env_config, db_viewer, etl_status, xcx_test, wx_callback, member_retention_clue, ops_panel, xcx_auth, admin_applications, business_day, xcx_tasks, xcx_notes, xcx_chat, xcx_ai_cache, xcx_performance, xcx_customers, xcx_coaches, xcx_board, xcx_config
|
||||
from app.services.scheduler import scheduler
|
||||
from app.services.task_queue import task_queue
|
||||
from app.ws.logs import ws_router
|
||||
@@ -139,7 +139,7 @@ app.include_router(admin_applications.router)
|
||||
app.include_router(business_day.router)
|
||||
app.include_router(xcx_tasks.router)
|
||||
app.include_router(xcx_notes.router)
|
||||
app.include_router(xcx_ai_chat.router)
|
||||
app.include_router(xcx_chat.router)
|
||||
app.include_router(xcx_ai_cache.router)
|
||||
app.include_router(xcx_performance.router)
|
||||
app.include_router(xcx_customers.router)
|
||||
|
||||
@@ -1,223 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
小程序 AI 对话路由 —— SSE 流式对话、历史对话列表、消息查询。
|
||||
|
||||
端点清单:
|
||||
- POST /api/ai/chat/stream — SSE 流式对话
|
||||
- GET /api/ai/conversations — 历史对话列表(分页)
|
||||
- GET /api/ai/conversations/{conversation_id}/messages — 对话消息列表
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app.ai.bailian_client import BailianClient
|
||||
from app.ai.conversation_service import ConversationService
|
||||
from app.ai.apps.app1_chat import chat_stream
|
||||
from app.ai.schemas import ChatStreamRequest, SSEEvent
|
||||
from app.auth.dependencies import CurrentUser, get_current_user
|
||||
from app.database import get_connection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/ai", tags=["小程序 AI 对话"])
|
||||
|
||||
|
||||
# ── 辅助:获取用户 nickname ──────────────────────────────────
|
||||
|
||||
|
||||
def _get_user_nickname(user_id: int) -> str:
|
||||
"""从 auth.users 查询用户 nickname,查不到返回空字符串。"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT nickname FROM auth.users WHERE id = %s",
|
||||
(user_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return row[0] if row and row[0] else ""
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ── 辅助:获取用户主要角色 ───────────────────────────────────
|
||||
|
||||
|
||||
def _get_user_role_label(roles: list[str]) -> str:
|
||||
"""从角色列表提取主要角色标签,用于 AI 上下文。"""
|
||||
if "store_manager" in roles or "owner" in roles:
|
||||
return "管理者"
|
||||
if "assistant" in roles or "coach" in roles:
|
||||
return "助教"
|
||||
return "用户"
|
||||
|
||||
|
||||
# ── 辅助:构建 BailianClient 实例 ────────────────────────────
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
# ── SSE 流式对话 ─────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/chat/stream")
|
||||
async def ai_chat_stream(
|
||||
body: ChatStreamRequest,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
"""SSE 流式对话端点。
|
||||
|
||||
接收用户消息,通过百炼 API 流式返回 AI 回复。
|
||||
每个 SSE 事件格式:data: {json}\n\n
|
||||
事件类型:chunk(文本片段)/ done(完成)/ error(错误)
|
||||
"""
|
||||
if not body.message or not body.message.strip():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="消息内容不能为空",
|
||||
)
|
||||
|
||||
nickname = _get_user_nickname(user.user_id)
|
||||
role_label = _get_user_role_label(user.roles)
|
||||
bailian = _get_bailian_client()
|
||||
conv_svc = ConversationService()
|
||||
|
||||
async def event_generator():
|
||||
"""SSE 事件生成器,逐事件 yield data: {json}\n\n 格式。"""
|
||||
try:
|
||||
async for event in chat_stream(
|
||||
message=body.message.strip(),
|
||||
user_id=user.user_id,
|
||||
nickname=nickname,
|
||||
role=role_label,
|
||||
site_id=user.site_id,
|
||||
source_page=body.source_page,
|
||||
page_context=body.page_context,
|
||||
screen_content=body.screen_content,
|
||||
bailian=bailian,
|
||||
conv_svc=conv_svc,
|
||||
):
|
||||
yield f"data: {event.model_dump_json()}\n\n"
|
||||
except Exception as e:
|
||||
# 兜底:生成器内部异常也以 SSE error 事件返回
|
||||
logger.error("SSE 生成器异常: %s", e, exc_info=True)
|
||||
error_event = SSEEvent(type="error", message=str(e))
|
||||
yield f"data: {error_event.model_dump_json()}\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
event_generator(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no", # nginx 禁用缓冲
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ── 历史对话列表 ─────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/conversations")
|
||||
async def list_conversations(
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
"""查询当前用户的历史对话列表,按时间倒序,分页。"""
|
||||
if page < 1:
|
||||
page = 1
|
||||
if page_size < 1 or page_size > 100:
|
||||
page_size = 20
|
||||
|
||||
conv_svc = ConversationService()
|
||||
conversations = conv_svc.get_conversations(
|
||||
user_id=user.user_id,
|
||||
site_id=user.site_id,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
# 为每条对话附加首条消息预览
|
||||
result = []
|
||||
for conv in conversations:
|
||||
item = {
|
||||
"id": conv["id"],
|
||||
"app_id": conv["app_id"],
|
||||
"source_page": conv.get("source_page"),
|
||||
"created_at": conv["created_at"],
|
||||
"first_message_preview": None,
|
||||
}
|
||||
# 查询首条 user 消息作为预览
|
||||
messages = conv_svc.get_messages(conv["id"])
|
||||
for msg in messages:
|
||||
if msg["role"] == "user":
|
||||
content = msg["content"] or ""
|
||||
item["first_message_preview"] = content[:50] if len(content) > 50 else content
|
||||
break
|
||||
result.append(item)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ── 对话消息列表 ─────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/conversations/{conversation_id}/messages")
|
||||
async def get_conversation_messages(
|
||||
conversation_id: int,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
"""查询指定对话的所有消息,按时间升序。
|
||||
|
||||
验证对话归属当前用户和 site_id,防止越权访问。
|
||||
"""
|
||||
# 先验证对话归属
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id FROM biz.ai_conversations
|
||||
WHERE id = %s AND user_id = %s AND site_id = %s
|
||||
""",
|
||||
(conversation_id, str(user.user_id), user.site_id),
|
||||
)
|
||||
if not cur.fetchone():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="对话不存在或无权访问",
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
conv_svc = ConversationService()
|
||||
messages = conv_svc.get_messages(conversation_id)
|
||||
|
||||
return [
|
||||
{
|
||||
"id": msg["id"],
|
||||
"role": msg["role"],
|
||||
"content": msg["content"],
|
||||
"tokens_used": msg.get("tokens_used"),
|
||||
"created_at": msg["created_at"],
|
||||
}
|
||||
for msg in messages
|
||||
]
|
||||
@@ -1,3 +1,7 @@
|
||||
# AI_CHANGELOG
|
||||
# - 2026-03-20 | Prompt: R3 项目类型筛选接口重建 | SkillFilterEnum/ProjectFilterEnum
|
||||
# 默认值从 .all 改为 .ALL,与新枚举值一致。
|
||||
|
||||
"""
|
||||
看板路由:BOARD-1(助教)、BOARD-2(客户)、BOARD-3(财务)。
|
||||
|
||||
@@ -30,7 +34,7 @@ router = APIRouter(prefix="/api/xcx/board", tags=["xcx-board"])
|
||||
@router.get("/coaches", response_model=CoachBoardResponse)
|
||||
async def get_coach_board(
|
||||
sort: CoachSortEnum = Query(default=CoachSortEnum.perf_desc),
|
||||
skill: SkillFilterEnum = Query(default=SkillFilterEnum.all),
|
||||
skill: SkillFilterEnum = Query(default=SkillFilterEnum.ALL),
|
||||
time: BoardTimeEnum = Query(default=BoardTimeEnum.month),
|
||||
user: CurrentUser = Depends(require_permission("view_board_coach")),
|
||||
):
|
||||
@@ -44,7 +48,7 @@ async def get_coach_board(
|
||||
@router.get("/customers", response_model=CustomerBoardResponse)
|
||||
async def get_customer_board(
|
||||
dimension: CustomerDimensionEnum = Query(default=CustomerDimensionEnum.recall),
|
||||
project: ProjectFilterEnum = Query(default=ProjectFilterEnum.all),
|
||||
project: ProjectFilterEnum = Query(default=ProjectFilterEnum.ALL),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
user: CurrentUser = Depends(require_permission("view_board_customer")),
|
||||
|
||||
329
apps/backend/app/routers/xcx_chat.py
Normal file
329
apps/backend/app/routers/xcx_chat.py
Normal 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
|
||||
@@ -1,3 +1,8 @@
|
||||
# AI_CHANGELOG
|
||||
# - 2026-03-20 | Prompt: R3 项目类型筛选接口重建 | SkillFilterEnum 和 ProjectFilterEnum
|
||||
# 枚举值从 all/chinese/snooker/mahjong/karaoke 改为 ALL/BILLIARD/SNOOKER/MAHJONG/KTV,
|
||||
# 与 dws.cfg_area_category.category_code 一致,消除前后端映射层。
|
||||
|
||||
"""三看板接口 Pydantic Schema(BOARD-1/2/3 请求参数枚举 + 响应模型)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -22,12 +27,14 @@ class CoachSortEnum(str, Enum):
|
||||
|
||||
|
||||
class SkillFilterEnum(str, Enum):
|
||||
"""BOARD-1 技能筛选。"""
|
||||
all = "all"
|
||||
chinese = "chinese"
|
||||
snooker = "snooker"
|
||||
mahjong = "mahjong"
|
||||
karaoke = "karaoke"
|
||||
"""BOARD-1 技能筛选(值与 dws.cfg_area_category.category_code 一致)。"""
|
||||
# CHANGE 2026-03-20 | R3 修复:枚举值从 chinese/snooker 等前端自定义值
|
||||
# 改为数据库 category_code(BILLIARD/SNOOKER/MAHJONG/KTV),消除映射层。
|
||||
ALL = "ALL"
|
||||
BILLIARD = "BILLIARD"
|
||||
SNOOKER = "SNOOKER"
|
||||
MAHJONG = "MAHJONG"
|
||||
KTV = "KTV"
|
||||
|
||||
|
||||
class BoardTimeEnum(str, Enum):
|
||||
@@ -53,12 +60,14 @@ class CustomerDimensionEnum(str, Enum):
|
||||
|
||||
|
||||
class ProjectFilterEnum(str, Enum):
|
||||
"""BOARD-2 项目筛选。"""
|
||||
all = "all"
|
||||
chinese = "chinese"
|
||||
snooker = "snooker"
|
||||
mahjong = "mahjong"
|
||||
karaoke = "karaoke"
|
||||
"""BOARD-2 项目筛选(值与 dws.cfg_area_category.category_code 一致)。"""
|
||||
# CHANGE 2026-03-20 | R3 修复:枚举值从 chinese/snooker 等前端自定义值
|
||||
# 改为数据库 category_code(BILLIARD/SNOOKER/MAHJONG/KTV),消除映射层。
|
||||
ALL = "ALL"
|
||||
BILLIARD = "BILLIARD"
|
||||
SNOOKER = "SNOOKER"
|
||||
MAHJONG = "MAHJONG"
|
||||
KTV = "KTV"
|
||||
|
||||
|
||||
class FinanceTimeEnum(str, Enum):
|
||||
|
||||
106
apps/backend/app/schemas/xcx_chat.py
Normal file
106
apps/backend/app/schemas/xcx_chat.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""
|
||||
小程序 CHAT 模块 Pydantic 模型。
|
||||
|
||||
覆盖:对话历史列表、消息查看、发送消息、SSE 流式请求等场景。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.schemas.base import CamelModel
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 对话历史(CHAT-1)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ChatHistoryItem(CamelModel):
|
||||
"""对话历史列表项。"""
|
||||
|
||||
id: int
|
||||
title: str
|
||||
customer_name: str | None = None
|
||||
last_message: str | None = None
|
||||
timestamp: str # ISO 8601,最后消息时间
|
||||
unread_count: int = 0
|
||||
|
||||
|
||||
class ChatHistoryResponse(CamelModel):
|
||||
"""CHAT-1 对话历史列表响应。"""
|
||||
|
||||
items: list[ChatHistoryItem]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 消息查看(CHAT-2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ReferenceCard(CamelModel):
|
||||
"""引用卡片,附加在 AI 回复消息中的结构化上下文数据。"""
|
||||
|
||||
type: str # 'customer' | 'record'
|
||||
title: str
|
||||
summary: str
|
||||
data: dict[str, str] # 键值对详情
|
||||
|
||||
|
||||
class ChatMessageItem(CamelModel):
|
||||
"""对话消息项。"""
|
||||
|
||||
id: int
|
||||
role: str # 'user' | 'assistant'
|
||||
content: str
|
||||
created_at: str # ISO 8601(统一字段名)
|
||||
reference_card: ReferenceCard | None = None
|
||||
|
||||
|
||||
class ChatMessagesResponse(CamelModel):
|
||||
"""CHAT-2 对话消息列表响应。"""
|
||||
|
||||
chat_id: int
|
||||
items: list[ChatMessageItem]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 发送消息(CHAT-3)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class MessageBrief(CamelModel):
|
||||
"""消息摘要(用于发送消息响应)。"""
|
||||
|
||||
id: int
|
||||
content: str
|
||||
created_at: str # ISO 8601
|
||||
|
||||
|
||||
class SendMessageRequest(CamelModel):
|
||||
"""CHAT-3 发送消息请求体。"""
|
||||
|
||||
content: str
|
||||
|
||||
|
||||
class SendMessageResponse(CamelModel):
|
||||
"""CHAT-3 发送消息响应(含用户消息和 AI 回复)。"""
|
||||
|
||||
user_message: MessageBrief
|
||||
ai_reply: MessageBrief
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SSE 流式(CHAT-4)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ChatStreamRequest(CamelModel):
|
||||
"""CHAT-4 SSE 流式请求体。"""
|
||||
|
||||
chat_id: int
|
||||
content: str
|
||||
@@ -1,3 +1,7 @@
|
||||
# AI_CHANGELOG
|
||||
# - 2026-03-20 | Prompt: M4 emoji 注释修复 | heart_emoji 注释从旧 3 级(❤️/💛/🤍)
|
||||
# 改为 P6 权威定义的 4 级(💖/🧡/💛/💙),与 compute_heart_icon() 实际逻辑对齐。
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.schemas.base import CamelModel
|
||||
@@ -43,7 +47,9 @@ class TopCustomer(CamelModel):
|
||||
name: str
|
||||
initial: str
|
||||
avatar_gradient: str
|
||||
heart_emoji: str # ❤️ / 💛 / 🤍
|
||||
# CHANGE 2026-03-20 | M4 修复: emoji 注释与 P6 权威定义对齐(4 级映射)
|
||||
# intent: 注释应反映 compute_heart_icon() 的实际 4 级映射(💖🧡💛💙)
|
||||
heart_emoji: str # 💖 / 🧡 / 💛 / 💙
|
||||
score: str
|
||||
score_color: str
|
||||
service_count: int
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
"""CONFIG-1 技能类型响应 Schema。"""
|
||||
# AI_CHANGELOG
|
||||
# - 2026-03-20 | Prompt: R3 项目类型筛选接口重建 | SkillTypeItem.key 注释从
|
||||
# chinese/snooker 改为 BILLIARD/SNOOKER,label 说明改为从 display_name 读取。
|
||||
|
||||
"""CONFIG-1 项目类型筛选器响应 Schema。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -6,7 +10,14 @@ from app.schemas.base import CamelModel
|
||||
|
||||
|
||||
class SkillTypeItem(CamelModel):
|
||||
key: str # chinese/snooker/mahjong/karaoke
|
||||
label: str # 中文标签
|
||||
emoji: str # 表情符号
|
||||
cls: str # 前端样式类
|
||||
"""项目类型筛选器选项。
|
||||
|
||||
key 值与 dws.cfg_area_category.category_code 一致
|
||||
(BILLIARD/SNOOKER/MAHJONG/KTV),"不限"选项 key="ALL"。
|
||||
"""
|
||||
# CHANGE 2026-03-20 | R3 修复:key 从 chinese/snooker 改为 BILLIARD/SNOOKER,
|
||||
# label 从 display_name 读取(含 emoji),cls 保留但后端不再填充。
|
||||
key: str # BILLIARD/SNOOKER/MAHJONG/KTV/ALL
|
||||
label: str # display_name(含 emoji,如 "🎱 中式/追分")
|
||||
emoji: str # short_name(单 emoji,如 "🎱")
|
||||
cls: str # 前端样式类(预留,当前为空字符串)
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
# AI_CHANGELOG
|
||||
# - 2026-03-20 | Prompt: M4 emoji 注释修复 | FavoriteCoach.emoji 注释从旧 2 级(💖/💛)
|
||||
# 改为 P6 权威定义的 4 级(💖/🧡/💛/💙),与 compute_heart_icon() 实际逻辑对齐。
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.schemas.base import CamelModel
|
||||
@@ -28,7 +32,9 @@ class CoachTask(CamelModel):
|
||||
metrics: list[MetricItem] = []
|
||||
|
||||
class FavoriteCoach(CamelModel):
|
||||
emoji: str # 💖 / 💛
|
||||
# CHANGE 2026-03-20 | M4 修复: emoji 注释与 P6 权威定义对齐(4 级映射)
|
||||
# intent: 注释应反映 compute_heart_icon() 的实际 4 级映射(💖🧡💛💙)
|
||||
emoji: str # 💖 / 🧡 / 💛 / 💙
|
||||
name: str
|
||||
relation_index: str
|
||||
index_color: str
|
||||
|
||||
685
apps/backend/app/services/chat_service.py
Normal file
685
apps/backend/app/services/chat_service.py
Normal file
@@ -0,0 +1,685 @@
|
||||
"""
|
||||
CHAT 模块业务逻辑层。
|
||||
|
||||
封装对话管理、消息持久化、referenceCard 组装、标题生成等核心逻辑。
|
||||
路由层(xcx_chat.py)调用本服务完成 CHAT-1/2/3/4 端点的业务处理。
|
||||
|
||||
表依赖:
|
||||
- biz.ai_conversations — 对话会话(含 context_type/context_id/title/last_message 扩展字段)
|
||||
- biz.ai_messages — 消息记录(含 reference_card 扩展字段)
|
||||
- fdw_etl.v_dim_member — 会员信息(通过 ETL 直连)
|
||||
- fdw_etl.v_dws_member_consumption_summary / v_dwd_assistant_service_log — 消费指标
|
||||
|
||||
⚠️ P5 PRD 合规:
|
||||
- app_id 固定为 'app1_chat'
|
||||
- 用户消息发送时即写入 ai_messages(role=user)
|
||||
- 流式完成后完整 assistant 回复写入 ai_messages(role=assistant),含 tokens_used
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from app.ai.bailian_client import BailianClient
|
||||
from app.database import get_connection
|
||||
from app.services import fdw_queries
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
APP_ID = "app1_chat"
|
||||
|
||||
# 对话复用时限(天)
|
||||
_REUSE_DAYS = 3
|
||||
|
||||
|
||||
class ChatService:
|
||||
"""CHAT 模块业务逻辑。"""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CHAT-1: 对话历史列表
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_chat_history(
|
||||
self,
|
||||
user_id: int,
|
||||
site_id: int,
|
||||
page: int,
|
||||
page_size: int,
|
||||
) -> tuple[list[dict], int]:
|
||||
"""查询对话历史列表,返回 (items, total)。
|
||||
|
||||
按 last_message_at 倒序,JOIN v_dim_member 获取 customerName。
|
||||
仅返回 app_id='app1_chat' 的对话。
|
||||
"""
|
||||
offset = (page - 1) * page_size
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# 总数
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM biz.ai_conversations
|
||||
WHERE user_id = %s AND site_id = %s AND app_id = %s
|
||||
""",
|
||||
(str(user_id), site_id, APP_ID),
|
||||
)
|
||||
total = cur.fetchone()[0]
|
||||
|
||||
# 分页列表
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, title, context_type, context_id,
|
||||
last_message, last_message_at, created_at
|
||||
FROM biz.ai_conversations
|
||||
WHERE user_id = %s AND site_id = %s AND app_id = %s
|
||||
ORDER BY COALESCE(last_message_at, created_at) DESC
|
||||
LIMIT %s OFFSET %s
|
||||
""",
|
||||
(str(user_id), site_id, APP_ID, page_size, offset),
|
||||
)
|
||||
columns = [desc[0] for desc in cur.description]
|
||||
rows = cur.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# 组装结果,尝试获取 customerName
|
||||
items: list[dict] = []
|
||||
# 收集需要查询姓名的 customer context_id
|
||||
customer_ids: list[int] = []
|
||||
raw_items: list[dict] = []
|
||||
for row in rows:
|
||||
item = dict(zip(columns, row))
|
||||
raw_items.append(item)
|
||||
if item.get("context_type") == "customer" and item.get("context_id"):
|
||||
try:
|
||||
customer_ids.append(int(item["context_id"]))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# 批量查询客户姓名(FDW 降级:查询失败返回空映射)
|
||||
name_map: dict[int, str] = {}
|
||||
if customer_ids:
|
||||
try:
|
||||
biz_conn = get_connection()
|
||||
try:
|
||||
info_map = fdw_queries.get_member_info(biz_conn, site_id, customer_ids)
|
||||
for mid, info in info_map.items():
|
||||
name_map[mid] = info.get("nickname") or ""
|
||||
finally:
|
||||
biz_conn.close()
|
||||
except Exception:
|
||||
logger.warning("查询客户姓名失败,降级为空", exc_info=True)
|
||||
|
||||
for item in raw_items:
|
||||
customer_name: str | None = None
|
||||
if item.get("context_type") == "customer" and item.get("context_id"):
|
||||
try:
|
||||
customer_name = name_map.get(int(item["context_id"]))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# 生成标题
|
||||
title = self.generate_title(
|
||||
title=item.get("title"),
|
||||
customer_name=customer_name,
|
||||
conversation_id=item["id"],
|
||||
)
|
||||
|
||||
ts = item.get("last_message_at") or item.get("created_at")
|
||||
items.append({
|
||||
"id": item["id"],
|
||||
"title": title,
|
||||
"customer_name": customer_name,
|
||||
"last_message": item.get("last_message"),
|
||||
"timestamp": ts.isoformat() if isinstance(ts, datetime) else str(ts) if ts else "",
|
||||
"unread_count": 0,
|
||||
})
|
||||
|
||||
return items, total
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 对话复用 / 创建
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_or_create_session(
|
||||
self,
|
||||
user_id: int,
|
||||
site_id: int,
|
||||
context_type: str,
|
||||
context_id: str | None,
|
||||
) -> int:
|
||||
"""按入口上下文查找或创建对话,返回 chat_id。
|
||||
|
||||
复用规则:
|
||||
- context_type='task': 同一 taskId 始终复用(无时限)
|
||||
- context_type='customer'/'coach': 最后消息 ≤ 3 天复用,> 3 天新建
|
||||
- context_type='general': 始终新建
|
||||
"""
|
||||
# general 入口始终新建
|
||||
if context_type == "general":
|
||||
return self._create_session(user_id, site_id, context_type, context_id)
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
if context_type == "task":
|
||||
# task 入口:始终复用(无时限)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id FROM biz.ai_conversations
|
||||
WHERE user_id = %s AND site_id = %s
|
||||
AND context_type = 'task' AND context_id = %s
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
""",
|
||||
(str(user_id), site_id, context_id),
|
||||
)
|
||||
elif context_type in ("customer", "coach"):
|
||||
# customer/coach 入口:3 天时限复用
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id FROM biz.ai_conversations
|
||||
WHERE user_id = %s AND site_id = %s
|
||||
AND context_type = %s AND context_id = %s
|
||||
AND last_message_at > NOW() - INTERVAL '3 days'
|
||||
ORDER BY last_message_at DESC LIMIT 1
|
||||
""",
|
||||
(str(user_id), site_id, context_type, context_id),
|
||||
)
|
||||
else:
|
||||
# 未知类型,新建
|
||||
return self._create_session(user_id, site_id, context_type, context_id)
|
||||
|
||||
row = cur.fetchone()
|
||||
if row:
|
||||
return row[0]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# 未找到可复用对话,新建
|
||||
return self._create_session(user_id, site_id, context_type, context_id)
|
||||
|
||||
def _create_session(
|
||||
self,
|
||||
user_id: int,
|
||||
site_id: int,
|
||||
context_type: str,
|
||||
context_id: str | None,
|
||||
) -> int:
|
||||
"""创建新对话记录,返回 conversation_id。"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# 查询用户昵称
|
||||
cur.execute(
|
||||
"SELECT nickname FROM auth.users WHERE id = %s",
|
||||
(user_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
nickname = row[0] if row and row[0] else ""
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO biz.ai_conversations
|
||||
(user_id, nickname, app_id, site_id, context_type, context_id)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(str(user_id), nickname, APP_ID, site_id, context_type, context_id),
|
||||
)
|
||||
new_id = cur.fetchone()[0]
|
||||
conn.commit()
|
||||
return new_id
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CHAT-2: 消息列表
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_messages(
|
||||
self,
|
||||
chat_id: int,
|
||||
user_id: int,
|
||||
site_id: int,
|
||||
page: int,
|
||||
page_size: int,
|
||||
) -> tuple[list[dict], int, int]:
|
||||
"""查询消息列表,返回 (messages, total, chat_id)。
|
||||
|
||||
验证 chat_id 归属当前用户,按 created_at 正序。
|
||||
"""
|
||||
self._verify_ownership(chat_id, user_id, site_id)
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT COUNT(*) FROM biz.ai_messages WHERE conversation_id = %s",
|
||||
(chat_id,),
|
||||
)
|
||||
total = cur.fetchone()[0]
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, role, content, created_at, reference_card
|
||||
FROM biz.ai_messages
|
||||
WHERE conversation_id = %s
|
||||
ORDER BY created_at ASC
|
||||
LIMIT %s OFFSET %s
|
||||
""",
|
||||
(chat_id, page_size, offset),
|
||||
)
|
||||
columns = [desc[0] for desc in cur.description]
|
||||
rows = cur.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
messages = []
|
||||
for row in rows:
|
||||
item = dict(zip(columns, row))
|
||||
ref_card = item.get("reference_card")
|
||||
# reference_card 可能是 dict(psycopg2 自动解析 jsonb)或 str
|
||||
if isinstance(ref_card, str):
|
||||
try:
|
||||
ref_card = json.loads(ref_card)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
ref_card = None
|
||||
|
||||
created_at = item["created_at"]
|
||||
messages.append({
|
||||
"id": item["id"],
|
||||
"role": item["role"],
|
||||
"content": item["content"],
|
||||
"created_at": created_at.isoformat() if isinstance(created_at, datetime) else str(created_at),
|
||||
"reference_card": ref_card,
|
||||
})
|
||||
|
||||
return messages, total, chat_id
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CHAT-3: 发送消息(同步回复)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def send_message_sync(
|
||||
self,
|
||||
chat_id: int,
|
||||
content: str,
|
||||
user_id: int,
|
||||
site_id: int,
|
||||
) -> dict:
|
||||
"""发送消息并获取同步 AI 回复。
|
||||
|
||||
流程:
|
||||
1. 验证 chatId 归属
|
||||
2. 存入用户消息(立即写入)
|
||||
3. 调用 AI 获取回复
|
||||
4. 存入 AI 回复
|
||||
5. 更新 session 的 last_message / last_message_at
|
||||
6. AI 失败时返回错误提示消息(HTTP 200)
|
||||
"""
|
||||
self._verify_ownership(chat_id, user_id, site_id)
|
||||
|
||||
# 1. 立即存入用户消息(P5 PRD 合规:发送时即写入)
|
||||
user_msg_id, user_created_at = self._save_message(chat_id, "user", content)
|
||||
|
||||
# 2. 调用 AI
|
||||
ai_reply_text: str
|
||||
tokens_used: int | None = None
|
||||
try:
|
||||
ai_reply_text, tokens_used = await self._call_ai(chat_id, content, user_id, site_id)
|
||||
except Exception as e:
|
||||
logger.error("AI 服务调用失败: %s", e, exc_info=True)
|
||||
ai_reply_text = "抱歉,AI 助手暂时无法回复,请稍后重试"
|
||||
|
||||
# 3. 存入 AI 回复
|
||||
ai_msg_id, ai_created_at = self._save_message(
|
||||
chat_id, "assistant", ai_reply_text, tokens_used=tokens_used,
|
||||
)
|
||||
|
||||
# 4. 更新 session 元数据
|
||||
self._update_session_metadata(chat_id, ai_reply_text)
|
||||
|
||||
return {
|
||||
"user_message": {
|
||||
"id": user_msg_id,
|
||||
"content": content,
|
||||
"created_at": user_created_at,
|
||||
},
|
||||
"ai_reply": {
|
||||
"id": ai_msg_id,
|
||||
"content": ai_reply_text,
|
||||
"created_at": ai_created_at,
|
||||
},
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# referenceCard 组装
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def build_reference_card(
|
||||
self,
|
||||
customer_id: int,
|
||||
site_id: int,
|
||||
) -> dict | None:
|
||||
"""从 FDW 查询客户关键指标,组装 referenceCard。
|
||||
|
||||
⚠️ DWD-DOC 规则:金额用 items_sum 口径(ledger_amount),
|
||||
会员信息通过 member_id JOIN dim_member(scd2_is_current=1)。
|
||||
|
||||
FDW 查询失败时静默降级返回 None(不影响消息本身)。
|
||||
"""
|
||||
try:
|
||||
biz_conn = get_connection()
|
||||
try:
|
||||
# 客户姓名
|
||||
info_map = fdw_queries.get_member_info(biz_conn, site_id, [customer_id])
|
||||
if customer_id not in info_map:
|
||||
return None
|
||||
member_name = info_map[customer_id].get("nickname") or "未知客户"
|
||||
|
||||
# 余额
|
||||
balance: Decimal | None = None
|
||||
try:
|
||||
balance_map = fdw_queries.get_member_balance(biz_conn, site_id, [customer_id])
|
||||
balance = balance_map.get(customer_id)
|
||||
except Exception:
|
||||
logger.warning("referenceCard: 查询余额失败", exc_info=True)
|
||||
|
||||
# 近 30 天消费(items_sum 口径)
|
||||
consume_30d: Decimal | None = None
|
||||
try:
|
||||
consume_30d = self._get_consumption_30d(biz_conn, site_id, customer_id)
|
||||
except Exception:
|
||||
logger.warning("referenceCard: 查询近30天消费失败", exc_info=True)
|
||||
|
||||
# 近 30 天到店次数
|
||||
visit_count: int | None = None
|
||||
try:
|
||||
visit_count = self._get_visit_count_30d(biz_conn, site_id, customer_id)
|
||||
except Exception:
|
||||
logger.warning("referenceCard: 查询到店次数失败", exc_info=True)
|
||||
|
||||
finally:
|
||||
biz_conn.close()
|
||||
|
||||
# 格式化
|
||||
balance_str = f"¥{balance:,.2f}" if balance is not None else "—"
|
||||
consume_str = f"¥{consume_30d:,.2f}" if consume_30d is not None else "—"
|
||||
visit_str = f"{visit_count}次" if visit_count is not None else "—"
|
||||
|
||||
return {
|
||||
"type": "customer",
|
||||
"title": f"{member_name} — 消费概览",
|
||||
"summary": f"余额 {balance_str},近30天消费 {consume_str}",
|
||||
"data": {
|
||||
"余额": balance_str,
|
||||
"近30天消费": consume_str,
|
||||
"到店次数": visit_str,
|
||||
},
|
||||
}
|
||||
|
||||
except Exception:
|
||||
logger.warning("referenceCard 组装失败,降级为 null", exc_info=True)
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 标题生成
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def generate_title(
|
||||
self,
|
||||
title: str | None = None,
|
||||
customer_name: str | None = None,
|
||||
conversation_id: int | None = None,
|
||||
first_message: str | None = None,
|
||||
) -> str:
|
||||
"""生成对话标题:自定义标题 > 客户姓名 > 首条消息前 20 字。
|
||||
|
||||
结果始终非空。
|
||||
"""
|
||||
# 优先级 1:自定义标题
|
||||
if title and title.strip():
|
||||
return title.strip()
|
||||
|
||||
# 优先级 2:客户姓名
|
||||
if customer_name and customer_name.strip():
|
||||
return customer_name.strip()
|
||||
|
||||
# 优先级 3:首条消息前 20 字
|
||||
if first_message is None and conversation_id is not None:
|
||||
first_message = self._get_first_message(conversation_id)
|
||||
|
||||
if first_message and first_message.strip():
|
||||
text = first_message.strip()
|
||||
return text[:20] if len(text) > 20 else text
|
||||
|
||||
return "新对话"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 内部辅助方法
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _verify_ownership(self, chat_id: int, user_id: int, site_id: int) -> None:
|
||||
"""验证对话归属当前用户,不属于时抛出 HTTP 403/404。"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT user_id FROM biz.ai_conversations
|
||||
WHERE id = %s AND site_id = %s
|
||||
""",
|
||||
(chat_id, site_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="对话不存在",
|
||||
)
|
||||
if str(row[0]) != str(user_id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="无权访问此对话",
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _save_message(
|
||||
self,
|
||||
conversation_id: int,
|
||||
role: str,
|
||||
content: str,
|
||||
tokens_used: int | None = None,
|
||||
reference_card: dict | None = None,
|
||||
) -> tuple[int, str]:
|
||||
"""写入消息记录,返回 (message_id, created_at ISO 字符串)。"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO biz.ai_messages
|
||||
(conversation_id, role, content, tokens_used, reference_card)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
RETURNING id, created_at
|
||||
""",
|
||||
(
|
||||
conversation_id,
|
||||
role,
|
||||
content,
|
||||
tokens_used,
|
||||
json.dumps(reference_card, ensure_ascii=False) if reference_card else None,
|
||||
),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
conn.commit()
|
||||
msg_id = row[0]
|
||||
created_at = row[1]
|
||||
return msg_id, created_at.isoformat() if isinstance(created_at, datetime) else str(created_at)
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _update_session_metadata(self, chat_id: int, last_message: str) -> None:
|
||||
"""更新对话的 last_message 和 last_message_at。"""
|
||||
# 截断至 100 字
|
||||
truncated = last_message[:100] if len(last_message) > 100 else last_message
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE biz.ai_conversations
|
||||
SET last_message = %s, last_message_at = NOW()
|
||||
WHERE id = %s
|
||||
""",
|
||||
(truncated, chat_id),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _get_first_message(self, conversation_id: int) -> str | None:
|
||||
"""查询对话的首条 user 消息内容。"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT content FROM biz.ai_messages
|
||||
WHERE conversation_id = %s AND role = 'user'
|
||||
ORDER BY created_at ASC LIMIT 1
|
||||
""",
|
||||
(conversation_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return row[0] if row else None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
async def _call_ai(
|
||||
self,
|
||||
chat_id: int,
|
||||
content: str,
|
||||
user_id: int,
|
||||
site_id: int,
|
||||
) -> tuple[str, int | None]:
|
||||
"""调用百炼 API 获取非流式回复,返回 (reply_text, tokens_used)。
|
||||
|
||||
构建历史消息上下文发送给 AI。
|
||||
"""
|
||||
bailian = _get_bailian_client()
|
||||
|
||||
# 获取历史消息作为上下文(最近 20 条)
|
||||
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 条(含刚写入的 user 消息)
|
||||
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)
|
||||
|
||||
# 非流式调用(chat_stream 用于 SSE,这里用 chat_stream 收集完整回复)
|
||||
full_parts: list[str] = []
|
||||
async for chunk in bailian.chat_stream(messages):
|
||||
full_parts.append(chunk)
|
||||
|
||||
reply = "".join(full_parts)
|
||||
# 流式模式不返回 tokens_used,按字符数估算
|
||||
estimated_tokens = len(reply)
|
||||
return reply, estimated_tokens
|
||||
|
||||
@staticmethod
|
||||
def _get_consumption_30d(conn: Any, site_id: int, member_id: int) -> Decimal | None:
|
||||
"""查询客户近 30 天消费金额(items_sum 口径)。
|
||||
|
||||
⚠️ DWD-DOC 规则 1: 使用 ledger_amount(items_sum 口径),禁用 consume_money。
|
||||
"""
|
||||
with fdw_queries._fdw_context(conn, site_id) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT COALESCE(SUM(ledger_amount), 0)
|
||||
FROM app.v_dwd_assistant_service_log
|
||||
WHERE tenant_member_id = %s
|
||||
AND is_delete = 0
|
||||
AND create_time >= (CURRENT_DATE - INTERVAL '30 days')::timestamptz
|
||||
""",
|
||||
(member_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return Decimal(str(row[0])) if row and row[0] is not None else None
|
||||
|
||||
@staticmethod
|
||||
def _get_visit_count_30d(conn: Any, site_id: int, member_id: int) -> int | None:
|
||||
"""查询客户近 30 天到店次数。"""
|
||||
with fdw_queries._fdw_context(conn, site_id) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT COUNT(DISTINCT create_time::date)
|
||||
FROM app.v_dwd_assistant_service_log
|
||||
WHERE tenant_member_id = %s
|
||||
AND is_delete = 0
|
||||
AND create_time >= (CURRENT_DATE - INTERVAL '30 days')::timestamptz
|
||||
""",
|
||||
(member_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return int(row[0]) if row and row[0] is not None else None
|
||||
|
||||
|
||||
# ── 模块级辅助函数 ──────────────────────────────────────────────
|
||||
|
||||
|
||||
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)
|
||||
@@ -1,4 +1,9 @@
|
||||
# AI_CHANGELOG
|
||||
# - 2026-03-20 | Prompt: R3 项目类型筛选接口重建 | get_skill_types() 从虚构的 v_cfg_skill_type
|
||||
# 改为查询 app.v_cfg_area_category(真实 RLS 视图),头部插入"不限"选项;
|
||||
# get_all_assistants() 移除 _skill_to_category/_category_to_skill 映射字典,
|
||||
# 改为 _valid_categories set 直接比较;_project_filter_clause() 移除 _project_to_category
|
||||
# 映射字典,直接用 category_code。
|
||||
# - 2026-03-20 | Prompt: RNS1.3 FDW 列名修正 | 修正 17 处列名映射(design.md 理想名 → 实际视图列名),
|
||||
# gift_rows 每个 cell 改为 GiftCell dict 避免 Pydantic 校验失败,
|
||||
# v_dws_member_spending_power_index 降级为空列表,skill_filter 暂不生效
|
||||
@@ -525,15 +530,22 @@ def get_relation_index(
|
||||
return records
|
||||
|
||||
|
||||
# AI_CHANGELOG
|
||||
# - 2026-03-20: R1 修复 — 5 列(table_charge_money, goods_money, assistant_pd_money,
|
||||
# assistant_cx_money, settle_type)从 sl(service_log) 改为 sh(settlement_head),
|
||||
# 添加 LEFT JOIN v_dwd_settlement_head,WHERE settle_type 引用也改为 sh。
|
||||
# 原因:这些字段属于结算单头表,不在助教服务日志视图中。
|
||||
# 验证:MCP 端到端查询通过。
|
||||
def get_consumption_records(
|
||||
conn: Any, site_id: int, member_id: int, limit: int, offset: int
|
||||
) -> list[dict]:
|
||||
"""
|
||||
查询客户消费记录(CUST-1 consumptionRecords 用)。
|
||||
|
||||
来源: app.v_dwd_assistant_service_log + v_dim_assistant。
|
||||
⚠️ DWD-DOC 规则 1: totalAmount 使用 ledger_amount。
|
||||
⚠️ DWD-DOC 规则 2: coaches fee 使用 assistant_pd_money / assistant_cx_money。
|
||||
来源: app.v_dwd_assistant_service_log + v_dwd_settlement_head + v_dim_assistant。
|
||||
⚠️ DWD-DOC 规则 1: totalAmount 使用 ledger_amount(来自 service_log)。
|
||||
⚠️ DWD-DOC 规则 2: coaches fee 使用 assistant_pd_money / assistant_cx_money(来自 settlement_head)。
|
||||
⚠️ 费用拆分字段(table_charge_money, goods_money, settle_type)来自 settlement_head。
|
||||
⚠️ 废单排除: is_delete = 0。
|
||||
⚠️ 正向交易: settle_type IN (1, 3)。
|
||||
⚠️ DQ-6: 助教姓名通过 v_dim_assistant 获取。
|
||||
@@ -553,18 +565,25 @@ def get_consumption_records(
|
||||
sl.site_assistant_id AS assistant_id,
|
||||
COALESCE(da.real_name, da.nickname, '') AS assistant_name,
|
||||
da.level AS assistant_level,
|
||||
sl.table_charge_money,
|
||||
sl.goods_money,
|
||||
sl.assistant_pd_money,
|
||||
sl.assistant_cx_money,
|
||||
sl.settle_type
|
||||
sh.table_charge_money,
|
||||
sh.goods_money,
|
||||
sh.assistant_pd_money,
|
||||
sh.assistant_cx_money,
|
||||
sh.settle_type
|
||||
FROM app.v_dwd_assistant_service_log sl
|
||||
LEFT JOIN app.v_dim_assistant da
|
||||
ON sl.site_assistant_id = da.assistant_id
|
||||
AND da.scd2_is_current = 1
|
||||
LEFT JOIN app.v_dwd_settlement_head sh
|
||||
ON sl.order_settle_id = sh.order_settle_id
|
||||
-- CHANGE 2026-03-20 | R1 修复: 费用拆分字段来自 settlement_head 而非 service_log
|
||||
-- intent: table_charge_money/goods_money/assistant_pd_money/assistant_cx_money/settle_type
|
||||
-- 属于结算单头表(dwd_settlement_head),通过 order_settle_id 关联
|
||||
-- assumption: 每条 service_log 对应一条 settlement_head(1:1 或 1:0)
|
||||
-- verify: SELECT count(*) FROM v_dwd_assistant_service_log WHERE order_settle_id IS NULL
|
||||
WHERE sl.tenant_member_id = %s
|
||||
AND sl.is_delete = 0
|
||||
AND sl.settle_type IN (1, 3)
|
||||
AND sh.settle_type IN (1, 3)
|
||||
ORDER BY sl.create_time DESC
|
||||
LIMIT %s OFFSET %s
|
||||
""",
|
||||
@@ -963,23 +982,17 @@ def get_coach_service_records(
|
||||
|
||||
|
||||
def get_all_assistants(
|
||||
conn: Any, site_id: int, skill_filter: str = "all"
|
||||
conn: Any, site_id: int, skill_filter: str = "ALL"
|
||||
) -> list[dict]:
|
||||
"""
|
||||
查询门店全部助教列表(BOARD-1 用)。
|
||||
|
||||
CHANGE 2026-03-19 | P1 修复:通过 LEFT JOIN v_dws_assistant_project_tag 获取技能标签,
|
||||
支持 skill_filter 筛选(chinese/snooker/mahjong/karaoke/all)。
|
||||
category_code 映射:BILLIARD→chinese, SNOOKER→snooker, MAHJONG→mahjong, KTV→karaoke。
|
||||
CHANGE 2026-03-20 | R3 修复:skill_filter 直接接收 category_code
|
||||
(BILLIARD/SNOOKER/MAHJONG/KTV/ALL),去掉 chinese→BILLIARD 映射层。
|
||||
"""
|
||||
# CHANGE 2026-03-19 | feiqiu-data-rules 规则 6: 等级名称从配置表动态读取
|
||||
_skill_to_category = {
|
||||
"chinese": "BILLIARD",
|
||||
"snooker": "SNOOKER",
|
||||
"mahjong": "MAHJONG",
|
||||
"karaoke": "KTV",
|
||||
}
|
||||
_category_to_skill = {v: k for k, v in _skill_to_category.items()}
|
||||
# CHANGE 2026-03-20 | R3 修复:去掉 _skill_to_category 映射,直接用 category_code
|
||||
_valid_categories = {"BILLIARD", "SNOOKER", "MAHJONG", "KTV"}
|
||||
|
||||
level_map = get_level_map(conn, site_id)
|
||||
records: list[dict] = []
|
||||
@@ -987,7 +1000,7 @@ def get_all_assistants(
|
||||
# 筛选条件:如果指定了技能,只返回被标记的助教
|
||||
filter_clause = ""
|
||||
params: tuple = ()
|
||||
if skill_filter != "all" and skill_filter in _skill_to_category:
|
||||
if skill_filter != "ALL" and skill_filter in _valid_categories:
|
||||
filter_clause = """
|
||||
AND da.assistant_id IN (
|
||||
SELECT apt.assistant_id
|
||||
@@ -995,7 +1008,7 @@ def get_all_assistants(
|
||||
WHERE apt.category_code = %s AND apt.is_tagged = true
|
||||
)
|
||||
"""
|
||||
params = (_skill_to_category[skill_filter],)
|
||||
params = (skill_filter,)
|
||||
|
||||
cur.execute(
|
||||
f"""
|
||||
@@ -1015,11 +1028,11 @@ def get_all_assistants(
|
||||
)
|
||||
for row in cur.fetchall():
|
||||
skill_codes = row[3] if row[3] else []
|
||||
skill_labels = [_category_to_skill.get(c, c) for c in skill_codes if c]
|
||||
# CHANGE 2026-03-20 | R3 修复:直接返回 category_code,不再反向映射为旧值
|
||||
records.append({
|
||||
"assistant_id": row[0],
|
||||
"name": row[1] or "",
|
||||
"skill": ",".join(skill_labels) if skill_labels else "",
|
||||
"skill": ",".join(c for c in skill_codes if c) if skill_codes else "",
|
||||
"level": level_map.get(row[2], "") if row[2] else "",
|
||||
})
|
||||
return records
|
||||
@@ -1190,19 +1203,13 @@ def _project_filter_clause(project: str) -> tuple[str, tuple]:
|
||||
"""
|
||||
生成项目筛选 SQL 片段(用于 BOARD-2 会员维度查询)。
|
||||
|
||||
CHANGE 2026-03-19 | P1 修复:通过 v_dws_member_project_tag 子查询实现项目筛选。
|
||||
project 参数映射:chinese→BILLIARD, snooker→SNOOKER, mahjong→MAHJONG, karaoke→KTV。
|
||||
CHANGE 2026-03-20 | R3 修复:project 参数直接接收 category_code
|
||||
(BILLIARD/SNOOKER/MAHJONG/KTV/ALL),去掉 chinese→BILLIARD 映射层。
|
||||
返回 (sql_fragment, params),sql_fragment 以 AND 开头,可直接拼入 WHERE 子句。
|
||||
"""
|
||||
_project_to_category = {
|
||||
"chinese": "BILLIARD",
|
||||
"snooker": "SNOOKER",
|
||||
"mahjong": "MAHJONG",
|
||||
"karaoke": "KTV",
|
||||
}
|
||||
if project == "all" or project not in _project_to_category:
|
||||
_valid_categories = {"BILLIARD", "SNOOKER", "MAHJONG", "KTV"}
|
||||
if project == "ALL" or project not in _valid_categories:
|
||||
return "", ()
|
||||
category_code = _project_to_category[project]
|
||||
clause = """
|
||||
AND vd.member_id IN (
|
||||
SELECT mpt.member_id
|
||||
@@ -1210,7 +1217,7 @@ def _project_filter_clause(project: str) -> tuple[str, tuple]:
|
||||
WHERE mpt.category_code = %s AND mpt.is_tagged = true
|
||||
)
|
||||
"""
|
||||
return clause, (category_code,)
|
||||
return clause, (project,)
|
||||
|
||||
|
||||
def get_customer_board_recall(
|
||||
@@ -2314,25 +2321,31 @@ def get_finance_coach_analysis(
|
||||
|
||||
def get_skill_types(conn: Any, site_id: int) -> list[dict]:
|
||||
"""
|
||||
CONFIG-1: 查询技能类型配置。
|
||||
CONFIG-1: 查询项目类型筛选器配置。
|
||||
|
||||
来源: ETL cfg 表(app.v_cfg_skill_type 或类似配置视图)。
|
||||
来源: app.v_cfg_area_category(基于 dws.cfg_area_category 去重,
|
||||
排除 SPECIAL/OTHER)。返回列表头部插入"不限"选项。
|
||||
查询失败时由调用方降级返回空数组。
|
||||
"""
|
||||
# CHANGE 2026-03-20 | R3 修复:原查询虚构的 v_cfg_skill_type 视图不存在,
|
||||
# 改为查询 v_cfg_area_category(项目类型配置),value 直接用 category_code
|
||||
# (BILLIARD/SNOOKER/MAHJONG/KTV),前端枚举同步修改。
|
||||
# 假设:cfg_area_category 的 category_code 是稳定的业务标识,不会频繁变动。
|
||||
with _fdw_context(conn, site_id) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT skill_key, skill_label, emoji, css_cls
|
||||
FROM app.v_cfg_skill_type
|
||||
SELECT category_code, display_name, short_name
|
||||
FROM app.v_cfg_area_category
|
||||
ORDER BY sort_order
|
||||
"""
|
||||
)
|
||||
items = []
|
||||
# 头部插入"不限"选项(后端生成,不存数据库)
|
||||
items: list[dict] = [{"key": "ALL", "label": "不限", "emoji": "", "cls": ""}]
|
||||
for row in cur.fetchall():
|
||||
items.append({
|
||||
"key": row[0] or "",
|
||||
"label": row[1] or "",
|
||||
"emoji": row[2] or "",
|
||||
"cls": row[3] or "",
|
||||
"cls": "",
|
||||
})
|
||||
return items
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
# AI_CHANGELOG
|
||||
# - 2026-03-20 | Prompt: H2 FDW→直连ETL统一改造 | FDW 外部表(fdw_etl.*)改为直连 ETL 库
|
||||
# 查询 app.v_* RLS 视图。原因:postgres_fdw 不传递 GUC 参数,RLS 门店隔离失效。
|
||||
# 使用 fdw_queries._fdw_context() 上下文管理器统一管理 ETL 连接。
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
人员匹配服务 —— 根据申请信息在 FDW 外部表中查找候选匹配。
|
||||
人员匹配服务 —— 根据申请信息在 ETL 库 RLS 视图中查找候选匹配。
|
||||
|
||||
职责:
|
||||
- find_candidates():根据 site_id + phone(+ employee_number)在助教表和员工表中查找匹配
|
||||
|
||||
查询通过业务库的 fdw_etl Schema 访问 ETL 库的 RLS 视图。
|
||||
查询前需 SET LOCAL app.current_site_id 以启用门店隔离。
|
||||
FDW 外部表可能不存在(测试库等场景),需优雅降级返回空列表。
|
||||
直连 ETL 库查询 app.v_* RLS 视图,通过 _fdw_context 设置 site_id 实现门店隔离。
|
||||
ETL 库连接失败时优雅降级返回空列表。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from app.database import get_connection
|
||||
from app.services.fdw_queries import _fdw_context
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -29,9 +33,9 @@ async def find_candidates(
|
||||
|
||||
查询逻辑:
|
||||
1. 若 site_id 为 None,跳过匹配,返回空列表
|
||||
2. 设置 app.current_site_id 进行 RLS 隔离
|
||||
3. fdw_etl.v_dim_assistant: WHERE mobile = phone
|
||||
4. fdw_etl.v_dim_staff JOIN fdw_etl.v_dim_staff_ex: WHERE mobile = phone OR job_num = employee_number
|
||||
2. 设置 app.current_site_id 进行 RLS 隔离(直连 ETL 库)
|
||||
3. app.v_dim_assistant: WHERE mobile = phone
|
||||
4. app.v_dim_staff JOIN app.v_dim_staff_ex: WHERE mobile = phone OR job_num = employee_number
|
||||
5. 合并结果返回统一候选列表
|
||||
|
||||
参数:
|
||||
@@ -49,56 +53,46 @@ async def find_candidates(
|
||||
|
||||
candidates: list[dict] = []
|
||||
|
||||
conn = get_connection()
|
||||
# CHANGE 2026-03-20 | H2 FDW→直连ETL | 从业务库 fdw_etl.* 改为直连 ETL 库 app.v_*
|
||||
# intent: 修复 RLS 门店隔离失效(postgres_fdw 不传递 GUC 参数)
|
||||
# assumptions: _fdw_context 内部管理 ETL 连接生命周期,无需外部 conn
|
||||
try:
|
||||
conn.autocommit = False
|
||||
with conn.cursor() as cur:
|
||||
# 设置 RLS 隔离:FDW 会透传 session 变量到远端 ETL 库
|
||||
cur.execute(
|
||||
"SET LOCAL app.current_site_id = %s", (str(site_id),)
|
||||
)
|
||||
|
||||
with _fdw_context(None, site_id) as cur:
|
||||
# 1. 查询助教匹配
|
||||
candidates.extend(_query_assistants(cur, phone))
|
||||
|
||||
# 2. 查询员工匹配
|
||||
candidates.extend(_query_staff(cur, phone, employee_number))
|
||||
|
||||
conn.commit()
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"FDW 人员匹配查询失败 (site_id=%s, phone=%s),返回空列表",
|
||||
"ETL 人员匹配查询失败 (site_id=%s, phone=%s),返回空列表",
|
||||
site_id,
|
||||
phone,
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
conn.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return candidates
|
||||
|
||||
|
||||
def _query_assistants(cur, phone: str) -> list[dict]:
|
||||
"""查询 fdw_etl.v_dim_assistant 中按 mobile 匹配的助教记录。"""
|
||||
"""查询 app.v_dim_assistant 中按 mobile 匹配的助教记录(直连 ETL 库)。"""
|
||||
try:
|
||||
# CHANGE 2026-03-20 | H2 | fdw_etl.v_dim_assistant → app.v_dim_assistant
|
||||
# 列名映射: scd2_is_current 是 integer 类型(1=当前),不是 boolean
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT assistant_id, real_name, mobile
|
||||
FROM fdw_etl.v_dim_assistant
|
||||
FROM app.v_dim_assistant
|
||||
WHERE mobile = %s
|
||||
AND scd2_is_current = TRUE
|
||||
AND scd2_is_current = 1
|
||||
""",
|
||||
(phone,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"查询 fdw_etl.v_dim_assistant 失败,跳过助教匹配",
|
||||
"查询 app.v_dim_assistant 失败,跳过助教匹配",
|
||||
exc_info=True,
|
||||
)
|
||||
return []
|
||||
@@ -119,20 +113,21 @@ def _query_staff(
|
||||
cur, phone: str, employee_number: str | None
|
||||
) -> list[dict]:
|
||||
"""
|
||||
查询 fdw_etl.v_dim_staff JOIN fdw_etl.v_dim_staff_ex
|
||||
查询 app.v_dim_staff JOIN app.v_dim_staff_ex(直连 ETL 库)
|
||||
按 mobile 或 job_num 匹配的员工记录。
|
||||
"""
|
||||
try:
|
||||
# 构建 WHERE 条件:mobile = phone,或 job_num = employee_number
|
||||
# CHANGE 2026-03-20 | H2 | fdw_etl.v_dim_staff/v_dim_staff_ex → app.v_dim_staff/v_dim_staff_ex
|
||||
# 列名映射: scd2_is_current 是 integer 类型(1=当前),不是 boolean
|
||||
if employee_number:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT s.staff_id, s.staff_name, s.mobile, ex.job_num
|
||||
FROM fdw_etl.v_dim_staff s
|
||||
LEFT JOIN fdw_etl.v_dim_staff_ex ex
|
||||
FROM app.v_dim_staff s
|
||||
LEFT JOIN app.v_dim_staff_ex ex
|
||||
ON s.staff_id = ex.staff_id
|
||||
AND ex.scd2_is_current = TRUE
|
||||
WHERE s.scd2_is_current = TRUE
|
||||
AND ex.scd2_is_current = 1
|
||||
WHERE s.scd2_is_current = 1
|
||||
AND (s.mobile = %s OR ex.job_num = %s)
|
||||
""",
|
||||
(phone, employee_number),
|
||||
@@ -141,11 +136,11 @@ def _query_staff(
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT s.staff_id, s.staff_name, s.mobile, ex.job_num
|
||||
FROM fdw_etl.v_dim_staff s
|
||||
LEFT JOIN fdw_etl.v_dim_staff_ex ex
|
||||
FROM app.v_dim_staff s
|
||||
LEFT JOIN app.v_dim_staff_ex ex
|
||||
ON s.staff_id = ex.staff_id
|
||||
AND ex.scd2_is_current = TRUE
|
||||
WHERE s.scd2_is_current = TRUE
|
||||
AND ex.scd2_is_current = 1
|
||||
WHERE s.scd2_is_current = 1
|
||||
AND s.mobile = %s
|
||||
""",
|
||||
(phone,),
|
||||
@@ -153,7 +148,7 @@ def _query_staff(
|
||||
rows = cur.fetchall()
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"查询 fdw_etl.v_dim_staff 失败,跳过员工匹配",
|
||||
"查询 app.v_dim_staff 失败,跳过员工匹配",
|
||||
exc_info=True,
|
||||
)
|
||||
return []
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
# AI_CHANGELOG
|
||||
# - 2026-03-20 | Prompt: H2 FDW→直连ETL统一改造 | _process_site() 中 fdw_etl.v_dwd_assistant_service_log
|
||||
# 改为直连 ETL 库查询 app.v_dwd_assistant_service_log。使用 fdw_queries._fdw_context()。
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
召回完成检测器(Recall Completion Detector)
|
||||
|
||||
ETL 数据更新后,通过 FDW 读取助教服务记录,
|
||||
ETL 数据更新后,直连 ETL 库读取助教服务记录,
|
||||
匹配活跃任务标记为 completed,记录 completed_at 和 completed_task_type 快照,
|
||||
触发 recall_completed 事件通知备注回溯重分类器。
|
||||
|
||||
@@ -138,25 +142,26 @@ def _process_site(conn, site_id: int, last_run_at) -> int:
|
||||
"""
|
||||
处理单个门店的召回完成检测。
|
||||
|
||||
通过 FDW 读取新增服务记录,匹配 active 任务并标记 completed。
|
||||
直连 ETL 库读取新增服务记录,匹配 active 任务并标记 completed。
|
||||
返回本门店完成的任务数。
|
||||
"""
|
||||
completed = 0
|
||||
|
||||
# 通过 FDW 读取新增服务记录(需要 SET LOCAL 启用 RLS)
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("BEGIN")
|
||||
cur.execute(
|
||||
"SET LOCAL app.current_site_id = %s", (str(site_id),)
|
||||
)
|
||||
# CHANGE 2026-03-20 | H2 FDW→直连ETL | fdw_etl.v_dwd_assistant_service_log → app.v_dwd_assistant_service_log
|
||||
# intent: 修复 RLS 门店隔离失效(postgres_fdw 不传递 GUC 参数)
|
||||
# assumptions: _fdw_context 内部管理 ETL 连接,conn 仅用于后续业务库操作
|
||||
from app.services.fdw_queries import _fdw_context
|
||||
|
||||
with _fdw_context(conn, site_id) as cur:
|
||||
if last_run_at is not None:
|
||||
# 列名映射: FDW 外部表 assistant_id/member_id/service_time
|
||||
# → RLS 视图 site_assistant_id/tenant_member_id/create_time
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT DISTINCT assistant_id, member_id, service_time
|
||||
FROM fdw_etl.v_dwd_assistant_service_log
|
||||
WHERE service_time > %s
|
||||
ORDER BY service_time ASC
|
||||
SELECT DISTINCT site_assistant_id, tenant_member_id, create_time
|
||||
FROM app.v_dwd_assistant_service_log
|
||||
WHERE create_time > %s
|
||||
ORDER BY create_time ASC
|
||||
""",
|
||||
(last_run_at,),
|
||||
)
|
||||
@@ -164,13 +169,12 @@ def _process_site(conn, site_id: int, last_run_at) -> int:
|
||||
# 首次运行,读取所有服务记录
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT DISTINCT assistant_id, member_id, service_time
|
||||
FROM fdw_etl.v_dwd_assistant_service_log
|
||||
ORDER BY service_time ASC
|
||||
SELECT DISTINCT site_assistant_id, tenant_member_id, create_time
|
||||
FROM app.v_dwd_assistant_service_log
|
||||
ORDER BY create_time ASC
|
||||
"""
|
||||
)
|
||||
service_records = cur.fetchall()
|
||||
conn.commit()
|
||||
|
||||
# ── 4-7. 逐条服务记录匹配并处理 ──
|
||||
for assistant_id, member_id, service_time in service_records:
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
# AI_CHANGELOG
|
||||
# - 2026-03-20 | Prompt: H2 FDW→直连ETL统一改造 | _process_assistant() 中 3 处 fdw_etl.v_dws_member_*
|
||||
# 改为直连 ETL 库查询 app.v_dws_member_*。使用 fdw_queries._fdw_context()。
|
||||
# 这是风险最高的改造点:WBI/NCI 全表扫描无 WHERE,RLS 是唯一门店过滤手段。
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
任务生成器(Task Generator)
|
||||
@@ -185,19 +190,18 @@ def _process_assistant(
|
||||
) -> None:
|
||||
"""处理单个助教下所有客户-助教对的任务生成。"""
|
||||
|
||||
# 通过 FDW 读取该助教关联的客户指数数据
|
||||
# 需要 SET LOCAL app.current_site_id 以启用 RLS
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("BEGIN")
|
||||
cur.execute(
|
||||
"SET LOCAL app.current_site_id = %s", (str(site_id),)
|
||||
)
|
||||
# CHANGE 2026-03-20 | H2 FDW→直连ETL | fdw_etl.v_dws_member_* → app.v_dws_member_*
|
||||
# intent: 修复 RLS 门店隔离失效(postgres_fdw 不传递 GUC 参数)
|
||||
# assumptions: _fdw_context 内部管理 ETL 连接,conn 仅用于后续业务库写入
|
||||
# 边界条件: WBI/NCI 全表扫描(无 WHERE),RLS 隔离是唯一的门店过滤手段
|
||||
from app.services.fdw_queries import _fdw_context
|
||||
|
||||
with _fdw_context(conn, site_id) as cur:
|
||||
# 读取 WBI(流失回赢指数)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT member_id, COALESCE(display_score, 0)
|
||||
FROM fdw_etl.v_dws_member_winback_index
|
||||
FROM app.v_dws_member_winback_index
|
||||
"""
|
||||
)
|
||||
wbi_map = {row[0]: Decimal(str(row[1])) for row in cur.fetchall()}
|
||||
@@ -206,7 +210,7 @@ def _process_assistant(
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT member_id, COALESCE(display_score, 0)
|
||||
FROM fdw_etl.v_dws_member_newconv_index
|
||||
FROM app.v_dws_member_newconv_index
|
||||
"""
|
||||
)
|
||||
nci_map = {row[0]: Decimal(str(row[1])) for row in cur.fetchall()}
|
||||
@@ -215,15 +219,13 @@ def _process_assistant(
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT member_id, COALESCE(rs_display, 0)
|
||||
FROM fdw_etl.v_dws_member_assistant_relation_index
|
||||
FROM app.v_dws_member_assistant_relation_index
|
||||
WHERE assistant_id = %s
|
||||
""",
|
||||
(assistant_id,),
|
||||
)
|
||||
rs_map = {row[0]: Decimal(str(row[1])) for row in cur.fetchall()}
|
||||
|
||||
conn.commit()
|
||||
|
||||
# 合并所有涉及的 member_id
|
||||
all_member_ids = set(wbi_map.keys()) | set(nci_map.keys()) | set(rs_map.keys())
|
||||
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
# AI_CHANGELOG
|
||||
# - 2026-03-20 | Prompt: H2 FDW→直连ETL统一改造 | get_task_list() 中 2 处、get_task_list_v2() 中 1 处、
|
||||
# get_task_detail() 中 1 处 fdw_etl.v_dim_member / v_dws_member_assistant_relation_index
|
||||
# 改为直连 ETL 库查询 app.v_* RLS 视图。使用 fdw_queries._fdw_context()。
|
||||
|
||||
"""
|
||||
任务管理服务
|
||||
|
||||
负责任务 CRUD、置顶、放弃、取消放弃等操作。
|
||||
通过 FDW 读取客户信息和 RS 指数,计算爱心 icon 档位。
|
||||
直连 ETL 库查询 app.v_* RLS 视图获取客户信息和 RS 指数,计算爱心 icon 档位。
|
||||
|
||||
RNS1.1 扩展:get_task_list_v2(TASK-1)、get_task_detail(TASK-2)。
|
||||
"""
|
||||
@@ -169,17 +174,18 @@ async def get_task_list(user_id: int, site_id: int) -> list[dict]:
|
||||
member_info_map: dict[int, dict] = {}
|
||||
rs_map: dict[int, Decimal] = {}
|
||||
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("BEGIN")
|
||||
cur.execute(
|
||||
"SET LOCAL app.current_site_id = %s", (str(site_id),)
|
||||
)
|
||||
# CHANGE 2026-03-20 | H2 FDW→直连ETL | fdw_etl.v_dim_member + v_dws_member_assistant_relation_index
|
||||
# → 直连 ETL 库查 app.v_* RLS 视图
|
||||
# intent: 修复 RLS 门店隔离失效(postgres_fdw 不传递 GUC 参数)
|
||||
from app.services.fdw_queries import _fdw_context
|
||||
|
||||
with _fdw_context(conn, site_id) as cur:
|
||||
# 读取客户基本信息
|
||||
# 列名映射: FDW 外部表 member_name/member_phone → RLS 视图 nickname/mobile
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT member_id, member_name, member_phone
|
||||
FROM fdw_etl.v_dim_member
|
||||
SELECT member_id, nickname, mobile
|
||||
FROM app.v_dim_member
|
||||
WHERE member_id = ANY(%s)
|
||||
""",
|
||||
(member_ids,),
|
||||
@@ -194,7 +200,7 @@ async def get_task_list(user_id: int, site_id: int) -> list[dict]:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT member_id, COALESCE(rs_display, 0)
|
||||
FROM fdw_etl.v_dws_member_assistant_relation_index
|
||||
FROM app.v_dws_member_assistant_relation_index
|
||||
WHERE assistant_id = %s
|
||||
AND member_id = ANY(%s)
|
||||
""",
|
||||
@@ -203,8 +209,6 @@ async def get_task_list(user_id: int, site_id: int) -> list[dict]:
|
||||
for row in cur.fetchall():
|
||||
rs_map[row[0]] = Decimal(str(row[1]))
|
||||
|
||||
conn.commit()
|
||||
|
||||
# 组装结果
|
||||
result = []
|
||||
for task_row in tasks:
|
||||
@@ -598,26 +602,24 @@ async def get_task_list_v2(
|
||||
logger.warning("FDW 查询 lastVisitDays 失败", exc_info=True)
|
||||
|
||||
# ── 5. RS 指数(用于 heart_score) ──
|
||||
# CHANGE 2026-03-20 | H2 FDW→直连ETL | fdw_etl → app(直连 ETL 库)
|
||||
rs_map: dict[int, Decimal] = {}
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("BEGIN")
|
||||
cur.execute(
|
||||
"SET LOCAL app.current_site_id = %s", (str(site_id),)
|
||||
)
|
||||
from app.services.fdw_queries import _fdw_context
|
||||
|
||||
with _fdw_context(conn, site_id) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT member_id, COALESCE(rs_display, 0)
|
||||
FROM fdw_etl.v_dws_member_assistant_relation_index
|
||||
FROM app.v_dws_member_assistant_relation_index
|
||||
WHERE assistant_id = %s AND member_id = ANY(%s)
|
||||
""",
|
||||
(assistant_id, member_ids),
|
||||
)
|
||||
for row in cur.fetchall():
|
||||
rs_map[row[0]] = Decimal(str(row[1]))
|
||||
conn.commit()
|
||||
except Exception:
|
||||
logger.warning("FDW 查询 RS 指数失败", exc_info=True)
|
||||
logger.warning("ETL 查询 RS 指数失败", exc_info=True)
|
||||
try:
|
||||
conn.rollback()
|
||||
except Exception:
|
||||
@@ -831,17 +833,16 @@ async def get_task_detail(
|
||||
customer_name = info.get("nickname") or "未知客户"
|
||||
|
||||
# RS 指数
|
||||
# CHANGE 2026-03-20 | H2 FDW→直连ETL | fdw_etl → app(直连 ETL 库)
|
||||
rs_score = Decimal("0")
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("BEGIN")
|
||||
cur.execute(
|
||||
"SET LOCAL app.current_site_id = %s", (str(site_id),)
|
||||
)
|
||||
from app.services.fdw_queries import _fdw_context
|
||||
|
||||
with _fdw_context(conn, site_id) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT COALESCE(rs_display, 0)
|
||||
FROM fdw_etl.v_dws_member_assistant_relation_index
|
||||
FROM app.v_dws_member_assistant_relation_index
|
||||
WHERE assistant_id = %s AND member_id = %s
|
||||
""",
|
||||
(assistant_id, member_id),
|
||||
@@ -849,13 +850,8 @@ async def get_task_detail(
|
||||
rs_row = cur.fetchone()
|
||||
if rs_row:
|
||||
rs_score = Decimal(str(rs_row[0]))
|
||||
conn.commit()
|
||||
except Exception:
|
||||
logger.warning("FDW 查询 RS 指数失败", exc_info=True)
|
||||
try:
|
||||
conn.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
logger.warning("ETL 查询 RS 指数失败", exc_info=True)
|
||||
|
||||
# ── 3. 查询维客线索 ──
|
||||
retention_clues = []
|
||||
|
||||
Reference in New Issue
Block a user