""" CRM 客户模块路由 —— /api/customers 薄路由层:参数解析 + 调用 Service + 包装响应 """ from __future__ import annotations import uuid from fastapi import APIRouter, Body, 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.put("/{customer_id}/transfer", summary="转移客户负责人(仅管理员)") async def transfer_customer( customer_id: uuid.UUID, body: dict = Body(..., examples=[{"new_owner_id": "uuid-here"}]), db: AsyncSession = Depends(get_db), current_user: CurrentUserPayload = Depends(get_current_user), ) -> dict: new_owner_id = body.get("new_owner_id") if not new_owner_id: raise Exception("缺少 new_owner_id 参数") result = await svc.transfer_customer(db, current_user, customer_id, uuid.UUID(str(new_owner_id))) return ok(data=result.model_dump(mode="json"), 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 包裹: ... + JSON """ import json as _json import re raw = await request.body() raw_str = raw.decode("utf-8", errors="replace").strip() # ── 统一预处理:去除 ... 标签 ── cleaned = re.sub(r'[\s\S]*?', '', raw_str, flags=re.DOTALL).strip() # 也处理可能缺少闭合的 cleaned = re.sub(r'[\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'[\s\S]*?', '', 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