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,100 @@
|
||||
"""
|
||||
Auth 路由 —— /api/auth/login & /api/auth/me
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.core.exceptions import BizException, UnauthorizedException
|
||||
from app.core.security import create_access_token, hash_password, verify_password
|
||||
from app.db.database import get_db
|
||||
from app.models.sys import SysUser
|
||||
from app.schemas.auth import (
|
||||
CurrentUserPayload,
|
||||
LoginRequest,
|
||||
TokenResponse,
|
||||
UpdatePasswordRequest,
|
||||
)
|
||||
from app.schemas.response import ok
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["鉴权"])
|
||||
|
||||
|
||||
@router.post("/login", summary="账号密码登录,签发 JWT")
|
||||
async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)) -> dict:
|
||||
# 1. 查询用户
|
||||
stmt = select(SysUser).where(
|
||||
SysUser.username == body.username,
|
||||
SysUser.is_deleted.is_(False),
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user is None:
|
||||
raise BizException(code=401, message="用户名或密码错误")
|
||||
|
||||
# 2. 校验密码
|
||||
if not verify_password(body.password, user.password_hash):
|
||||
raise BizException(code=401, message="用户名或密码错误")
|
||||
|
||||
# 3. 检查账号状态
|
||||
if user.status != 1:
|
||||
raise BizException(code=403, message="账号已被禁用,请联系管理员")
|
||||
|
||||
# 4. 签发 Token(sub 存 user_id 字符串)
|
||||
access_token = create_access_token(data={"sub": str(user.id)})
|
||||
|
||||
# 5. 刷新最后登录时间
|
||||
await db.execute(
|
||||
update(SysUser)
|
||||
.where(SysUser.id == user.id)
|
||||
.values(last_login_at=datetime.utcnow())
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return ok(
|
||||
data=TokenResponse(access_token=access_token).model_dump(),
|
||||
message="登录成功",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", summary="获取当前登录用户信息(验证 Token 有效性)")
|
||||
async def get_me(
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
) -> dict:
|
||||
return ok(data=current_user.model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.put("/password", summary="当前用户修改密码")
|
||||
async def change_password(
|
||||
body: UpdatePasswordRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
) -> dict:
|
||||
# 1. 查出用户记录
|
||||
stmt = select(SysUser).where(SysUser.id == current_user.user_id)
|
||||
result = await db.execute(stmt)
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
raise BizException(code=404, message="用户不存在")
|
||||
|
||||
# 2. 校验旧密码
|
||||
if not verify_password(body.old_password, user.password_hash):
|
||||
raise BizException(code=400, message="旧密码错误")
|
||||
|
||||
# 3. 哈希新密码并更新
|
||||
await db.execute(
|
||||
update(SysUser)
|
||||
.where(SysUser.id == current_user.user_id)
|
||||
.values(password_hash=hash_password(body.new_password), updated_at=datetime.utcnow())
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return ok(message="密码修改成功,请重新登录")
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
"""
|
||||
AI 智能助手路由 —— /api/chat
|
||||
- POST /stream: 流式对话(SSE)
|
||||
- GET /mcp/tools: 返回已注册的 MCP 工具清单
|
||||
- POST /mcp/execute: 执行指定 MCP 工具
|
||||
- POST /action-card/callback: Action Card 确认/取消回调
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, Depends
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.db.database import get_db
|
||||
from app.schemas.auth import CurrentUserPayload
|
||||
from app.schemas.action_card import ActionCardCallback
|
||||
from app.schemas.response import ok
|
||||
|
||||
# 导入 MCP 注册中心(导入 tools 模块会触发 @register_tool 装饰器注册)
|
||||
from app.mcp.registry import get_tools_manifest, execute_tool, MCPToolResult
|
||||
import app.mcp.tools # noqa: F401 — 触发工具注册
|
||||
from app.core.action_card_queue import pop_cards
|
||||
|
||||
# 导入 service 层(用于 action card 回调真正执行)
|
||||
from app.services import customer_service, order_service
|
||||
from app.schemas.crm import CustomerCreate
|
||||
from app.schemas.order import OrderCreate
|
||||
|
||||
router = APIRouter(prefix="/chat", tags=["AI 智能助手"])
|
||||
|
||||
|
||||
class ChatRequest(BaseModel):
|
||||
message: str
|
||||
conversation_id: str = "" # Dify 会话 ID,空则新建会话
|
||||
|
||||
|
||||
# ── Dify 可用性探测 ──────────────────────────────────────
|
||||
async def _check_dify_available() -> bool:
|
||||
"""检测 Dify API 是否可用(2s 超时),探测根路径即可"""
|
||||
from app.core.config import settings
|
||||
if not settings.DIFY_API_BASE_URL or not settings.DIFY_API_KEY:
|
||||
return False
|
||||
try:
|
||||
import httpx
|
||||
async with httpx.AsyncClient(timeout=2.0) as client:
|
||||
resp = await client.get(settings.DIFY_API_BASE_URL)
|
||||
return resp.status_code < 500 # 2xx/3xx/4xx 都说明服务存活
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# ── <think> 标签流式过滤器 ────────────────────────────────
|
||||
class _ThinkFilter:
|
||||
"""
|
||||
状态机:跨多个 SSE chunk 过滤 <think>...</think>。
|
||||
流式场景下标签可能被拆分到不同 chunk 中到达。
|
||||
"""
|
||||
def __init__(self):
|
||||
self._inside = False # 是否正处于 <think> 块内
|
||||
self._buf = "" # 缓冲可能是不完整标签的尾部
|
||||
|
||||
def feed(self, text: str) -> str:
|
||||
"""输入一段文本,返回过滤后应输出的部分"""
|
||||
out = []
|
||||
self._buf += text
|
||||
while self._buf:
|
||||
if self._inside:
|
||||
# 在 think 块内,找 </think> 结束标签
|
||||
end_pos = self._buf.lower().find("</think>")
|
||||
if end_pos >= 0:
|
||||
# 找到了,跳过 </think> 本身(8 字符)
|
||||
self._buf = self._buf[end_pos + 8:]
|
||||
self._inside = False
|
||||
elif len(self._buf) > 8 and "</think>" not in self._buf.lower():
|
||||
# 确认不含部分标签,整块吞掉
|
||||
# 保留最后 8 字符以防 </think> 跨 chunk
|
||||
self._buf = self._buf[-8:]
|
||||
break
|
||||
else:
|
||||
# 缓冲区太短或可能含部分标签,等下一个 chunk
|
||||
break
|
||||
else:
|
||||
# 不在 think 块内,找 <think> 开始标签
|
||||
start_pos = self._buf.lower().find("<think>")
|
||||
if start_pos >= 0:
|
||||
# <think> 之前的内容输出
|
||||
out.append(self._buf[:start_pos])
|
||||
# 跳过 <think> 本身(7 字符),进入 inside 状态
|
||||
self._buf = self._buf[start_pos + 7:]
|
||||
self._inside = True
|
||||
elif len(self._buf) > 7:
|
||||
# 安全输出,保留最后 7 字符防跨 chunk 的 <think>
|
||||
out.append(self._buf[:-7])
|
||||
self._buf = self._buf[-7:]
|
||||
break
|
||||
else:
|
||||
break
|
||||
return "".join(out)
|
||||
|
||||
|
||||
# ── Dify 流式转发生成器 ──────────────────────────────────
|
||||
async def _dify_stream_generator(message: str, user_id: str, conversation_id: str = ""):
|
||||
"""将用户消息转发到 Dify chat-messages API,流式返回"""
|
||||
from app.core.config import settings
|
||||
import httpx
|
||||
|
||||
url = f"{settings.DIFY_API_BASE_URL}/v1/chat-messages"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {settings.DIFY_API_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload = {
|
||||
"inputs": {},
|
||||
"query": message,
|
||||
"response_mode": "streaming",
|
||||
"conversation_id": conversation_id,
|
||||
"user": user_id,
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=settings.DIFY_TIMEOUT_MS / 1000) as client:
|
||||
async with client.stream("POST", url, json=payload, headers=headers) as resp:
|
||||
if resp.status_code != 200:
|
||||
error_text = ""
|
||||
async for chunk in resp.aiter_text():
|
||||
error_text += chunk
|
||||
yield f"data: {json.dumps({'type': 'text', 'content': f'Dify 返回错误 ({resp.status_code}): {error_text[:200]}'}, ensure_ascii=False)}\n\n"
|
||||
return
|
||||
|
||||
# 用原始 text 做 SSE 解析,因为 aiter_lines 可能丢失 event 行
|
||||
buf = ""
|
||||
think_filter = _ThinkFilter() # 跨 chunk 过滤 <think> 标签
|
||||
message_ended = False # 标记文本流是否结束
|
||||
async for chunk in resp.aiter_text():
|
||||
buf += chunk
|
||||
# 按双换行分割完整 SSE 事件
|
||||
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:
|
||||
continue
|
||||
|
||||
event_type = event.get("event", "")
|
||||
|
||||
# ── Chatflow 模式:message 事件
|
||||
if event_type == "message" and not message_ended:
|
||||
answer = think_filter.feed(event.get("answer", ""))
|
||||
if answer:
|
||||
yield f"data: {json.dumps({'type': 'text', 'content': answer}, ensure_ascii=False)}\n\n"
|
||||
|
||||
# ── Agent 模式:agent_message 事件(最终文本回复)
|
||||
elif event_type == "agent_message" and not message_ended:
|
||||
answer = think_filter.feed(event.get("answer", ""))
|
||||
if answer:
|
||||
yield f"data: {json.dumps({'type': 'text', 'content': answer}, ensure_ascii=False)}\n\n"
|
||||
|
||||
# ── Agent 模式:agent_thought — 提取工具调用的 action_card
|
||||
elif event_type == "agent_thought":
|
||||
observation = event.get("observation", "")
|
||||
if observation:
|
||||
try:
|
||||
obs_data = json.loads(observation)
|
||||
for tool_key, tool_val in (obs_data.items() if isinstance(obs_data, dict) else []):
|
||||
inner = tool_val
|
||||
if isinstance(tool_val, str):
|
||||
try:
|
||||
inner = json.loads(tool_val)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if not isinstance(inner, dict):
|
||||
continue
|
||||
result_data = inner.get("data", inner)
|
||||
if isinstance(result_data, dict):
|
||||
resp_type = result_data.get("response_type", "")
|
||||
result_inner = result_data.get("result", {})
|
||||
if resp_type == "action_card" and isinstance(result_inner, dict):
|
||||
yield f"data: {json.dumps({'type': 'action_card', 'content': result_inner.get('summary', '请确认操作'), 'card': result_inner}, ensure_ascii=False)}\n\n"
|
||||
print(f"[Dify SSE] ✅ 注入 action_card: {result_inner.get('card_type', 'unknown')}")
|
||||
except (json.JSONDecodeError, TypeError) as e:
|
||||
print(f"[Dify SSE] observation 解析失败: {e}")
|
||||
|
||||
# ── message_end / 结束 — 不再 break,设置标记继续处理剩余事件
|
||||
elif event_type in ("message_end", "agent_message_end"):
|
||||
conv_id = event.get("conversation_id", "")
|
||||
if conv_id:
|
||||
yield f"data: {json.dumps({'type': 'conversation_id', 'conversation_id': conv_id}, ensure_ascii=False)}\n\n"
|
||||
# 检查工具端点是否推送了 action_card
|
||||
cards = await pop_cards()
|
||||
for card in cards:
|
||||
yield f"data: {json.dumps({'type': 'action_card', 'content': card.get('summary', '请确认操作'), 'card': card}, ensure_ascii=False)}\n\n"
|
||||
print(f"[Dify SSE] ✅ 注入 action_card: {card.get('card_type', 'unknown')}")
|
||||
message_ended = True
|
||||
|
||||
# ── 错误
|
||||
elif event_type == "error":
|
||||
err_msg = event.get("message", "未知错误")
|
||||
yield f"data: {json.dumps({'type': 'text', 'content': f'\\n⚠️ Dify 错误: {err_msg}'}, ensure_ascii=False)}\n\n"
|
||||
return # 出错直接返回
|
||||
|
||||
# ── 流结束后,刷新 buf 中可能剩余的事件(重要:agent_thought 可能在 message_end 之后)
|
||||
if buf.strip():
|
||||
for remaining_block in buf.split("\n\n"):
|
||||
remaining_block = remaining_block.strip()
|
||||
if not remaining_block:
|
||||
continue
|
||||
data_line = ""
|
||||
for line in remaining_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)
|
||||
if event.get("event") == "agent_thought":
|
||||
observation = event.get("observation", "")
|
||||
if observation:
|
||||
obs_data = json.loads(observation)
|
||||
for tool_key, tool_val in (obs_data.items() if isinstance(obs_data, dict) else []):
|
||||
inner = tool_val
|
||||
if isinstance(tool_val, str):
|
||||
try:
|
||||
inner = json.loads(tool_val)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if not isinstance(inner, dict):
|
||||
continue
|
||||
result_data = inner.get("data", inner)
|
||||
if isinstance(result_data, dict) and result_data.get("response_type") == "action_card":
|
||||
result_inner = result_data.get("result", {})
|
||||
if isinstance(result_inner, dict):
|
||||
yield f"data: {json.dumps({'type': 'action_card', 'content': result_inner.get('summary', '请确认操作'), 'card': result_inner}, ensure_ascii=False)}\n\n"
|
||||
print(f"[Dify SSE] ✅ 注入 action_card (post-flush): {result_inner.get('card_type', 'unknown')}")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
except httpx.TimeoutException:
|
||||
yield f"data: {json.dumps({'type': 'text', 'content': '\n⚠️ Dify 响应超时,请稍后重试'}, ensure_ascii=False)}\n\n"
|
||||
except Exception as e:
|
||||
print(f"[Dify SSE] 异常: {e!s}")
|
||||
yield f"data: {json.dumps({'type': 'text', 'content': f'\n⚠️ Dify 连接失败: {e!s}'}, ensure_ascii=False)}\n\n"
|
||||
|
||||
|
||||
# ── Mock 流式生成器(降级用) ────────────────────────────
|
||||
async def _mock_stream_generator(message: str, user_id: str):
|
||||
"""Dify 不可用时的降级 mock 回复"""
|
||||
await asyncio.sleep(0.3)
|
||||
|
||||
notice = "⚠️ AI 引擎暂不可用,当前为本地模拟回复。\n\n"
|
||||
for char in notice:
|
||||
yield f"data: {json.dumps({'type': 'text', 'content': char}, ensure_ascii=False)}\n\n"
|
||||
await asyncio.sleep(0.02)
|
||||
|
||||
reply = f"收到指令,正在解析:【{message}】...\n来自用户: {user_id}\n"
|
||||
for char in reply:
|
||||
yield f"data: {json.dumps({'type': 'text', 'content': char}, ensure_ascii=False)}\n\n"
|
||||
await asyncio.sleep(0.04)
|
||||
|
||||
|
||||
# ── 1. POST /api/chat/stream — 流式对话(含意图网关) ────
|
||||
@router.post("/stream", summary="流式对话接口(SSE)")
|
||||
async def chat_stream(
|
||||
body: ChatRequest,
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
):
|
||||
user_id = str(current_user.user_id)
|
||||
print(f"[AI Chat] user_id={user_id}, scope={current_user.data_scope}, msg='{body.message}'")
|
||||
|
||||
# Step 1: 意图分类(4060 Qwen3.5-4B,5s 超时)
|
||||
from app.services.intent_service import classify_intent
|
||||
intent_result = await classify_intent(body.message)
|
||||
intent = intent_result.get("intent", "general")
|
||||
route = intent_result.get("route", "dify_agent")
|
||||
print(f"[AI Chat] 意图网关: intent={intent}, route={route}")
|
||||
|
||||
# Step 2: 根据意图路由到不同后端
|
||||
dify_available = await _check_dify_available()
|
||||
|
||||
if route == "dify_agent" and dify_available:
|
||||
generator = _dify_stream_generator(body.message, user_id, body.conversation_id)
|
||||
elif route == "dify_workflow_report" and dify_available:
|
||||
# 周报走 Workflow,非流式,包装成 SSE
|
||||
generator = _report_workflow_generator(body.message, user_id)
|
||||
elif dify_available:
|
||||
generator = _dify_stream_generator(body.message, user_id, body.conversation_id)
|
||||
else:
|
||||
generator = _mock_stream_generator(body.message, user_id)
|
||||
|
||||
return StreamingResponse(generator, media_type="text/event-stream")
|
||||
|
||||
|
||||
# ── 周报 Workflow 生成器 ───────────────────────────
|
||||
async def _report_workflow_generator(message: str, user_id: str):
|
||||
"""调用周报 Workflow 并把结果包装成 SSE 流"""
|
||||
from app.core.config import settings
|
||||
import httpx
|
||||
|
||||
yield f"data: {json.dumps({'type': 'text', 'content': '📊 正在调用 AI 生成周报,请稍候...\n\n'}, ensure_ascii=False)}\n\n"
|
||||
|
||||
if not settings.DIFY_WORKFLOW_REPORT_KEY:
|
||||
yield f"data: {json.dumps({'type': 'text', 'content': '⚠️ 周报 Workflow 未配置,请联系管理员。'}, 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": user_id, "request": message},
|
||||
"response_mode": "blocking",
|
||||
"user": user_id,
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=60) as client:
|
||||
resp = await client.post(url, json=payload, headers=headers)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
output_text = data.get("data", {}).get("outputs", {}).get("text", "周报生成完成,但未获取到内容。")
|
||||
yield f"data: {json.dumps({'type': 'text', 'content': output_text}, ensure_ascii=False)}\n\n"
|
||||
else:
|
||||
yield f"data: {json.dumps({'type': 'text', 'content': f'⚠️ 周报 Workflow 返回错误 ({resp.status_code})'}, ensure_ascii=False)}\n\n"
|
||||
except Exception as e:
|
||||
yield f"data: {json.dumps({'type': 'text', 'content': f'⚠️ 周报生成失败: {e}'}, ensure_ascii=False)}\n\n"
|
||||
|
||||
|
||||
# ── 2. GET /api/chat/mcp/tools — 工具清单 ───────────────
|
||||
@router.get("/mcp/tools", summary="获取已注册 MCP 工具列表(供 Dify 配置)")
|
||||
async def list_mcp_tools(
|
||||
_: CurrentUserPayload = Depends(get_current_user),
|
||||
) -> dict:
|
||||
return ok(data=get_tools_manifest())
|
||||
|
||||
|
||||
# ── 3. POST /api/chat/mcp/execute — 执行工具 ────────────
|
||||
class MCPExecuteRequest(BaseModel):
|
||||
tool_name: str
|
||||
params: dict[str, Any] = {}
|
||||
|
||||
|
||||
@router.post("/mcp/execute", summary="执行指定 MCP 工具(Dify function_call 回调)")
|
||||
async def execute_mcp_tool(
|
||||
body: MCPExecuteRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
) -> dict:
|
||||
print(f"[MCP Execute] user_id={current_user.user_id}, tool={body.tool_name}, params={body.params}")
|
||||
result: MCPToolResult = await execute_tool(body.tool_name, db, current_user, body.params)
|
||||
return ok(data={
|
||||
"success": result.success,
|
||||
"response_type": result.response_type,
|
||||
"data": result.data,
|
||||
"message": result.message,
|
||||
})
|
||||
|
||||
|
||||
# ── 4. POST /api/chat/action-card/callback — 卡片回调 ──
|
||||
@router.post("/action-card/callback", summary="Action Card 确认/取消回调")
|
||||
async def action_card_callback(
|
||||
body: ActionCardCallback,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
) -> dict:
|
||||
print(f"[Action Card] user_id={current_user.user_id}, type={body.card_type}, action={body.action_key}")
|
||||
|
||||
if body.action_key == "cancel":
|
||||
return ok(message="操作已取消")
|
||||
|
||||
# 根据 card_type 路由到具体 service
|
||||
if body.card_type == "create_customer":
|
||||
p = body.params
|
||||
result = await customer_service.create_customer(
|
||||
db, current_user,
|
||||
CustomerCreate(
|
||||
name=p.get("name", ""),
|
||||
level=p.get("level", "C"),
|
||||
contact=p.get("contact"),
|
||||
phone=p.get("phone"),
|
||||
),
|
||||
)
|
||||
return ok(data=result.model_dump(mode="json"), message="客户创建成功")
|
||||
|
||||
elif body.card_type == "create_order":
|
||||
from datetime import date
|
||||
p = body.params
|
||||
items_raw = p.get("items", [])
|
||||
from app.schemas.order import OrderItemCreate
|
||||
items = [
|
||||
OrderItemCreate(
|
||||
sku_id=uuid.UUID(i["sku_id"]),
|
||||
qty=i["qty"],
|
||||
unit_price=i["unit_price"],
|
||||
)
|
||||
for i in items_raw
|
||||
]
|
||||
result = await order_service.create_order(
|
||||
db, current_user,
|
||||
OrderCreate(
|
||||
customer_id=uuid.UUID(p["customer_id"]),
|
||||
items=items,
|
||||
remark=p.get("remark"),
|
||||
order_date=date.today(),
|
||||
),
|
||||
)
|
||||
return ok(data=result.model_dump(mode="json"), message=f"订单 {result.order_no} 创建成功")
|
||||
|
||||
return ok(message=f"未知的卡片类型: {body.card_type}")
|
||||
|
||||
|
||||
# ── 5. GET /api/chat/history — 对话历史 ──────────────────
|
||||
@router.get("/history", summary="获取当前用户的 AI 对话历史")
|
||||
async def get_chat_history(
|
||||
limit: int = 50,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
) -> dict:
|
||||
from app.services import chat_service
|
||||
history = await chat_service.load_history(db, current_user.user_id, limit=limit)
|
||||
return ok(data=history)
|
||||
@@ -0,0 +1,72 @@
|
||||
"""
|
||||
联系人 API 路由 — /api/customers/{cid}/contacts & /api/contacts/{id}
|
||||
V5.0: 实现客户下联系人的完整 CRUD
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from fastapi import APIRouter, Depends, Body
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.db.database import get_db
|
||||
from app.schemas.auth import CurrentUserPayload
|
||||
from app.schemas.response import ok
|
||||
from app.services import contact_service
|
||||
|
||||
router = APIRouter(tags=["联系人"])
|
||||
|
||||
|
||||
@router.get("/customers/{customer_id}/contacts", summary="列出客户下所有联系人")
|
||||
async def list_contacts(
|
||||
customer_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: CurrentUserPayload = Depends(get_current_user),
|
||||
) -> dict:
|
||||
items = await contact_service.list_contacts(db, customer_id)
|
||||
return ok(data=items)
|
||||
|
||||
|
||||
@router.post("/customers/{customer_id}/contacts", summary="新增联系人")
|
||||
async def create_contact(
|
||||
customer_id: uuid.UUID,
|
||||
name: str = Body(..., embed=True),
|
||||
phone: str | None = Body(None, embed=True),
|
||||
title: str | None = Body(None, embed=True),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: CurrentUserPayload = Depends(get_current_user),
|
||||
) -> dict:
|
||||
result = await contact_service.create_contact(
|
||||
db, customer_id, {"name": name, "phone": phone, "title": title}
|
||||
)
|
||||
return ok(data=result, message="联系人创建成功")
|
||||
|
||||
|
||||
@router.put("/contacts/{contact_id}", summary="编辑联系人")
|
||||
async def update_contact(
|
||||
contact_id: uuid.UUID,
|
||||
name: str | None = Body(None, embed=True),
|
||||
phone: str | None = Body(None, embed=True),
|
||||
title: str | None = Body(None, embed=True),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: CurrentUserPayload = Depends(get_current_user),
|
||||
) -> dict:
|
||||
data = {}
|
||||
if name is not None:
|
||||
data["name"] = name
|
||||
if phone is not None:
|
||||
data["phone"] = phone
|
||||
if title is not None:
|
||||
data["title"] = title
|
||||
result = await contact_service.update_contact(db, contact_id, data)
|
||||
return ok(data=result, message="联系人更新成功")
|
||||
|
||||
|
||||
@router.delete("/contacts/{contact_id}", summary="删除联系人 (软删除)")
|
||||
async def delete_contact(
|
||||
contact_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: CurrentUserPayload = Depends(get_current_user),
|
||||
) -> dict:
|
||||
await contact_service.delete_contact(db, contact_id)
|
||||
return ok(message="联系人已删除")
|
||||
@@ -0,0 +1,286 @@
|
||||
"""
|
||||
CRM 客户模块路由 —— /api/customers
|
||||
薄路由层:参数解析 + 调用 Service + 包装响应
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import uuid
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.api.deps import get_current_user
|
||||
from app.db.database import get_db
|
||||
from app.schemas.auth import CurrentUserPayload
|
||||
from app.schemas.crm import CustomerCreate, CustomerUpdate
|
||||
from app.schemas.response import ok
|
||||
from app.services import customer_service as svc
|
||||
|
||||
router = APIRouter(prefix="/customers", tags=["客户管理"])
|
||||
|
||||
|
||||
@router.post("", summary="新增客户")
|
||||
async def create_customer(
|
||||
body: CustomerCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
) -> dict:
|
||||
result = await svc.create_customer(db, current_user, body)
|
||||
return ok(data=result.model_dump(mode="json"), message="客户创建成功")
|
||||
|
||||
|
||||
@router.get("", summary="分页获取客户列表(含数据权限隔离)")
|
||||
async def list_customers(
|
||||
page: int = Query(1, ge=1, description="页码"),
|
||||
size: int = Query(20, ge=1, le=100, description="每页数量"),
|
||||
keyword: str | None = Query(None, description="名称模糊搜索"),
|
||||
level: str | None = Query(None, pattern=r"^[ABC]$", description="客户等级"),
|
||||
include_archived: bool = Query(False, description="是否包含已归档客户"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
) -> dict:
|
||||
result = await svc.list_customers(db, current_user, page, size, keyword, level, include_archived)
|
||||
return ok(data=result.model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.get("/search", summary="模糊搜索客户(远程选择器用)")
|
||||
async def search_customers(
|
||||
q: str = Query(..., min_length=1, max_length=100, description="搜索关键词"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
) -> dict:
|
||||
result = await svc.search_customers(db, current_user, q)
|
||||
return ok(data=result)
|
||||
|
||||
|
||||
@router.get("/{customer_id}", summary="获取客户详情")
|
||||
async def get_customer(
|
||||
customer_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
) -> dict:
|
||||
result = await svc.get_customer(db, current_user, customer_id)
|
||||
return ok(data=result.model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.put("/{customer_id}", summary="修改客户信息")
|
||||
async def update_customer(
|
||||
customer_id: uuid.UUID,
|
||||
body: CustomerUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
) -> dict:
|
||||
result = await svc.update_customer(db, current_user, customer_id, body)
|
||||
return ok(data=result.model_dump(mode="json"), message="客户信息已更新")
|
||||
|
||||
|
||||
@router.delete("/{customer_id}", summary="软删除客户")
|
||||
async def delete_customer(
|
||||
customer_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
) -> dict:
|
||||
await svc.delete_customer(db, current_user, customer_id)
|
||||
return ok(message="客户已归档")
|
||||
|
||||
|
||||
@router.put("/{customer_id}/restore", summary="恢复已归档客户")
|
||||
async def restore_customer(
|
||||
customer_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
) -> dict:
|
||||
await svc.restore_customer(db, current_user, customer_id)
|
||||
return ok(message="客户已恢复")
|
||||
|
||||
|
||||
@router.get("/{customer_id}/products", summary="获取客户关联产品(通过订单反查)")
|
||||
async def get_customer_products(
|
||||
customer_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
) -> dict:
|
||||
result = await svc.get_customer_products(db, current_user, customer_id)
|
||||
return ok(data=result)
|
||||
|
||||
|
||||
@router.put("/{customer_id}/persona", summary="双轨画像回写(Dify Workflow 回调)")
|
||||
async def update_customer_persona(
|
||||
customer_id: uuid.UUID,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
"""
|
||||
V5.0 双轨画像回写 — 容错解析 Dify Workflow 回调 body。
|
||||
支持以下格式:
|
||||
1. 标准 JSON: {"company_updates": {...}, "contact_updates": [...]}
|
||||
2. Dify 空 key 包装: {"": "JSON字符串"}
|
||||
3. Qwen3.5 CoT 包裹: <think>...</think> + JSON
|
||||
"""
|
||||
import json as _json
|
||||
import re
|
||||
|
||||
raw = await request.body()
|
||||
raw_str = raw.decode("utf-8", errors="replace").strip()
|
||||
|
||||
# ── 统一预处理:去除 <think>...</think> 标签 ──
|
||||
cleaned = re.sub(r'<think>[\s\S]*?</think>', '', raw_str, flags=re.DOTALL).strip()
|
||||
# 也处理可能缺少闭合的 <think>
|
||||
cleaned = re.sub(r'<think>[\s\S]*$', '', cleaned, flags=re.DOTALL).strip()
|
||||
|
||||
body = None
|
||||
|
||||
# 策略1:清理后直接 JSON 解析
|
||||
try:
|
||||
parsed = _json.loads(cleaned)
|
||||
if isinstance(parsed, dict):
|
||||
body = parsed
|
||||
elif isinstance(parsed, list):
|
||||
# 从数组中找到含画像 key 的 dict
|
||||
for item in parsed:
|
||||
if isinstance(item, dict) and len(item) > 1:
|
||||
body = item
|
||||
break
|
||||
except _json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# 策略2:Dify 空 key 包装 {"": "...JSON..."}
|
||||
if not body:
|
||||
try:
|
||||
wrapped = _json.loads(raw_str)
|
||||
if isinstance(wrapped, dict):
|
||||
values = list(wrapped.values())
|
||||
if len(values) == 1 and isinstance(values[0], str):
|
||||
inner = re.sub(r'<think>[\s\S]*?</think>', '', values[0], flags=re.DOTALL).strip()
|
||||
try:
|
||||
body = _json.loads(inner)
|
||||
except _json.JSONDecodeError:
|
||||
pass
|
||||
except _json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# 策略3:正则从文本中提取最外层 JSON 对象 {...}
|
||||
if not body or not isinstance(body, dict):
|
||||
# 找到第一个 { 到最后一个 } 之间的内容
|
||||
m = re.search(r'\{', cleaned)
|
||||
if m:
|
||||
start = m.start()
|
||||
# 用计数法匹配完整的 JSON 对象
|
||||
depth = 0
|
||||
end = start
|
||||
for i in range(start, len(cleaned)):
|
||||
if cleaned[i] == '{': depth += 1
|
||||
elif cleaned[i] == '}': depth -= 1
|
||||
if depth == 0:
|
||||
end = i + 1
|
||||
break
|
||||
if depth == 0:
|
||||
try:
|
||||
body = _json.loads(cleaned[start:end])
|
||||
except _json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
if not body or not isinstance(body, dict):
|
||||
print(f"[Persona] 无法解析 body ({len(raw_str)} chars): {cleaned[:300]}")
|
||||
return ok(message="画像回写失败:无法解析请求体")
|
||||
|
||||
print(f"[Persona] 解析成功,keys={list(body.keys())}")
|
||||
|
||||
from app.models.crm import CrmCustomer, CrmContact
|
||||
from sqlalchemy import update as sa_update
|
||||
|
||||
# Key 模糊映射:LLM 可能不严格遵循 key name
|
||||
_COMPANY_KEYS = {"company_updates", "company_info", "firmographics", "company", "企业画像"}
|
||||
_CONTACT_KEYS = {"contact_updates", "contacts", "contact_info", "buyer_updates", "联系人画像"}
|
||||
|
||||
company_updates = None
|
||||
contact_updates = None
|
||||
for k, v in body.items():
|
||||
kl = k.lower().strip()
|
||||
print(f"[Persona] key='{k}' type={type(v).__name__}")
|
||||
if kl in _COMPANY_KEYS or "company" in kl or "firm" in kl:
|
||||
if isinstance(v, dict):
|
||||
company_updates = v
|
||||
elif isinstance(v, str):
|
||||
try:
|
||||
company_updates = _json.loads(v)
|
||||
except _json.JSONDecodeError:
|
||||
company_updates = {"summary": v}
|
||||
elif kl in _CONTACT_KEYS or "contact" in kl:
|
||||
if isinstance(v, list):
|
||||
contact_updates = v
|
||||
elif isinstance(v, dict):
|
||||
contact_updates = [v]
|
||||
elif isinstance(v, str):
|
||||
try:
|
||||
parsed_contacts = _json.loads(v)
|
||||
contact_updates = parsed_contacts if isinstance(parsed_contacts, list) else [parsed_contacts]
|
||||
except _json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# 如果 company_updates 没有标准结构但有 firmographics/dynamic_status 在顶层
|
||||
if not company_updates and ("firmographics" in body or "dynamic_status" in body):
|
||||
company_updates = {
|
||||
"firmographics": body.get("firmographics", {}),
|
||||
"dynamic_status": body.get("dynamic_status", {}),
|
||||
}
|
||||
|
||||
# 如果完全没匹配到 company key,但 body 本身看起来就是画像数据,直接用整个 body
|
||||
if not company_updates and not contact_updates:
|
||||
company_updates = body
|
||||
|
||||
print(f"[Persona] company={'有' if company_updates else '无'}, contacts={len(contact_updates) if contact_updates else 0}条")
|
||||
|
||||
# ── 企业级画像合并 ──
|
||||
if company_updates:
|
||||
customer = await db.get(CrmCustomer, customer_id)
|
||||
if customer:
|
||||
merged = _deep_merge(customer.ai_persona or {}, company_updates)
|
||||
stmt = (
|
||||
sa_update(CrmCustomer)
|
||||
.where(CrmCustomer.id == customer_id)
|
||||
.values(ai_persona=merged)
|
||||
)
|
||||
await db.execute(stmt)
|
||||
|
||||
# ── 联系人级画像合并 ──
|
||||
if contact_updates and isinstance(contact_updates, list):
|
||||
for cu in contact_updates:
|
||||
cid = cu.get("contact_id")
|
||||
if not cid:
|
||||
continue
|
||||
try:
|
||||
contact = await db.get(CrmContact, uuid.UUID(cid))
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
if not contact:
|
||||
continue
|
||||
updates = {k: v for k, v in cu.items() if k != "contact_id" and v is not None}
|
||||
merged = _deep_merge(contact.ai_buyer_persona or {}, updates)
|
||||
contact.ai_buyer_persona = merged
|
||||
|
||||
await db.commit()
|
||||
return ok(message="画像更新成功")
|
||||
|
||||
|
||||
def _deep_merge(base: dict, override: dict) -> dict:
|
||||
"""递归深度合并两个 dict(增量模式)。
|
||||
规则:
|
||||
- dict + dict → 递归合并
|
||||
- list + list → 追加去重
|
||||
- 新值为空(空字符串/空列表/空dict/None)→ 保留旧值
|
||||
- 新值非空 → 覆盖旧值
|
||||
"""
|
||||
result = base.copy()
|
||||
for k, v in override.items():
|
||||
old = result.get(k)
|
||||
# 空值不覆盖已有数据
|
||||
if old and (v is None or v == "" or v == [] or v == {}):
|
||||
continue
|
||||
if isinstance(old, dict) and isinstance(v, dict):
|
||||
result[k] = _deep_merge(old, v)
|
||||
elif isinstance(old, list) and isinstance(v, list):
|
||||
# 列表去重追加
|
||||
seen = set(str(x) for x in old)
|
||||
result[k] = old + [x for x in v if str(x) not in seen]
|
||||
else:
|
||||
result[k] = v
|
||||
return result
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""
|
||||
Dashboard 统计 API — /api/dashboard
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import func, select, and_, extract
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.db.database import get_db
|
||||
from app.schemas.auth import CurrentUserPayload
|
||||
from app.schemas.response import ok
|
||||
|
||||
from app.models.order import ErpOrder
|
||||
from app.models.shipping import ErpShippingRecord
|
||||
from app.models.erp import ProductSku
|
||||
|
||||
router = APIRouter(prefix="/dashboard", tags=["Dashboard"])
|
||||
|
||||
|
||||
@router.get("/stats", summary="工作台统计数据")
|
||||
async def get_stats(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
):
|
||||
today = date.today()
|
||||
month_start = today.replace(day=1)
|
||||
|
||||
# 本月新增订单数
|
||||
orders_count_q = select(func.count()).select_from(ErpOrder).where(
|
||||
and_(
|
||||
ErpOrder.is_deleted.is_(False),
|
||||
ErpOrder.order_date >= month_start,
|
||||
)
|
||||
)
|
||||
orders_count = (await db.execute(orders_count_q)).scalar() or 0
|
||||
|
||||
# 待出库发货数(状态为 pending)
|
||||
pending_shipping_q = select(func.count()).select_from(ErpOrder).where(
|
||||
and_(
|
||||
ErpOrder.is_deleted.is_(False),
|
||||
ErpOrder.shipping_state == "pending",
|
||||
)
|
||||
)
|
||||
pending_shipping = (await db.execute(pending_shipping_q)).scalar() or 0
|
||||
|
||||
# 库存预警 SKU 数(stock_qty <= warning_threshold 且 warning_threshold > 0)
|
||||
warning_skus_q = select(func.count()).select_from(ProductSku).where(
|
||||
and_(
|
||||
ProductSku.is_deleted.is_(False),
|
||||
ProductSku.warning_threshold > 0,
|
||||
ProductSku.stock_qty <= ProductSku.warning_threshold,
|
||||
)
|
||||
)
|
||||
warning_skus = (await db.execute(warning_skus_q)).scalar() or 0
|
||||
|
||||
# 本月预计营收(本月订单总金额)
|
||||
revenue_q = select(func.coalesce(func.sum(ErpOrder.total_amount), 0)).where(
|
||||
and_(
|
||||
ErpOrder.is_deleted.is_(False),
|
||||
ErpOrder.order_date >= month_start,
|
||||
)
|
||||
)
|
||||
monthly_revenue = float((await db.execute(revenue_q)).scalar() or 0)
|
||||
|
||||
return ok(data={
|
||||
"orders_count": orders_count,
|
||||
"pending_shipping": pending_shipping,
|
||||
"warning_skus": warning_skus,
|
||||
"monthly_revenue": monthly_revenue,
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
"""
|
||||
FastAPI 依赖注入 —— 权限拦截核心
|
||||
get_current_user: 解析 JWT → 查表获取完整权限上下文
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import Depends, Header
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.exceptions import UnauthorizedException
|
||||
from app.core.security import decode_access_token
|
||||
from app.db.database import get_db
|
||||
from app.models.sys import SysUser
|
||||
from app.schemas.auth import CurrentUserPayload
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
authorization: str = Header(..., description="Bearer <token>"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> CurrentUserPayload:
|
||||
"""
|
||||
核心鉴权依赖:
|
||||
1. 从 Header 提取 Bearer Token
|
||||
2. 解码 JWT 拿到 user_id
|
||||
3. 查 sys_users + 联表 role/dept 拿到 data_scope 等完整上下文
|
||||
4. 返回 CurrentUserPayload 供业务层使用
|
||||
"""
|
||||
# ── 解析 Bearer Token ──
|
||||
if not authorization.startswith("Bearer "):
|
||||
raise UnauthorizedException("Authorization 格式错误,需为 Bearer <token>")
|
||||
|
||||
token = authorization.removeprefix("Bearer ").strip()
|
||||
payload = decode_access_token(token)
|
||||
if payload is None:
|
||||
raise UnauthorizedException("Token 无效或已过期")
|
||||
|
||||
user_id: str | None = payload.get("sub")
|
||||
if user_id is None:
|
||||
raise UnauthorizedException("Token 载荷缺少 sub 字段")
|
||||
|
||||
# ── 查库获取用户及关联角色 ──
|
||||
stmt = (
|
||||
select(SysUser)
|
||||
.where(SysUser.id == user_id, SysUser.is_deleted.is_(False))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user is None:
|
||||
raise UnauthorizedException("用户不存在或已被停用")
|
||||
if user.status != 1:
|
||||
raise UnauthorizedException("账号已被禁用")
|
||||
|
||||
# ── 组装权限上下文 ──
|
||||
return CurrentUserPayload(
|
||||
user_id=user.id,
|
||||
username=user.username,
|
||||
real_name=user.real_name,
|
||||
dept_id=user.dept_id,
|
||||
dept_name=user.department.name if user.department else None,
|
||||
role_id=user.role_id,
|
||||
role_name=user.role.role_name if user.role else None,
|
||||
data_scope=user.role.data_scope if user.role else "self",
|
||||
menu_keys=user.role.menu_keys if user.role else [],
|
||||
)
|
||||
@@ -0,0 +1,205 @@
|
||||
"""
|
||||
Dify 专用工具路由 —— /api/dify/tools
|
||||
每个 MCP 工具对应一个独立 REST 端点,方便 Dify 通过 OpenAPI Schema 自动发现
|
||||
注意:这些端点由 Dify Agent 内部调用,使用 API Key 认证而非 JWT
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import uuid
|
||||
from typing import Any
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.db.database import get_db
|
||||
from app.schemas.auth import CurrentUserPayload
|
||||
from app.schemas.response import ok
|
||||
from app.mcp.registry import execute_tool
|
||||
import app.mcp.tools # noqa: F401
|
||||
from app.core.action_card_queue import push_card
|
||||
|
||||
router = APIRouter(prefix="/dify/tools", tags=["Dify 工具接口"])
|
||||
|
||||
|
||||
# ── Dify 工具专用认证:跳过 JWT,使用 Dify API Key 或直接放行 ──
|
||||
async def get_dify_user(
|
||||
authorization: str = Header("", description="可选的 Bearer <token>"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> CurrentUserPayload:
|
||||
"""
|
||||
Dify 调用工具时不携带 JWT,这里提供一个管理员级上下文。
|
||||
生产环境应增加 API Key 校验。
|
||||
"""
|
||||
# TODO: 生产环境加 Dify API Secret 校验
|
||||
return CurrentUserPayload(
|
||||
user_id=uuid.UUID("c0000000-0000-0000-0000-000000000001"), # admin
|
||||
username="admin",
|
||||
real_name="系统管理员",
|
||||
dept_id=uuid.UUID("a0000000-0000-0000-0000-000000000001"),
|
||||
dept_name="总部",
|
||||
role_id=uuid.UUID("b0000000-0000-0000-0000-000000000001"),
|
||||
role_name="超级管理员",
|
||||
data_scope="all",
|
||||
menu_keys=[],
|
||||
)
|
||||
|
||||
|
||||
# ── 1. 搜索客户 ──────────────────────────────────────────
|
||||
class SearchCustomersInput(BaseModel):
|
||||
keyword: str | None = Field(None, description="客户名称关键词")
|
||||
level: str | None = Field(None, description="客户等级: A / B / C")
|
||||
page: int = Field(1, description="页码")
|
||||
size: int = Field(10, description="每页数量")
|
||||
|
||||
|
||||
@router.post("/search_customers", summary="搜索客户列表,支持按名称模糊搜索和等级过滤")
|
||||
async def dify_search_customers(
|
||||
body: SearchCustomersInput,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_dify_user),
|
||||
) -> dict:
|
||||
result = await execute_tool("search_customers", db, current_user, body.model_dump(exclude_none=True))
|
||||
return ok(data={"response_type": result.response_type, "result": result.data, "message": result.message})
|
||||
|
||||
|
||||
# ── 2. 创建客户 ──────────────────────────────────────────
|
||||
class CreateCustomerInput(BaseModel):
|
||||
name: str = Field(..., description="客户名称")
|
||||
level: str = Field("C", description="客户等级: A / B / C")
|
||||
contact: str | None = Field(None, description="联系人")
|
||||
phone: str | None = Field(None, description="电话")
|
||||
|
||||
|
||||
@router.post("/create_customer", summary="创建新客户(返回确认卡片,需用户在前端确认后执行)")
|
||||
async def dify_create_customer(
|
||||
body: CreateCustomerInput,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_dify_user),
|
||||
) -> dict:
|
||||
result = await execute_tool("create_customer", db, current_user, body.model_dump(exclude_none=True))
|
||||
# action_card 结果推入共享队列,供 SSE 生成器注入前端
|
||||
if result.response_type == "action_card" and isinstance(result.data, dict):
|
||||
await push_card(result.data)
|
||||
return ok(data={"response_type": result.response_type, "result": result.data, "message": result.message})
|
||||
|
||||
|
||||
# ── 3. 查询客户专属报价 ──────────────────────────────────
|
||||
class CalculatePriceInput(BaseModel):
|
||||
customer_id: str = Field(..., description="客户 UUID")
|
||||
sku_id: str = Field(..., description="产品 SKU UUID")
|
||||
|
||||
|
||||
@router.post("/calculate_price", summary="查询客户专属报价:历史成交价追溯,无历史则标准价兜底")
|
||||
async def dify_calculate_price(
|
||||
body: CalculatePriceInput,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_dify_user),
|
||||
) -> dict:
|
||||
result = await execute_tool("calculate_price", db, current_user, body.model_dump())
|
||||
return ok(data={"response_type": result.response_type, "result": result.data, "message": result.message})
|
||||
|
||||
|
||||
# ── 4. 创建销售订单 ──────────────────────────────────────
|
||||
class OrderItemInput(BaseModel):
|
||||
sku_id: str = Field(..., description="产品 SKU UUID")
|
||||
qty: float = Field(..., description="数量")
|
||||
unit_price: float = Field(..., description="单价")
|
||||
|
||||
|
||||
class CreateOrderInput(BaseModel):
|
||||
customer_id: str = Field(..., description="客户 UUID")
|
||||
items: list[OrderItemInput] = Field(..., description="订单明细行列表")
|
||||
remark: str | None = Field(None, description="备注")
|
||||
|
||||
|
||||
@router.post("/create_order", summary="创建销售订单(返回确认卡片,需用户确认后执行)")
|
||||
async def dify_create_order(
|
||||
body: CreateOrderInput,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_dify_user),
|
||||
) -> dict:
|
||||
params = body.model_dump()
|
||||
params["items"] = [i.model_dump() for i in body.items]
|
||||
result = await execute_tool("create_order", db, current_user, params)
|
||||
if result.response_type == "action_card" and isinstance(result.data, dict):
|
||||
await push_card(result.data)
|
||||
return ok(data={"response_type": result.response_type, "result": result.data, "message": result.message})
|
||||
|
||||
|
||||
# ── 5. 搜索订单 ──────────────────────────────────────────
|
||||
class SearchOrdersInput(BaseModel):
|
||||
keyword: str | None = Field(None, description="客户名称关键词")
|
||||
order_no: str | None = Field(None, description="订单号模糊搜索")
|
||||
shipping_state: str | None = Field(None, description="发货状态: pending / partial / shipped")
|
||||
payment_state: str | None = Field(None, description="付款状态: unpaid / partial / cleared")
|
||||
page: int = Field(1, description="页码")
|
||||
size: int = Field(10, description="每页数量")
|
||||
|
||||
|
||||
@router.post("/search_orders", summary="搜索订单列表,支持按客户名称、订单号、发货/付款状态筛选")
|
||||
async def dify_search_orders(
|
||||
body: SearchOrdersInput,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_dify_user),
|
||||
) -> dict:
|
||||
result = await execute_tool("search_orders", db, current_user, body.model_dump(exclude_none=True))
|
||||
return ok(data={"response_type": result.response_type, "result": result.data, "message": result.message})
|
||||
|
||||
|
||||
# ── 6. 双轨画像回写 (V5.0) ──────────────────────────────
|
||||
class ContactUpdate(BaseModel):
|
||||
contact_id: str = Field(..., description="联系人 UUID")
|
||||
role: dict | None = Field(None, description="决策角色更新 {decision_role, authority_level}")
|
||||
kpi: dict | None = Field(None, description="KPI 更新 {core_goals, pain_points}")
|
||||
preference: dict | None = Field(None, description="偏好更新 {comm_style, meeting_preference, topics_of_interest}")
|
||||
|
||||
|
||||
class UpdatePersonaInput(BaseModel):
|
||||
customer_id: str = Field(..., description="客户 UUID")
|
||||
company_updates: dict | None = Field(None, description="企业级画像增量 {firmographics, dynamic_status}")
|
||||
contact_updates: list[ContactUpdate] | None = Field(None, description="联系人级画像增量列表")
|
||||
|
||||
|
||||
@router.post("/update_persona", summary="双轨画像回写:分别更新企业画像与联系人画像 (V5.0)")
|
||||
async def dify_update_persona(
|
||||
body: UpdatePersonaInput,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: CurrentUserPayload = Depends(get_dify_user),
|
||||
) -> dict:
|
||||
"""Dify Agent 画像提取 Workflow 调用此工具,分类写入企业/联系人画像"""
|
||||
import httpx
|
||||
from app.core.config import settings
|
||||
|
||||
# 内部转调 PUT /api/customers/{id}/persona
|
||||
payload: dict[str, Any] = {}
|
||||
if body.company_updates:
|
||||
payload["company_updates"] = body.company_updates
|
||||
if body.contact_updates:
|
||||
payload["contact_updates"] = [cu.model_dump(exclude_none=True) for cu in body.contact_updates]
|
||||
|
||||
from app.models.crm import CrmCustomer, CrmContact
|
||||
from app.api.customers import _deep_merge
|
||||
from sqlalchemy import update as sa_update
|
||||
|
||||
cid = uuid.UUID(body.customer_id)
|
||||
|
||||
if body.company_updates:
|
||||
customer = await db.get(CrmCustomer, cid)
|
||||
if customer:
|
||||
merged = _deep_merge(customer.ai_persona or {}, body.company_updates)
|
||||
stmt = sa_update(CrmCustomer).where(CrmCustomer.id == cid).values(ai_persona=merged)
|
||||
await db.execute(stmt)
|
||||
|
||||
if body.contact_updates:
|
||||
for cu in body.contact_updates:
|
||||
try:
|
||||
contact = await db.get(CrmContact, uuid.UUID(cu.contact_id))
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
if not contact:
|
||||
continue
|
||||
updates = cu.model_dump(exclude_none=True, exclude={"contact_id"})
|
||||
merged = _deep_merge(contact.ai_buyer_persona or {}, updates)
|
||||
contact.ai_buyer_persona = merged
|
||||
|
||||
await db.commit()
|
||||
return ok(message="双轨画像回写成功")
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
"""
|
||||
财务票据域路由 —— /api/finance
|
||||
薄路由层:参数解析 + 调用 Service + 包装响应
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import uuid
|
||||
import os
|
||||
import time
|
||||
import base64
|
||||
from fastapi import APIRouter, Depends, Query, Body, File, UploadFile, Form
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.api.deps import get_current_user
|
||||
from app.db.database import get_db
|
||||
from app.schemas.auth import CurrentUserPayload
|
||||
from app.schemas.finance import ExpenseCreate, ExpenseStatusUpdate, InvoiceCreate
|
||||
from app.schemas.response import ok
|
||||
from app.core.exceptions import BizException
|
||||
from app.services import finance_service as svc
|
||||
|
||||
router = APIRouter(prefix="/finance", tags=["财务票据"])
|
||||
|
||||
|
||||
@router.post("/ocr", summary="上传票据图片并做 AI 发票/名片 OCR 识别")
|
||||
async def ocr_recognize(
|
||||
file: UploadFile = File(...),
|
||||
scene: str = Form("invoice"),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
) -> dict:
|
||||
from app.services.ocr_service import ocr_image
|
||||
|
||||
# 读取并在本地保存原始文件
|
||||
file_bytes = await file.read()
|
||||
|
||||
upload_dir = "uploads/finance"
|
||||
os.makedirs(upload_dir, exist_ok=True)
|
||||
ext = os.path.splitext(file.filename or "")[1].lower() or ".png"
|
||||
ts = int(time.time())
|
||||
safe_filename = f"{ts}_{current_user.user_id}{ext}"
|
||||
file_path = os.path.join(upload_dir, safe_filename)
|
||||
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(file_bytes)
|
||||
|
||||
file_url = f"/uploads/finance/{safe_filename}"
|
||||
|
||||
# 仅支持图片(png/jpg/jpeg)和 PDF,不再支持 MD/TXT
|
||||
supported = {".png", ".jpg", ".jpeg", ".pdf"}
|
||||
if ext not in supported:
|
||||
raise BizException(message=f"不支持的文件格式 {ext},仅支持: {', '.join(supported)}")
|
||||
|
||||
# 如果是 PDF,转成 PNG 再做 OCR
|
||||
ocr_bytes = file_bytes
|
||||
if ext == ".pdf":
|
||||
try:
|
||||
import fitz # PyMuPDF
|
||||
doc = fitz.open(stream=file_bytes, filetype="pdf")
|
||||
page = doc[0] # 取第一页
|
||||
# 中等分辨率渲染(150 DPI,平衡质量与大小)
|
||||
pix = page.get_pixmap(dpi=150)
|
||||
ocr_bytes = pix.tobytes("png")
|
||||
doc.close()
|
||||
print(f"[OCR] PDF 转 PNG 成功: {len(ocr_bytes)} bytes")
|
||||
except Exception as e:
|
||||
print(f"[OCR] PDF 转换失败: {e}")
|
||||
return ok(data={"ocr_data": {}, "file_url": file_url}, message=f"PDF 转换失败: {e}")
|
||||
|
||||
# 转换为纯 base64 传给 OCR
|
||||
image_base64 = base64.b64encode(ocr_bytes).decode("utf-8")
|
||||
result = await ocr_image(image_base64, scene)
|
||||
|
||||
if result.get("success"):
|
||||
return ok(data={"ocr_data": result["data"], "file_url": file_url}, message="AI OCR 识别成功")
|
||||
return ok(data={"ocr_data": result.get("data", {}), "file_url": file_url}, message=result.get("error", "OCR 识别失败"))
|
||||
|
||||
|
||||
@router.post("/invoices", summary="上传票据入池(含 AI/OCR JSONB 数据)")
|
||||
async def create_invoice(
|
||||
body: InvoiceCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
) -> dict:
|
||||
result = await svc.create_invoice(db, current_user, body)
|
||||
return ok(data=result.model_dump(mode="json"), message="票据入池成功")
|
||||
|
||||
|
||||
@router.get("/invoices", summary="票据池列表(数据权限隔离)")
|
||||
async def list_invoices(
|
||||
page: int = Query(1, ge=1),
|
||||
size: int = Query(20, ge=1, le=100),
|
||||
type: str | None = Query(None, alias="category", pattern=r"^(expense|customer)$"),
|
||||
is_used: bool | None = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
) -> dict:
|
||||
result = await svc.list_invoices(db, current_user, page, size, type, is_used)
|
||||
return ok(data=result.model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.delete("/invoices/{invoice_id}", summary="作废票据(软删除)")
|
||||
async def void_invoice(
|
||||
invoice_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
) -> dict:
|
||||
await svc.void_invoice(db, current_user, invoice_id)
|
||||
return ok(message="票据已作废")
|
||||
|
||||
|
||||
@router.post("/expenses", summary="生成报销单(防重锁定 + 强事务)")
|
||||
async def create_expense(
|
||||
body: ExpenseCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
) -> dict:
|
||||
result = await svc.create_expense(db, current_user, body)
|
||||
return ok(data=result.model_dump(mode="json"), message=f"报销单 {result.system_no} 提交成功")
|
||||
|
||||
|
||||
@router.get("/expenses", summary="报销单大盘(多角色数据权限)")
|
||||
async def list_expenses(
|
||||
page: int = Query(1, ge=1),
|
||||
size: int = Query(20, ge=1, le=100),
|
||||
status: str | None = Query(None, pattern=r"^(submitted|approved|rejected|voided)$"),
|
||||
applicant_id: uuid.UUID | None = Query(None, description="按申请人过滤(管理员用)"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
) -> dict:
|
||||
result = await svc.list_expenses(db, current_user, page, size, status, applicant_id)
|
||||
return ok(data=result.model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.get("/expenses/{expense_id}", summary="报销单详情(含明细行)")
|
||||
async def get_expense(
|
||||
expense_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
) -> dict:
|
||||
result = await svc.get_expense(db, current_user, expense_id)
|
||||
return ok(data=result.model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.put("/expenses/{expense_id}/status", summary="审批/撤回报销单(含发票释放)")
|
||||
async def update_expense_status(
|
||||
expense_id: uuid.UUID,
|
||||
body: ExpenseStatusUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
) -> dict:
|
||||
msg = await svc.update_expense_status(db, current_user, expense_id, body)
|
||||
return ok(message=msg)
|
||||
@@ -0,0 +1,225 @@
|
||||
"""
|
||||
批量导入/导出路由 —— 产品导入 / 客户导入 / 客户导出 / 模板下载
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, File, UploadFile
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.db.database import get_db
|
||||
from app.schemas.auth import CurrentUserPayload
|
||||
from app.schemas.response import ok
|
||||
from app.core.exceptions import BizException, ForbiddenException
|
||||
|
||||
router = APIRouter(tags=["批量导入导出"])
|
||||
|
||||
|
||||
# ── 模板下载 ──────────────────────────────────────────
|
||||
TEMPLATES_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "templates")
|
||||
|
||||
|
||||
@router.get("/templates/{name}", summary="下载 Excel 导入模板")
|
||||
async def download_template(
|
||||
name: str,
|
||||
):
|
||||
allowed = {
|
||||
"product_import_template.xlsx",
|
||||
"customer_import_template.xlsx",
|
||||
}
|
||||
if name not in allowed:
|
||||
raise BizException(message=f"模板 {name} 不存在")
|
||||
|
||||
file_path = os.path.join(TEMPLATES_DIR, name)
|
||||
if not os.path.exists(file_path):
|
||||
raise BizException(message=f"模板文件 {name} 未找到")
|
||||
|
||||
return FileResponse(
|
||||
path=file_path,
|
||||
filename=name,
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
)
|
||||
|
||||
|
||||
# ── 产品批量导入 ──────────────────────────────────────
|
||||
@router.post("/products/import", summary="Excel 批量导入产品 SKU")
|
||||
async def import_products(
|
||||
file: UploadFile = File(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
):
|
||||
from openpyxl import load_workbook
|
||||
from app.models.erp import ErpProductSku
|
||||
|
||||
content = await file.read()
|
||||
wb = load_workbook(io.BytesIO(content))
|
||||
ws = wb.active
|
||||
if ws is None:
|
||||
raise BizException(message="Excel 文件无可用工作表")
|
||||
|
||||
rows = list(ws.iter_rows(min_row=2, values_only=True)) # 跳过表头
|
||||
if not rows:
|
||||
raise BizException(message="Excel 中无数据行")
|
||||
|
||||
created = 0
|
||||
skipped = 0
|
||||
errors = []
|
||||
|
||||
for i, row in enumerate(rows, start=2):
|
||||
try:
|
||||
sku_code = str(row[0] or "").strip()
|
||||
name = str(row[1] or "").strip()
|
||||
spec = str(row[2] or "").strip() or None
|
||||
standard_price = float(row[3] or 0)
|
||||
unit = str(row[4] or "桶").strip()
|
||||
warning_threshold = float(row[5] or 0)
|
||||
|
||||
if not sku_code or not name:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
# 检查 sku_code 是否已存在
|
||||
exists = (await db.execute(
|
||||
select(func.count()).select_from(ErpProductSku).where(
|
||||
ErpProductSku.sku_code == sku_code,
|
||||
ErpProductSku.is_deleted.is_(False),
|
||||
)
|
||||
)).scalar()
|
||||
if exists:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
sku = ErpProductSku(
|
||||
sku_code=sku_code,
|
||||
name=name,
|
||||
spec=spec,
|
||||
standard_price=standard_price,
|
||||
unit=unit,
|
||||
warning_threshold=warning_threshold,
|
||||
)
|
||||
db.add(sku)
|
||||
created += 1
|
||||
except Exception as e:
|
||||
errors.append(f"第{i}行: {e!s}")
|
||||
|
||||
await db.commit()
|
||||
return ok(
|
||||
data={"created": created, "skipped": skipped, "errors": errors},
|
||||
message=f"导入完成:新增 {created} 条,跳过 {skipped} 条",
|
||||
)
|
||||
|
||||
|
||||
# ── 客户批量导入 ──────────────────────────────────────
|
||||
@router.post("/crm/import", summary="Excel 批量导入客户")
|
||||
async def import_customers(
|
||||
file: UploadFile = File(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
):
|
||||
from openpyxl import load_workbook
|
||||
from app.models.crm import CrmCustomer
|
||||
|
||||
content = await file.read()
|
||||
wb = load_workbook(io.BytesIO(content))
|
||||
ws = wb.active
|
||||
if ws is None:
|
||||
raise BizException(message="Excel 文件无可用工作表")
|
||||
|
||||
rows = list(ws.iter_rows(min_row=2, values_only=True))
|
||||
if not rows:
|
||||
raise BizException(message="Excel 中无数据行")
|
||||
|
||||
created = 0
|
||||
skipped = 0
|
||||
errors = []
|
||||
|
||||
for i, row in enumerate(rows, start=2):
|
||||
try:
|
||||
name = str(row[0] or "").strip()
|
||||
level = str(row[1] or "C").strip().upper()
|
||||
industry = str(row[2] or "").strip() or None
|
||||
contact = str(row[3] or "").strip() or None
|
||||
phone = str(row[4] or "").strip() or None
|
||||
email = str(row[5] or "").strip() or None
|
||||
address = str(row[6] or "").strip() or None
|
||||
|
||||
if not name:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
if level not in ("A", "B", "C"):
|
||||
level = "C"
|
||||
|
||||
customer = CrmCustomer(
|
||||
name=name,
|
||||
level=level,
|
||||
industry=industry,
|
||||
contact=contact,
|
||||
phone=phone,
|
||||
email=email,
|
||||
address=address,
|
||||
owner_id=current_user.user_id,
|
||||
)
|
||||
db.add(customer)
|
||||
created += 1
|
||||
except Exception as e:
|
||||
errors.append(f"第{i}行: {e!s}")
|
||||
|
||||
await db.commit()
|
||||
return ok(
|
||||
data={"created": created, "skipped": skipped, "errors": errors},
|
||||
message=f"导入完成:新增 {created} 条,跳过 {skipped} 条",
|
||||
)
|
||||
|
||||
|
||||
# ── 客户导出(仅 admin) ─────────────────────────────
|
||||
@router.get("/crm/export", summary="导出客户数据 (仅管理员)")
|
||||
async def export_customers(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
):
|
||||
# 权限校验
|
||||
if current_user.data_scope != "all" and (current_user.role_name or "").lower() != "admin":
|
||||
raise ForbiddenException("仅管理员可导出客户数据")
|
||||
|
||||
from openpyxl import Workbook
|
||||
from app.models.crm import CrmCustomer
|
||||
|
||||
stmt = select(CrmCustomer).where(CrmCustomer.is_deleted.is_(False)).order_by(CrmCustomer.created_at.desc())
|
||||
customers = (await db.execute(stmt)).scalars().all()
|
||||
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "客户列表"
|
||||
ws.append(["客户名称", "等级", "行业", "联系人", "电话", "邮箱", "地址", "AI评分", "创建时间"])
|
||||
|
||||
for c in customers:
|
||||
ws.append([
|
||||
c.name,
|
||||
c.level,
|
||||
c.industry or "",
|
||||
c.contact or "",
|
||||
c.phone or "",
|
||||
c.email or "",
|
||||
c.address or "",
|
||||
float(c.ai_score or 0),
|
||||
c.created_at.strftime("%Y-%m-%d %H:%M") if c.created_at else "",
|
||||
])
|
||||
|
||||
buffer = io.BytesIO()
|
||||
wb.save(buffer)
|
||||
buffer.seek(0)
|
||||
|
||||
filename = f"customers_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
|
||||
return StreamingResponse(
|
||||
buffer,
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": f"attachment; filename={filename}"},
|
||||
)
|
||||
@@ -0,0 +1,62 @@
|
||||
"""
|
||||
ERP 订单管理路由 —— /api/orders
|
||||
薄路由层:参数解析 + 调用 Service + 包装响应
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import uuid
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.api.deps import get_current_user
|
||||
from app.db.database import get_db
|
||||
from app.schemas.auth import CurrentUserPayload
|
||||
from app.schemas.order import OrderCreate
|
||||
from app.schemas.response import ok
|
||||
from app.services import order_service as svc
|
||||
|
||||
router = APIRouter(prefix="/orders", tags=["订单管理"])
|
||||
|
||||
|
||||
@router.get("/price/calculate", summary="B2B 动态定价:历史成交价追溯 → 标准价兜底")
|
||||
async def calculate_price(
|
||||
customer_id: uuid.UUID = Query(..., description="客户 ID"),
|
||||
sku_id: uuid.UUID = Query(..., description="产品 SKU ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: CurrentUserPayload = Depends(get_current_user),
|
||||
) -> dict:
|
||||
result = await svc.calculate_price(db, customer_id, sku_id)
|
||||
return ok(data=result.model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.post("", summary="创建订单(主子表事务)")
|
||||
async def create_order(
|
||||
body: OrderCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
) -> dict:
|
||||
result = await svc.create_order(db, current_user, body)
|
||||
return ok(data=result.model_dump(mode="json"), message=f"订单 {result.order_no} 创建成功")
|
||||
|
||||
|
||||
@router.get("", summary="订单大盘列表(含数据权限隔离)")
|
||||
async def list_orders(
|
||||
page: int = Query(1, ge=1),
|
||||
size: int = Query(20, ge=1, le=100),
|
||||
customer_id: uuid.UUID | None = Query(None),
|
||||
shipping_state: str | None = Query(None, pattern=r"^(pending|partial|shipped)$"),
|
||||
payment_state: str | None = Query(None, pattern=r"^(unpaid|partial|cleared)$"),
|
||||
keyword: str | None = Query(None, description="模糊搜索订单号"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
) -> dict:
|
||||
result = await svc.list_orders(db, current_user, page, size, customer_id, shipping_state, payment_state, keyword)
|
||||
return ok(data=result.model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.get("/{order_id}", summary="订单全景详情(关系预加载 items + customer)")
|
||||
async def get_order(
|
||||
order_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
) -> dict:
|
||||
result = await svc.get_order(db, current_user, order_id)
|
||||
return ok(data=result.model_dump(mode="json"))
|
||||
@@ -0,0 +1,112 @@
|
||||
"""
|
||||
ERP 产品 & 库存模块路由 —— /api/products
|
||||
薄路由层:参数解析 + 调用 Service + 包装响应
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import uuid
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.api.deps import get_current_user
|
||||
from app.db.database import get_db
|
||||
from app.schemas.auth import CurrentUserPayload
|
||||
from app.schemas.erp import CategoryCreate, CategoryUpdate, InventoryFlowCreate, SkuCreate, SkuUpdate
|
||||
from app.schemas.response import ok
|
||||
from app.services import product_service as svc
|
||||
|
||||
router = APIRouter(prefix="/products", tags=["产品与库存"])
|
||||
|
||||
|
||||
@router.get("/categories/tree", summary="获取产品分类树(嵌套结构)")
|
||||
async def get_category_tree(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: CurrentUserPayload = Depends(get_current_user),
|
||||
) -> dict:
|
||||
tree = await svc.get_category_tree(db)
|
||||
return ok(data=tree)
|
||||
|
||||
|
||||
@router.post("/categories", summary="新增产品分类")
|
||||
async def create_category(
|
||||
body: CategoryCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: CurrentUserPayload = Depends(get_current_user),
|
||||
) -> dict:
|
||||
result = await svc.create_category(db, body)
|
||||
return ok(data=result, message="分类创建成功")
|
||||
|
||||
|
||||
@router.put("/categories/{cat_id}", summary="修改产品分类")
|
||||
async def update_category(
|
||||
cat_id: uuid.UUID,
|
||||
body: CategoryUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: CurrentUserPayload = Depends(get_current_user),
|
||||
) -> dict:
|
||||
await svc.update_category(db, cat_id, body)
|
||||
return ok(message="分类信息已更新")
|
||||
|
||||
|
||||
@router.delete("/categories/{cat_id}", summary="软删除产品分类")
|
||||
async def delete_category(
|
||||
cat_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: CurrentUserPayload = Depends(get_current_user),
|
||||
) -> dict:
|
||||
await svc.delete_category(db, cat_id)
|
||||
return ok(message="分类已删除")
|
||||
|
||||
|
||||
@router.get("/skus", summary="分页获取产品 SKU 列表")
|
||||
async def list_skus(
|
||||
page: int = Query(1, ge=1),
|
||||
size: int = Query(20, ge=1, le=100),
|
||||
category_id: uuid.UUID | None = Query(None, description="按分类过滤"),
|
||||
keyword: str | None = Query(None, description="模糊搜索 SKU 编码或名称"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: CurrentUserPayload = Depends(get_current_user),
|
||||
) -> dict:
|
||||
result = await svc.list_skus(db, page, size, category_id, keyword)
|
||||
return ok(data=result.model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.post("/skus", summary="新增产品 SKU")
|
||||
async def create_sku(
|
||||
body: SkuCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: CurrentUserPayload = Depends(get_current_user),
|
||||
) -> dict:
|
||||
result = await svc.create_sku(db, body)
|
||||
return ok(data=result.model_dump(mode="json"), message="产品创建成功")
|
||||
|
||||
|
||||
@router.put("/skus/{sku_id}", summary="修改产品基础信息(不含库存)")
|
||||
async def update_sku(
|
||||
sku_id: uuid.UUID,
|
||||
body: SkuUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: CurrentUserPayload = Depends(get_current_user),
|
||||
) -> dict:
|
||||
result = await svc.update_sku(db, sku_id, body)
|
||||
return ok(data=result.model_dump(mode="json"), message="产品信息已更新")
|
||||
|
||||
|
||||
@router.post("/inventory/flow", summary="库存变更(事务级原子操作)")
|
||||
async def create_inventory_flow(
|
||||
body: InventoryFlowCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
) -> dict:
|
||||
result = await svc.create_inventory_flow(db, current_user, body)
|
||||
return ok(data=result.model_dump(mode="json"), message="库存变更成功")
|
||||
|
||||
|
||||
@router.get("/inventory/flows/{sku_id}", summary="获取单个 SKU 的库存流水(倒序)")
|
||||
async def get_inventory_flows(
|
||||
sku_id: uuid.UUID,
|
||||
page: int = Query(1, ge=1),
|
||||
size: int = Query(50, ge=1, le=200),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: CurrentUserPayload = Depends(get_current_user),
|
||||
) -> dict:
|
||||
result = await svc.get_inventory_flows(db, sku_id, page, size)
|
||||
return ok(data=result)
|
||||
@@ -0,0 +1,354 @@
|
||||
"""
|
||||
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
|
||||
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),
|
||||
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 ""),
|
||||
media_type="text/event-stream",
|
||||
)
|
||||
|
||||
|
||||
async def _report_sse_generator(
|
||||
db: AsyncSession,
|
||||
user: CurrentUserPayload,
|
||||
start_date: date,
|
||||
end_date: date,
|
||||
authorization: str = "",
|
||||
):
|
||||
import httpx
|
||||
from app.core.config import settings
|
||||
from app.models.ai import SalesLog
|
||||
|
||||
# 1. 聚合日志
|
||||
stmt = (
|
||||
select(SalesLog)
|
||||
.where(
|
||||
SalesLog.salesperson_id == user.user_id,
|
||||
SalesLog.log_date >= start_date,
|
||||
SalesLog.log_date <= end_date,
|
||||
SalesLog.is_deleted.is_(False),
|
||||
)
|
||||
.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 存档",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"""
|
||||
销项发票路由 —— /api/finance/sales-invoices
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.db.database import get_db
|
||||
from app.schemas.auth import CurrentUserPayload
|
||||
from app.schemas.sales_invoice import SalesInvoiceCreate, SalesInvoiceUpdate
|
||||
from app.schemas.response import ok
|
||||
from app.services import sales_invoice_service as svc
|
||||
from app.core.exceptions import ForbiddenException
|
||||
|
||||
router = APIRouter(prefix="/finance/sales-invoices", tags=["销项发票(AR)"])
|
||||
|
||||
|
||||
@router.post("", summary="创建销项发票")
|
||||
async def create_invoice(
|
||||
body: SalesInvoiceCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
) -> dict:
|
||||
result = await svc.create_invoice(db, current_user, body)
|
||||
return ok(data=result.model_dump(mode="json"), message="销项发票创建成功")
|
||||
|
||||
|
||||
@router.get("", summary="多条件查询销项发票列表")
|
||||
async def list_invoices(
|
||||
page: int = Query(1, ge=1),
|
||||
size: int = Query(20, ge=1, le=100),
|
||||
customer_name: str | None = Query(None, description="客户名称模糊搜索"),
|
||||
invoice_number: str | None = Query(None, description="发票号搜索"),
|
||||
payment_status: str | None = Query(None, description="回款状态"),
|
||||
start_date: date | None = Query(None, description="开票开始日期"),
|
||||
end_date: date | None = Query(None, description="开票结束日期"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
) -> dict:
|
||||
result = await svc.list_invoices(
|
||||
db, page, size, customer_name, invoice_number,
|
||||
payment_status, start_date, end_date,
|
||||
)
|
||||
return ok(data=result.model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.get("/export", summary="导出发票汇总及回款追踪表 (仅管理员)")
|
||||
async def export_invoices(
|
||||
start_date: date | None = Query(None),
|
||||
end_date: date | None = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
):
|
||||
if current_user.data_scope != "all" and (current_user.role_name or "").lower() != "admin":
|
||||
raise ForbiddenException("仅管理员可导出发票数据")
|
||||
|
||||
buffer = await svc.export_invoices(db, start_date, end_date)
|
||||
filename = f"sales_invoices_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
|
||||
return StreamingResponse(
|
||||
buffer,
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": f"attachment; filename={filename}"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{invoice_id}", summary="获取销项发票详情")
|
||||
async def get_invoice(
|
||||
invoice_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
) -> dict:
|
||||
result = await svc.get_invoice(db, invoice_id)
|
||||
return ok(data=result.model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.put("/{invoice_id}", summary="更新回款状态")
|
||||
async def update_invoice(
|
||||
invoice_id: uuid.UUID,
|
||||
body: SalesInvoiceUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
) -> dict:
|
||||
result = await svc.update_invoice(db, invoice_id, body)
|
||||
return ok(data=result.model_dump(mode="json"), message="回款状态更新成功")
|
||||
@@ -0,0 +1,77 @@
|
||||
"""
|
||||
销售日志 API 路由 — /api/sales-logs
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from fastapi import APIRouter, Depends, Body
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.db.database import get_db
|
||||
from app.schemas.auth import CurrentUserPayload
|
||||
from app.schemas.response import ok
|
||||
from app.services import sales_log_service
|
||||
|
||||
router = APIRouter(prefix="/sales-logs", tags=["销售日志"])
|
||||
|
||||
|
||||
@router.get("", summary="查询销售日志列表")
|
||||
async def list_logs(
|
||||
page: int = 1,
|
||||
size: int = 20,
|
||||
customer_id: str | None = None,
|
||||
user_id: str | None = None,
|
||||
start_date: str | None = None,
|
||||
end_date: str | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
):
|
||||
result = await sales_log_service.list_logs(
|
||||
db, current_user,
|
||||
page=page, size=size,
|
||||
customer_id=customer_id,
|
||||
user_id=user_id,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
)
|
||||
return ok(data=result)
|
||||
|
||||
|
||||
@router.post("", summary="创建销售日志")
|
||||
async def create_log(
|
||||
content: str = Body(..., embed=True),
|
||||
customer_id: str | None = Body(None, embed=True),
|
||||
contact_ids: list[str] | None = Body(None, embed=True),
|
||||
log_date: str | None = Body(None, embed=True),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
):
|
||||
from datetime import date as date_type
|
||||
|
||||
parsed_date = None
|
||||
if log_date:
|
||||
parsed_date = date_type.fromisoformat(log_date)
|
||||
|
||||
result = await sales_log_service.create_log(
|
||||
db, current_user,
|
||||
content=content,
|
||||
customer_id=customer_id,
|
||||
contact_ids=contact_ids,
|
||||
log_date=parsed_date,
|
||||
)
|
||||
|
||||
# 异步触发 Dify 画像提取工作流(仅当关联了客户时)
|
||||
if customer_id:
|
||||
import uuid
|
||||
asyncio.create_task(
|
||||
sales_log_service.trigger_persona_workflow(
|
||||
log_id=uuid.UUID(result["id"]),
|
||||
customer_id=uuid.UUID(customer_id),
|
||||
content=content,
|
||||
salesperson_name=getattr(current_user, "real_name", ""),
|
||||
contact_ids=contact_ids,
|
||||
)
|
||||
)
|
||||
|
||||
return ok(data=result, message="日志创建成功")
|
||||
@@ -0,0 +1,49 @@
|
||||
"""
|
||||
ERP 物流发货路由 —— /api/shipping
|
||||
薄路由层:参数解析 + 调用 Service + 包装响应
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import uuid
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.api.deps import get_current_user
|
||||
from app.db.database import get_db
|
||||
from app.schemas.auth import CurrentUserPayload
|
||||
from app.schemas.shipping import ShippingCreate
|
||||
from app.schemas.response import ok
|
||||
from app.services import shipping_service as svc
|
||||
|
||||
router = APIRouter(prefix="/shipping", tags=["物流发货"])
|
||||
|
||||
|
||||
@router.post("", summary="执行分批发货(五步原子事务)")
|
||||
async def create_shipping(
|
||||
body: ShippingCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
) -> dict:
|
||||
resp, new_state = await svc.create_shipping(db, current_user, body)
|
||||
return ok(data=resp.model_dump(mode="json"), message=f"发货单 {resp.shipping_no} 创建成功,订单状态已更新为 {new_state}")
|
||||
|
||||
|
||||
@router.get("", summary="发货单大盘列表(数据权限与订单一致)")
|
||||
async def list_shipping(
|
||||
page: int = Query(1, ge=1),
|
||||
size: int = Query(20, ge=1, le=100),
|
||||
order_no: str | None = Query(None, description="按订单号模糊搜索"),
|
||||
tracking_no: str | None = Query(None, description="按物流单号搜索"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
) -> dict:
|
||||
result = await svc.list_shipping(db, current_user, page, size, order_no, tracking_no)
|
||||
return ok(data=result.model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.get("/order/{order_id}", summary="查询特定订单的全部发货轨迹")
|
||||
async def get_shipping_by_order(
|
||||
order_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
) -> dict:
|
||||
result = await svc.get_shipping_by_order(db, current_user, order_id)
|
||||
return ok(data=result)
|
||||
@@ -0,0 +1,431 @@
|
||||
"""
|
||||
系统设置与权限域路由 —— /api/settings
|
||||
核心亮点:
|
||||
1. 部门树递归组装
|
||||
2. 角色 CRUD + JSONB menu_keys
|
||||
3. 员工管理 + bcrypt 密码哈希
|
||||
4. 全接口强制管理员权限校验
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.core.exceptions import BizException, ForbiddenException, NotFoundException
|
||||
from app.core.security import hash_password
|
||||
from app.db.database import get_db
|
||||
from app.models.sys import SysDepartment, SysRole, SysUser
|
||||
from app.schemas.auth import CurrentUserPayload
|
||||
from app.schemas.sys import (
|
||||
DeptNode,
|
||||
RoleCreate,
|
||||
RoleResponse,
|
||||
RoleUpdate,
|
||||
UserCreate,
|
||||
UserListResponse,
|
||||
UserResetPassword,
|
||||
UserResponse,
|
||||
UserUpdate,
|
||||
)
|
||||
from app.schemas.response import ok
|
||||
|
||||
router = APIRouter(prefix="/settings", tags=["系统设置"])
|
||||
|
||||
|
||||
# ── 管理员权限守卫 ────────────────────────────────────────
|
||||
def _require_admin(user: CurrentUserPayload) -> None:
|
||||
"""全接口强制校验:必须 data_scope == 'all'"""
|
||||
if user.data_scope != "all":
|
||||
raise ForbiddenException("系统设置仅限管理员操作")
|
||||
|
||||
|
||||
# ── 工具:部门树递归组装 ─────────────────────────────────
|
||||
def _build_dept_tree(
|
||||
items: list[SysDepartment], parent_id: uuid.UUID | None = None
|
||||
) -> list[DeptNode]:
|
||||
nodes: list[DeptNode] = []
|
||||
for item in items:
|
||||
if item.parent_id == parent_id:
|
||||
children = _build_dept_tree(items, item.id)
|
||||
nodes.append(
|
||||
DeptNode(
|
||||
id=item.id,
|
||||
parent_id=item.parent_id,
|
||||
name=item.name,
|
||||
sort_order=item.sort_order,
|
||||
status=item.status,
|
||||
children=children,
|
||||
)
|
||||
)
|
||||
nodes.sort(key=lambda n: n.sort_order)
|
||||
return nodes
|
||||
|
||||
|
||||
# ── 工具:收集部门及所有子部门 ID(递归) ────────────────
|
||||
def _collect_dept_ids(
|
||||
items: list[SysDepartment], root_id: uuid.UUID
|
||||
) -> list[uuid.UUID]:
|
||||
"""从扁平列表中递归收集某部门及其所有后代 ID"""
|
||||
result = [root_id]
|
||||
for item in items:
|
||||
if item.parent_id == root_id:
|
||||
result.extend(_collect_dept_ids(items, item.id))
|
||||
return result
|
||||
|
||||
|
||||
# ── 工具:User ORM → Response ────────────────────────────
|
||||
def _user_to_resp(u: SysUser) -> UserResponse:
|
||||
return UserResponse(
|
||||
id=u.id,
|
||||
username=u.username,
|
||||
real_name=u.real_name,
|
||||
phone=u.phone,
|
||||
email=u.email,
|
||||
dept_id=u.dept_id,
|
||||
dept_name=u.department.name if u.department else None,
|
||||
role_id=u.role_id,
|
||||
role_name=u.role.role_name if u.role else None,
|
||||
data_scope=u.role.data_scope if u.role else None,
|
||||
status=u.status,
|
||||
last_login_at=u.last_login_at,
|
||||
created_at=u.created_at,
|
||||
)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 1. GET /api/settings/departments/tree —— 部门树
|
||||
# ================================================================
|
||||
@router.get("/departments/tree", summary="获取组织架构树")
|
||||
async def get_dept_tree(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
) -> dict:
|
||||
_require_admin(current_user)
|
||||
|
||||
stmt = (
|
||||
select(SysDepartment)
|
||||
.where(SysDepartment.is_deleted.is_(False))
|
||||
.order_by(SysDepartment.sort_order)
|
||||
)
|
||||
depts = list((await db.execute(stmt)).scalars().all())
|
||||
tree = _build_dept_tree(depts, parent_id=None)
|
||||
return ok(data=[n.model_dump(mode="json") for n in tree])
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 2. GET /api/settings/roles —— 角色列表
|
||||
# ================================================================
|
||||
@router.get("/roles", summary="角色列表")
|
||||
async def list_roles(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
) -> dict:
|
||||
_require_admin(current_user)
|
||||
|
||||
stmt = (
|
||||
select(SysRole)
|
||||
.where(SysRole.is_deleted.is_(False))
|
||||
.order_by(SysRole.created_at)
|
||||
)
|
||||
roles = (await db.execute(stmt)).scalars().all()
|
||||
return ok(
|
||||
data=[
|
||||
RoleResponse(
|
||||
id=r.id,
|
||||
role_name=r.role_name,
|
||||
data_scope=r.data_scope,
|
||||
menu_keys=r.menu_keys or [],
|
||||
description=r.description,
|
||||
status=r.status,
|
||||
created_at=r.created_at,
|
||||
).model_dump(mode="json")
|
||||
for r in roles
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 3. POST /api/settings/roles —— 新增角色
|
||||
# ================================================================
|
||||
@router.post("/roles", summary="新增角色")
|
||||
async def create_role(
|
||||
body: RoleCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
) -> dict:
|
||||
_require_admin(current_user)
|
||||
|
||||
# 校验名称唯一
|
||||
exists = (
|
||||
await db.execute(
|
||||
select(SysRole.id).where(
|
||||
SysRole.role_name == body.role_name,
|
||||
SysRole.is_deleted.is_(False),
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if exists:
|
||||
raise BizException(message=f"角色名称 '{body.role_name}' 已存在")
|
||||
|
||||
role = SysRole(
|
||||
role_name=body.role_name,
|
||||
data_scope=body.data_scope,
|
||||
menu_keys=body.menu_keys,
|
||||
description=body.description,
|
||||
status=body.status,
|
||||
)
|
||||
db.add(role)
|
||||
await db.commit()
|
||||
await db.refresh(role)
|
||||
return ok(
|
||||
data=RoleResponse(
|
||||
id=role.id,
|
||||
role_name=role.role_name,
|
||||
data_scope=role.data_scope,
|
||||
menu_keys=role.menu_keys or [],
|
||||
description=role.description,
|
||||
status=role.status,
|
||||
created_at=role.created_at,
|
||||
).model_dump(mode="json"),
|
||||
message="角色创建成功",
|
||||
)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 4. PUT /api/settings/roles/{id} —— 修改角色
|
||||
# ================================================================
|
||||
@router.put("/roles/{role_id}", summary="修改角色(含 JSONB menu_keys)")
|
||||
async def update_role(
|
||||
role_id: uuid.UUID,
|
||||
body: RoleUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
) -> dict:
|
||||
_require_admin(current_user)
|
||||
|
||||
role = (
|
||||
await db.execute(
|
||||
select(SysRole).where(
|
||||
SysRole.id == role_id, SysRole.is_deleted.is_(False)
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if role is None:
|
||||
raise NotFoundException("角色不存在")
|
||||
|
||||
update_data = body.model_dump(exclude_unset=True)
|
||||
if not update_data:
|
||||
raise BizException(message="未提供任何需要更新的字段")
|
||||
|
||||
# 名称唯一性校验(如果改了名)
|
||||
if "role_name" in update_data and update_data["role_name"] != role.role_name:
|
||||
dup = (
|
||||
await db.execute(
|
||||
select(SysRole.id).where(
|
||||
SysRole.role_name == update_data["role_name"],
|
||||
SysRole.id != role_id,
|
||||
SysRole.is_deleted.is_(False),
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if dup:
|
||||
raise BizException(message=f"角色名称 '{update_data['role_name']}' 已存在")
|
||||
|
||||
update_data["updated_at"] = datetime.utcnow()
|
||||
await db.execute(
|
||||
update(SysRole).where(SysRole.id == role_id).values(**update_data)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
refreshed = (
|
||||
await db.execute(select(SysRole).where(SysRole.id == role_id))
|
||||
).scalar_one()
|
||||
return ok(
|
||||
data=RoleResponse(
|
||||
id=refreshed.id,
|
||||
role_name=refreshed.role_name,
|
||||
data_scope=refreshed.data_scope,
|
||||
menu_keys=refreshed.menu_keys or [],
|
||||
description=refreshed.description,
|
||||
status=refreshed.status,
|
||||
created_at=refreshed.created_at,
|
||||
).model_dump(mode="json"),
|
||||
message="角色信息已更新",
|
||||
)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 5. GET /api/settings/users —— 员工分页列表
|
||||
# ================================================================
|
||||
@router.get("/users", summary="员工分页列表(支持部门树递归过滤)")
|
||||
async def list_users(
|
||||
page: int = Query(1, ge=1),
|
||||
size: int = Query(20, ge=1, le=100),
|
||||
dept_id: uuid.UUID | None = Query(None, description="部门 ID(含所有子部门)"),
|
||||
keyword: str | None = Query(None, description="姓名/手机号模糊搜索"),
|
||||
status: int | None = Query(None, ge=0, le=1),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
) -> dict:
|
||||
_require_admin(current_user)
|
||||
|
||||
where = [SysUser.is_deleted.is_(False)]
|
||||
|
||||
# 按部门过滤(递归收集子部门 ID)
|
||||
if dept_id:
|
||||
all_depts = list(
|
||||
(
|
||||
await db.execute(
|
||||
select(SysDepartment).where(SysDepartment.is_deleted.is_(False))
|
||||
)
|
||||
).scalars().all()
|
||||
)
|
||||
target_ids = _collect_dept_ids(all_depts, dept_id)
|
||||
where.append(SysUser.dept_id.in_(target_ids))
|
||||
|
||||
if keyword:
|
||||
where.append(
|
||||
SysUser.real_name.ilike(f"%{keyword}%")
|
||||
| SysUser.phone.ilike(f"%{keyword}%")
|
||||
)
|
||||
if status is not None:
|
||||
where.append(SysUser.status == status)
|
||||
|
||||
total = (
|
||||
await db.execute(select(func.count()).select_from(SysUser).where(*where))
|
||||
).scalar() or 0
|
||||
|
||||
stmt = (
|
||||
select(SysUser)
|
||||
.where(*where)
|
||||
.order_by(SysUser.created_at.desc())
|
||||
.offset((page - 1) * size)
|
||||
.limit(size)
|
||||
)
|
||||
users = (await db.execute(stmt)).scalars().all()
|
||||
|
||||
return ok(
|
||||
data=UserListResponse(
|
||||
total=total,
|
||||
items=[_user_to_resp(u) for u in users],
|
||||
page=page,
|
||||
size=size,
|
||||
).model_dump(mode="json")
|
||||
)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 6. POST /api/settings/users —— 开通账号
|
||||
# ================================================================
|
||||
@router.post("/users", summary="开通员工账号(bcrypt 密码哈希)")
|
||||
async def create_user(
|
||||
body: UserCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
) -> dict:
|
||||
_require_admin(current_user)
|
||||
|
||||
# 用户名唯一
|
||||
exists = (
|
||||
await db.execute(
|
||||
select(SysUser.id).where(
|
||||
SysUser.username == body.username,
|
||||
SysUser.is_deleted.is_(False),
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if exists:
|
||||
raise BizException(message=f"用户名 '{body.username}' 已被占用")
|
||||
|
||||
user = SysUser(
|
||||
username=body.username,
|
||||
password_hash=hash_password(body.password),
|
||||
real_name=body.real_name,
|
||||
phone=body.phone,
|
||||
email=body.email,
|
||||
dept_id=body.dept_id,
|
||||
role_id=body.role_id,
|
||||
status=body.status,
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return ok(data=_user_to_resp(user).model_dump(mode="json"), message="账号创建成功")
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 7. PUT /api/settings/users/{id} —— 编辑员工信息
|
||||
# ================================================================
|
||||
@router.put("/users/{user_id}", summary="编辑员工信息(不含密码)")
|
||||
async def update_user(
|
||||
user_id: uuid.UUID,
|
||||
body: UserUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
) -> dict:
|
||||
_require_admin(current_user)
|
||||
|
||||
user = (
|
||||
await db.execute(
|
||||
select(SysUser).where(
|
||||
SysUser.id == user_id, SysUser.is_deleted.is_(False)
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if user is None:
|
||||
raise NotFoundException("用户不存在")
|
||||
|
||||
update_data = body.model_dump(exclude_unset=True)
|
||||
if not update_data:
|
||||
raise BizException(message="未提供任何需要更新的字段")
|
||||
|
||||
update_data["updated_at"] = datetime.utcnow()
|
||||
await db.execute(
|
||||
update(SysUser).where(SysUser.id == user_id).values(**update_data)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
refreshed = (
|
||||
await db.execute(select(SysUser).where(SysUser.id == user_id))
|
||||
).scalar_one()
|
||||
return ok(data=_user_to_resp(refreshed).model_dump(mode="json"), message="员工信息已更新")
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 8. PUT /api/settings/users/{id}/reset-password —— 强制重置密码
|
||||
# ================================================================
|
||||
@router.put("/users/{user_id}/reset-password", summary="强制重置密码(bcrypt)")
|
||||
async def reset_password(
|
||||
user_id: uuid.UUID,
|
||||
body: UserResetPassword,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||||
) -> dict:
|
||||
_require_admin(current_user)
|
||||
|
||||
user = (
|
||||
await db.execute(
|
||||
select(SysUser).where(
|
||||
SysUser.id == user_id, SysUser.is_deleted.is_(False)
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if user is None:
|
||||
raise NotFoundException("用户不存在")
|
||||
|
||||
await db.execute(
|
||||
update(SysUser)
|
||||
.where(SysUser.id == user_id)
|
||||
.values(
|
||||
password_hash=hash_password(body.new_password),
|
||||
updated_at=datetime.utcnow(),
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
return ok(message="密码已重置")
|
||||
Reference in New Issue
Block a user