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:
hankin
2026-03-16 07:31:37 +00:00
commit 423baff73b
2578 changed files with 824643 additions and 0 deletions
+286
View File
@@ -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
# 策略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