Files
crm_project/server/app/api/customers.py
T
hankin 423baff73b 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
2026-03-16 07:31:37 +00:00

287 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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
# 策略2Dify 空 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