v0.1.0: CRM/ERP 系统内测版本 - 安全加固完成
- Docker bridge 网络隔离(8000 端口封死) - Gunicorn 4 Worker 多进程 - Alembic 数据库迁移基线 - 日志轮转 20m×3 - JWT 密钥 + DB 密码 + CORS 收紧 - 3-2-1 备份链路(NAS + R740-B 冷备) - 连接池 pool_pre_ping + pool_recycle=3600
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
AI 工作流服务 (Dify BaaS 版本)
|
||||
事件驱动的异步 AI 任务:从客户沟通日志中自动提取标签和生成跟进待办。
|
||||
|
||||
架构要点:
|
||||
- 此函数由 FastAPI BackgroundTasks 调用,运行在独立的后台线程中
|
||||
- 必须使用独立的 DB Session 生命周期,严禁与主请求共享 Session
|
||||
- AI 调用通过 Dify 平台 API(非直连大模型)
|
||||
- AI 解析失败时静默降级(记录日志),绝不抛出异常导致主线程崩溃
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from app.core.dify_client import dify_client
|
||||
from app.core.database import AsyncSessionLocal
|
||||
from app.models.crm_business import CustomerTag, FollowUpToDo
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger("ai_workflow")
|
||||
|
||||
|
||||
async def process_log_with_ai(
|
||||
log_id: uuid.UUID,
|
||||
content: str,
|
||||
customer_id: uuid.UUID,
|
||||
) -> None:
|
||||
"""
|
||||
后台 AI 任务:分析沟通日志,提取标签和待办。
|
||||
|
||||
*** 关键约束 ***
|
||||
此函数在 BackgroundTasks 中执行,必须:
|
||||
1. 使用独立的 AsyncSession(不与主请求共享)
|
||||
2. 内部捕获所有异常,不向外抛出
|
||||
3. AI 返回格式异常时静默降级
|
||||
"""
|
||||
logger.info("开始 AI 处理: log_id=%s, customer_id=%s", log_id, customer_id)
|
||||
|
||||
if not settings.DIFY_LOG_APP_API_KEY:
|
||||
logger.error("DIFY_LOG_APP_API_KEY 未配置,跳过 AI 处理")
|
||||
return
|
||||
|
||||
# ---- 独立的 DB Session 生命周期 ----
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
# Step 1: 调用 Dify 日志分析 Workflow App
|
||||
# *** inputs 的键名必须与 Dify 后台配置的变量名对齐 ***
|
||||
# 在 Dify 后台的 Workflow 编排中,需定义输入变量 "log_content"
|
||||
workflow_outputs = await dify_client.call_workflow(
|
||||
api_key=settings.DIFY_LOG_APP_API_KEY,
|
||||
inputs={"log_content": content},
|
||||
)
|
||||
|
||||
if not workflow_outputs:
|
||||
logger.warning("Dify 返回空响应,跳过入库 (log_id=%s)", log_id)
|
||||
return
|
||||
|
||||
# Workflow 返回的是 dict,序列化为 JSON 字符串以兼容现有解析管线
|
||||
if isinstance(workflow_outputs, dict):
|
||||
raw_response = json.dumps(workflow_outputs, ensure_ascii=False)
|
||||
else:
|
||||
raw_response = str(workflow_outputs)
|
||||
|
||||
logger.debug("Dify Workflow 原始返回: %s", raw_response[:500])
|
||||
|
||||
# Step 2: 解析 JSON 响应 (容错)
|
||||
json_str = _extract_json(raw_response)
|
||||
result = json.loads(json_str)
|
||||
|
||||
tags: list[str] = result.get("tags", [])
|
||||
next_task: str = result.get("next_task", "")
|
||||
|
||||
# Step 3: 写入标签 (最多 3 个)
|
||||
if tags:
|
||||
for tag_name in tags[:3]:
|
||||
tag_name = tag_name.strip()
|
||||
if not tag_name:
|
||||
continue
|
||||
tag = CustomerTag(
|
||||
customer_id=customer_id,
|
||||
tag_name=tag_name,
|
||||
)
|
||||
db.add(tag)
|
||||
logger.info("写入 %d 个标签: %s", len(tags[:3]), tags[:3])
|
||||
|
||||
# Step 4: 写入跟进待办
|
||||
if next_task and next_task.strip():
|
||||
todo = FollowUpToDo(
|
||||
customer_id=customer_id,
|
||||
task_desc=next_task.strip(),
|
||||
status="pending",
|
||||
)
|
||||
db.add(todo)
|
||||
logger.info("写入待办: %s", next_task.strip()[:100])
|
||||
|
||||
await db.commit()
|
||||
logger.info("AI 处理完成: log_id=%s", log_id)
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(
|
||||
"Dify 返回 JSON 解析失败 (log_id=%s): %s | 原始响应: %s",
|
||||
log_id, e, raw_response[:300],
|
||||
)
|
||||
await db.rollback()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"AI 后台任务异常 (log_id=%s): %s",
|
||||
log_id, e, exc_info=True,
|
||||
)
|
||||
await db.rollback()
|
||||
|
||||
|
||||
def _extract_json(text: str) -> str:
|
||||
"""
|
||||
从 Dify/LLM 响应中提取 JSON 字符串。
|
||||
处理常见的"包裹"行为:
|
||||
- 直接返回 JSON
|
||||
- 用 ```json ... ``` 包裹
|
||||
- 在 JSON 前后加解释文字
|
||||
"""
|
||||
text = text.strip()
|
||||
|
||||
# 尝试提取 ```json ... ``` 代码块
|
||||
if "```json" in text:
|
||||
start = text.index("```json") + len("```json")
|
||||
end = text.index("```", start)
|
||||
return text[start:end].strip()
|
||||
|
||||
if "```" in text:
|
||||
start = text.index("```") + len("```")
|
||||
end = text.index("```", start)
|
||||
return text[start:end].strip()
|
||||
|
||||
# 尝试找到第一个 { 和最后一个 }
|
||||
first_brace = text.find("{")
|
||||
last_brace = text.rfind("}")
|
||||
if first_brace != -1 and last_brace != -1 and last_brace > first_brace:
|
||||
return text[first_brace:last_brace + 1]
|
||||
|
||||
# 原样返回,让 json.loads 报错触发容错
|
||||
return text
|
||||
@@ -0,0 +1,127 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
销售数据分析服务 (Dify BaaS 版本)
|
||||
基于 SQL 预聚合的销售漏斗统计 + Dify AI 驱动的复盘报告生成。
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import func, select, extract, case
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.dify_client import dify_client
|
||||
from app.models.crm_business import SalesOpportunity
|
||||
|
||||
logger = logging.getLogger("analytics")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 1. SQL 预聚合:销售漏斗指标
|
||||
# ============================================================
|
||||
|
||||
async def get_sales_metrics(db: AsyncSession) -> list[dict]:
|
||||
"""
|
||||
按当月统计各销售阶段的机会数量和总金额。
|
||||
使用 SQLAlchemy GROUP BY 聚合查询,在数据库层面完成计算。
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
stmt = (
|
||||
select(
|
||||
SalesOpportunity.stage,
|
||||
func.count(SalesOpportunity.id).label("count"),
|
||||
func.coalesce(func.sum(SalesOpportunity.amount), 0).label("total_amount"),
|
||||
)
|
||||
.where(
|
||||
extract("year", SalesOpportunity.created_at) == now.year,
|
||||
extract("month", SalesOpportunity.created_at) == now.month,
|
||||
)
|
||||
.group_by(SalesOpportunity.stage)
|
||||
.order_by(
|
||||
case(
|
||||
(SalesOpportunity.stage == "意向", 1),
|
||||
(SalesOpportunity.stage == "谈判", 2),
|
||||
(SalesOpportunity.stage == "成交", 3),
|
||||
(SalesOpportunity.stage == "流失", 4),
|
||||
else_=5,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
rows = result.all()
|
||||
|
||||
metrics = [
|
||||
{
|
||||
"stage": row.stage,
|
||||
"count": row.count,
|
||||
"total_amount": float(row.total_amount),
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
logger.info("当月销售指标: %s", metrics)
|
||||
return metrics
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 2. Dify AI 复盘报告生成
|
||||
# ============================================================
|
||||
|
||||
async def generate_monthly_report(db: AsyncSession) -> dict:
|
||||
"""
|
||||
生成当月销售复盘报告:
|
||||
1. SQL 预聚合获取真实数据指标
|
||||
2. 将结构化数据通过 inputs 传给 Dify 报告 App
|
||||
3. 直接返回 Dify 生成的报告文本
|
||||
|
||||
:return: {"metrics": [...], "report": "Dify 生成的报告文本"}
|
||||
"""
|
||||
# Step 1: 获取真实销售数据
|
||||
metrics = await get_sales_metrics(db)
|
||||
|
||||
if not metrics:
|
||||
return {
|
||||
"metrics": [],
|
||||
"report": "当月暂无销售机会数据,无法生成复盘报告。",
|
||||
}
|
||||
|
||||
# Step 2: 将数据序列化为 Dify 可消费的文本格式
|
||||
# *** inputs 的键名必须与 Dify 后台配置的变量名对齐 ***
|
||||
# 在 Dify 后台的报告 App 编排中,需定义输入变量 "metrics_data"
|
||||
metrics_text = "\n".join(
|
||||
f"- {m['stage']}: {m['count']} 个机会, 总金额 ¥{m['total_amount']:,.2f}"
|
||||
for m in metrics
|
||||
)
|
||||
total_count = sum(m["count"] for m in metrics)
|
||||
total_amount = sum(m["total_amount"] for m in metrics)
|
||||
metrics_text += f"\n- 合计: {total_count} 个机会, 总金额 ¥{total_amount:,.2f}"
|
||||
|
||||
# Step 3: 调用 Dify 报告 App
|
||||
if not settings.DIFY_REPORT_APP_API_KEY:
|
||||
logger.error("DIFY_REPORT_APP_API_KEY 未配置")
|
||||
return {
|
||||
"metrics": metrics,
|
||||
"report": "AI 报告服务未配置,请联系管理员。",
|
||||
}
|
||||
|
||||
# 动态生成 Dify 后台所需的必填参数 report_period
|
||||
now = datetime.now(timezone.utc)
|
||||
report_period = f"{now.year}年{now.month:02d}月"
|
||||
|
||||
report_text = await dify_client.call_text_generator(
|
||||
api_key=settings.DIFY_REPORT_APP_API_KEY,
|
||||
inputs={"metrics_data": metrics_text, "report_period": report_period},
|
||||
)
|
||||
|
||||
if not report_text:
|
||||
report_text = "AI 报告生成失败,请稍后重试或检查 Dify 服务状态。"
|
||||
|
||||
logger.info("月度复盘报告生成完成 (%d 字)", len(report_text))
|
||||
|
||||
return {
|
||||
"metrics": metrics,
|
||||
"report": report_text,
|
||||
}
|
||||
Reference in New Issue
Block a user