815cbf9d8c
- 更新 .gitignore:全面覆盖环境变量、数据库、日志、缓存、上传文件 - 移除误跟踪的 server/venv/、crm_data.db、.env 文件 - 新增 server/.env.example 模板 - 新增合同管理、利润核算、AI教练等功能模块 - 新增 Playwright e2e 测试套件 - 前后端多项功能升级和 bug 修复
361 lines
14 KiB
Python
361 lines
14 KiB
Python
"""
|
|
AI 复盘报告路由 —— /api/reports
|
|
- POST /generate: SSE 流式生成复盘报告
|
|
- POST /confirm: 确认存档报告
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import uuid
|
|
from datetime import date, datetime
|
|
|
|
from fastapi import APIRouter, Body, Depends, Header, Query
|
|
from fastapi.responses import StreamingResponse
|
|
from sqlalchemy import and_, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.api.deps import get_current_user, get_current_company_id
|
|
from app.db.database import get_db
|
|
from app.schemas.auth import CurrentUserPayload
|
|
from app.schemas.response import ok
|
|
|
|
router = APIRouter(prefix="/reports", tags=["AI 复盘报告"])
|
|
|
|
|
|
@router.post("/generate", summary="SSE 流式生成复盘报告")
|
|
async def generate_report(
|
|
start_date: date = Body(..., embed=True),
|
|
end_date: date = Body(..., embed=True),
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: CurrentUserPayload = Depends(get_current_user),
|
|
company_id: uuid.UUID = Depends(get_current_company_id),
|
|
authorization: str | None = Header(None),
|
|
):
|
|
"""
|
|
1. 聚合该用户在时间范围内、涉及当前公司的 sales_logs 内容
|
|
2. 调用 Dify Workflow (streaming) 生成复盘报告
|
|
3. SSE 流式返回给前端
|
|
"""
|
|
return StreamingResponse(
|
|
_report_sse_generator(db, current_user, start_date, end_date, authorization or "", company_id),
|
|
media_type="text/event-stream",
|
|
)
|
|
|
|
|
|
async def _report_sse_generator(
|
|
db: AsyncSession,
|
|
user: CurrentUserPayload,
|
|
start_date: date,
|
|
end_date: date,
|
|
authorization: str = "",
|
|
company_id: uuid.UUID | None = None,
|
|
):
|
|
import httpx
|
|
from app.core.config import settings
|
|
from app.models.ai import SalesLog
|
|
|
|
# 1. 聚合日志 — 仅提取涉及当前公司的日志
|
|
conditions = [
|
|
SalesLog.salesperson_id == user.user_id,
|
|
SalesLog.log_date >= start_date,
|
|
SalesLog.log_date <= end_date,
|
|
SalesLog.is_deleted.is_(False),
|
|
]
|
|
if company_id:
|
|
conditions.append(SalesLog.involved_company_ids.any(company_id))
|
|
|
|
stmt = (
|
|
select(SalesLog)
|
|
.where(*conditions)
|
|
.order_by(SalesLog.log_date)
|
|
)
|
|
logs = (await db.execute(stmt)).scalars().all()
|
|
|
|
if not logs:
|
|
yield f"data: {json.dumps({'type': 'text', 'content': '⚠️ 该时间段内暂无销售日志数据,无法生成复盘报告。'}, ensure_ascii=False)}\n\n"
|
|
yield f"data: {json.dumps({'type': 'done'}, ensure_ascii=False)}\n\n"
|
|
return
|
|
|
|
# 拼接日志摘要
|
|
log_summary = "\n".join([
|
|
f"[{log.log_date}] {log.content}" for log in logs
|
|
])
|
|
|
|
yield f"data: {json.dumps({'type': 'text', 'content': f'📊 找到 {len(logs)} 条日志,正在调用 AI 生成复盘报告...\\n\\n'}, ensure_ascii=False)}\n\n"
|
|
|
|
# 2. 调用 Dify Workflow
|
|
if not settings.DIFY_WORKFLOW_REPORT_KEY or not settings.DIFY_API_BASE_URL:
|
|
yield f"data: {json.dumps({'type': 'text', 'content': '⚠️ 周报 Workflow 未配置,请联系管理员设置 DIFY_WORKFLOW_REPORT_KEY。'}, ensure_ascii=False)}\n\n"
|
|
yield f"data: {json.dumps({'type': 'done'}, ensure_ascii=False)}\n\n"
|
|
return
|
|
|
|
url = f"{settings.DIFY_API_BASE_URL}/v1/workflows/run"
|
|
headers = {
|
|
"Authorization": f"Bearer {settings.DIFY_WORKFLOW_REPORT_KEY}",
|
|
"Content-Type": "application/json",
|
|
}
|
|
payload = {
|
|
"inputs": {
|
|
"user_id": str(user.user_id),
|
|
"user_name": user.real_name or user.username,
|
|
"period_start": start_date.isoformat(),
|
|
"period_end": end_date.isoformat(),
|
|
"report_type": "monthly",
|
|
"sales_logs": log_summary,
|
|
"request": f"请基于以上 {len(logs)} 条销售日志,生成 {start_date} 至 {end_date} 的复盘报告。",
|
|
"authorization": authorization,
|
|
},
|
|
"response_mode": "streaming",
|
|
"user": str(user.user_id),
|
|
}
|
|
|
|
print(f"[Report SSE] 开始调用 Dify: {url}")
|
|
print(f"[Report SSE] payload inputs keys: {list(payload['inputs'].keys())}")
|
|
|
|
try:
|
|
async with httpx.AsyncClient(timeout=httpx.Timeout(600.0, connect=30.0)) as client:
|
|
async with client.stream("POST", url, json=payload, headers=headers) as resp:
|
|
print(f"[Report SSE] Dify 响应状态: {resp.status_code}")
|
|
if resp.status_code != 200:
|
|
error_text = ""
|
|
async for chunk in resp.aiter_text():
|
|
error_text += chunk
|
|
print(f"[Report SSE] Dify 错误: {error_text[:500]}")
|
|
if resp.status_code in (401, 403):
|
|
yield f"data: {json.dumps({'type': 'text', 'content': '⚠️ Dify API Key 无效或已过期 (HTTP {}), 请在系统设置中检查 DIFY_WORKFLOW_REPORT_KEY 配置。'.format(resp.status_code)}, ensure_ascii=False)}\n\n"
|
|
else:
|
|
yield f"data: {json.dumps({'type': 'text', 'content': f'⚠️ Dify 返回错误 ({resp.status_code}): {error_text[:200]}'}, ensure_ascii=False)}\n\n"
|
|
yield f"data: {json.dumps({'type': 'done'}, ensure_ascii=False)}\n\n"
|
|
return
|
|
|
|
buf = ""
|
|
chunk_count = 0
|
|
async for chunk in resp.aiter_text():
|
|
chunk_count += 1
|
|
buf += chunk
|
|
while "\n\n" in buf:
|
|
event_block, buf = buf.split("\n\n", 1)
|
|
data_line = ""
|
|
for line in event_block.split("\n"):
|
|
if line.startswith("data: "):
|
|
data_line = line[6:]
|
|
if not data_line or data_line.strip() == "[DONE]":
|
|
continue
|
|
try:
|
|
event = json.loads(data_line)
|
|
except json.JSONDecodeError:
|
|
print(f"[Report SSE] JSON 解析失败: {data_line[:100]}")
|
|
continue
|
|
|
|
event_type = event.get("event", "")
|
|
# 打印所有事件的概要信息
|
|
event_data = event.get("data", {})
|
|
node_id = event_data.get("node_id", "")
|
|
node_type = event_data.get("node_type", "")
|
|
status = event_data.get("status", "")
|
|
error_msg = event_data.get("error", "")
|
|
print(f"[Report SSE] event={event_type} node_type={node_type} status={status} error={str(error_msg)[:200]}")
|
|
|
|
if event_type == "text_chunk":
|
|
text = event.get("data", {}).get("text", "")
|
|
if text:
|
|
yield f"data: {json.dumps({'type': 'text', 'content': text}, ensure_ascii=False)}\n\n"
|
|
|
|
elif event_type == "node_finished":
|
|
node_data = event.get("data", {})
|
|
node_type = node_data.get("node_type", "")
|
|
print(f"[Report SSE] node_finished: node_type={node_type}")
|
|
# 捕获 LLM 节点的输出
|
|
if node_type == "llm":
|
|
llm_outputs = node_data.get("outputs", {})
|
|
llm_text = llm_outputs.get("text", "")
|
|
if llm_text:
|
|
print(f"[Report SSE] LLM 节点输出: {len(llm_text)} 字符")
|
|
yield f"data: {json.dumps({'type': 'text', 'content': llm_text}, ensure_ascii=False)}\n\n"
|
|
|
|
elif event_type == "workflow_finished":
|
|
outputs = event.get("data", {}).get("outputs", {})
|
|
print(f"[Report SSE] workflow_finished data keys: {list(event.get('data', {}).keys())}")
|
|
print(f"[Report SSE] workflow_finished outputs keys: {list(outputs.keys())}")
|
|
print(f"[Report SSE] workflow_finished outputs preview: {str(outputs)[:500]}")
|
|
output_text = outputs.get("text", "") or outputs.get("output", "") or outputs.get("result", "")
|
|
if not output_text:
|
|
# 尝试取第一个非空值
|
|
for v in outputs.values():
|
|
if isinstance(v, str) and len(v) > 20:
|
|
output_text = v
|
|
break
|
|
if output_text:
|
|
yield f"data: {json.dumps({'type': 'text', 'content': output_text}, ensure_ascii=False)}\n\n"
|
|
print(f"[Report SSE] Workflow 完成, output_text长度: {len(output_text)}")
|
|
|
|
print(f"[Report SSE] 流结束,共收到 {chunk_count} 个 chunk")
|
|
|
|
except httpx.TimeoutException:
|
|
print(f"[Report SSE] 超时!")
|
|
yield f"data: {json.dumps({'type': 'text', 'content': '\\n⚠️ Dify 响应超时(120秒),请稍后重试'}, ensure_ascii=False)}\n\n"
|
|
except Exception as e:
|
|
print(f"[Report SSE] 异常: {e!s}")
|
|
yield f"data: {json.dumps({'type': 'text', 'content': f'\\n⚠️ 报告生成失败: {e!s}'}, ensure_ascii=False)}\n\n"
|
|
|
|
yield f"data: {json.dumps({'type': 'done'}, ensure_ascii=False)}\n\n"
|
|
print(f"[Report SSE] SSE 流完全结束")
|
|
|
|
|
|
@router.post("/confirm", summary="确认并存档复盘报告")
|
|
async def confirm_report(
|
|
start_date: date = Body(..., embed=True),
|
|
end_date: date = Body(..., embed=True),
|
|
content_md: str = Body(..., embed=True),
|
|
report_type: str = Body("monthly", embed=True),
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: CurrentUserPayload = Depends(get_current_user),
|
|
) -> dict:
|
|
from app.models.ai import AiReportDraft
|
|
|
|
report = AiReportDraft(
|
|
author_id=current_user.user_id,
|
|
report_type=report_type,
|
|
period_start=start_date,
|
|
period_end=end_date,
|
|
content_md=content_md,
|
|
status="confirmed",
|
|
)
|
|
db.add(report)
|
|
await db.commit()
|
|
await db.refresh(report)
|
|
return ok(
|
|
data={"id": str(report.id), "status": report.status},
|
|
message="复盘报告已确认存档",
|
|
)
|
|
|
|
|
|
@router.get("/history", summary="查询复盘报告历史列表")
|
|
async def list_reports(
|
|
page: int = Query(1, ge=1),
|
|
size: int = Query(20, ge=1, le=100),
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: CurrentUserPayload = Depends(get_current_user),
|
|
) -> dict:
|
|
from sqlalchemy import func as sa_func, desc
|
|
from app.models.ai import AiReportDraft
|
|
|
|
where = [
|
|
AiReportDraft.author_id == current_user.user_id,
|
|
AiReportDraft.is_deleted.is_(False),
|
|
]
|
|
|
|
total = (
|
|
await db.execute(select(sa_func.count()).select_from(AiReportDraft).where(*where))
|
|
).scalar() or 0
|
|
|
|
stmt = (
|
|
select(AiReportDraft)
|
|
.where(*where)
|
|
.order_by(desc(AiReportDraft.created_at))
|
|
.offset((page - 1) * size)
|
|
.limit(size)
|
|
)
|
|
rows = (await db.execute(stmt)).scalars().all()
|
|
|
|
items = [
|
|
{
|
|
"id": str(r.id),
|
|
"report_type": r.report_type,
|
|
"period_start": r.period_start.isoformat(),
|
|
"period_end": r.period_end.isoformat(),
|
|
"status": r.status,
|
|
"content_md": r.content_md,
|
|
"created_at": r.created_at.isoformat() if r.created_at else None,
|
|
"updated_at": r.updated_at.isoformat() if r.updated_at else None,
|
|
}
|
|
for r in rows
|
|
]
|
|
return ok(data={"total": total, "items": items, "page": page, "size": size})
|
|
|
|
|
|
@router.put("/{report_id}", summary="修改复盘报告内容")
|
|
async def update_report(
|
|
report_id: uuid.UUID,
|
|
content_md: str = Body(..., embed=True),
|
|
status: str = Body("confirmed", embed=True),
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: CurrentUserPayload = Depends(get_current_user),
|
|
) -> dict:
|
|
from app.models.ai import AiReportDraft
|
|
|
|
report = (
|
|
await db.execute(
|
|
select(AiReportDraft).where(
|
|
AiReportDraft.id == report_id,
|
|
AiReportDraft.author_id == current_user.user_id,
|
|
AiReportDraft.is_deleted.is_(False),
|
|
)
|
|
)
|
|
).scalar_one_or_none()
|
|
if report is None:
|
|
from app.core.exceptions import NotFoundException
|
|
raise NotFoundException("报告不存在")
|
|
|
|
report.content_md = content_md
|
|
report.status = status
|
|
await db.commit()
|
|
return ok(message="报告已更新")
|
|
|
|
|
|
@router.delete("/{report_id}", summary="删除复盘报告")
|
|
async def delete_report(
|
|
report_id: uuid.UUID,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: CurrentUserPayload = Depends(get_current_user),
|
|
) -> dict:
|
|
from app.models.ai import AiReportDraft
|
|
|
|
report = (
|
|
await db.execute(
|
|
select(AiReportDraft).where(
|
|
AiReportDraft.id == report_id,
|
|
AiReportDraft.author_id == current_user.user_id,
|
|
AiReportDraft.is_deleted.is_(False),
|
|
)
|
|
)
|
|
).scalar_one_or_none()
|
|
if report is None:
|
|
from app.core.exceptions import NotFoundException
|
|
raise NotFoundException("报告不存在")
|
|
|
|
report.is_deleted = True
|
|
await db.commit()
|
|
return ok(message="报告已删除")
|
|
|
|
|
|
@router.post("/drafts", summary="Dify Workflow 回调 — 接收 LLM 生成的复盘报告")
|
|
async def receive_draft(
|
|
author_id: uuid.UUID = Body(..., embed=True),
|
|
report_type: str = Body("monthly", embed=True),
|
|
period_start: date = Body(..., embed=True),
|
|
period_end: date = Body(..., embed=True),
|
|
content_md: str = Body(..., embed=True),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> dict:
|
|
"""供 Dify Workflow HTTP 请求 2 回调使用,无需 CRM 用户认证。"""
|
|
from app.models.ai import AiReportDraft
|
|
|
|
report = AiReportDraft(
|
|
author_id=author_id,
|
|
report_type=report_type,
|
|
period_start=period_start,
|
|
period_end=period_end,
|
|
content_md=content_md,
|
|
status="confirmed",
|
|
)
|
|
db.add(report)
|
|
await db.commit()
|
|
await db.refresh(report)
|
|
print(f"[Report Drafts] Dify 回调存储成功: {report.id}, 内容长度: {len(content_md)}")
|
|
return ok(
|
|
data={"id": str(report.id), "status": report.status},
|
|
message="复盘报告已由 Dify Workflow 存档",
|
|
)
|
|
|