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:
hankin
2026-03-16 07:31:37 +00:00
commit 423baff73b
2578 changed files with 824643 additions and 0 deletions
View File
+145
View File
@@ -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
+127
View File
@@ -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,
}