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,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
|
||||
|
||||
Reference in New Issue
Block a user