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,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_messagesrole=user
- 流式完成后完整 assistant 回复写入 ai_messagesrole=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 可能是 dictpsycopg2 自动解析 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_memberscd2_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_amountitems_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)

View File

@@ -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_headWHERE 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_head1: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

View File

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

View File

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

View File

@@ -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 全表扫描无 WHERERLS 是唯一门店过滤手段。
# -*- 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 全表扫描(无 WHERERLS 隔离是唯一的门店过滤手段
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())

View File

@@ -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_v2TASK-1、get_task_detailTASK-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 = []