v3.1 + 批次0: 智能回复重构基线 - ApprovalMatcher + 关键词降级 + 文档速修 + v4.0任务书面化
This commit is contained in:
@@ -0,0 +1,387 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 欢迎与引导配置 API
|
||||
# =============================================================================
|
||||
# 说明:管理员工端H5的欢迎页、引导视频、互动教学、主题模板配置
|
||||
# GET /api/admin/welcome-config - 获取全部配置
|
||||
# PUT /api/admin/welcome-config - 更新配置
|
||||
# POST /api/admin/welcome-video - 上传视频
|
||||
# DELETE /api/admin/welcome-video - 删除视频
|
||||
# =============================================================================
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.agents import get_current_agent
|
||||
from app.database import get_db
|
||||
from app.models.agent import Agent
|
||||
from app.models.system_config import SystemConfig
|
||||
from app.utils.response import AppException, success_response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 创建路由器
|
||||
router = APIRouter(prefix="/admin/welcome-config", tags=["欢迎与引导配置"])
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 请求/响应模型
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
# 欢迎页配置
|
||||
class WelcomePageConfig(BaseModel):
|
||||
enabled: bool = False
|
||||
title: str = "欢迎使用IT服务台"
|
||||
content: str = "在这里您可以提交IT问题申请,获取快速支持"
|
||||
background_color: str = "#07C160"
|
||||
|
||||
|
||||
# 引导视频配置
|
||||
class WelcomeVideoConfig(BaseModel):
|
||||
url: str
|
||||
filename: str
|
||||
uploaded_at: str
|
||||
|
||||
|
||||
# 教程步骤
|
||||
class TutorialStep(BaseModel):
|
||||
id: str
|
||||
title: str
|
||||
description: str
|
||||
|
||||
|
||||
# 互动教学配置
|
||||
class TutorialConfig(BaseModel):
|
||||
enabled: bool = False
|
||||
steps: list[TutorialStep] = []
|
||||
|
||||
|
||||
# 主题配置
|
||||
class ThemeConfig(BaseModel):
|
||||
theme: str = "default" # default/blue/orange/red
|
||||
|
||||
|
||||
# 完整配置
|
||||
class WelcomeConfigData(BaseModel):
|
||||
welcome_page: WelcomePageConfig = WelcomePageConfig()
|
||||
welcome_video: Optional[WelcomeVideoConfig] = None
|
||||
tutorial: TutorialConfig = TutorialConfig()
|
||||
theme: ThemeConfig = ThemeConfig()
|
||||
|
||||
|
||||
# 更新配置请求
|
||||
class UpdateWelcomeConfigRequest(BaseModel):
|
||||
welcome_page: Optional[WelcomePageConfig] = None
|
||||
welcome_video: Optional[WelcomeVideoConfig] = None
|
||||
tutorial: Optional[TutorialConfig] = None
|
||||
theme: Optional[ThemeConfig] = None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 视频存储配置
|
||||
# --------------------------------------------------------------------------
|
||||
VIDEO_UPLOAD_DIR = Path(os.getenv("VIDEO_UPLOAD_DIR", "./uploads/videos"))
|
||||
VIDEO_UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
MAX_VIDEO_SIZE = int(os.getenv("MAX_VIDEO_SIZE", str(50 * 1024 * 1024))) # 50MB
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 权限校验依赖
|
||||
# --------------------------------------------------------------------------
|
||||
async def require_admin(
|
||||
agent: Agent = Depends(get_current_agent),
|
||||
) -> Agent:
|
||||
"""管理后台权限校验:仅 role='admin' 可访问。"""
|
||||
if agent.role != "admin":
|
||||
raise AppException(1004, "无管理权限")
|
||||
return agent
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 辅助函数
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
async def _get_welcome_config(db: AsyncSession) -> WelcomeConfigData:
|
||||
"""从数据库获取欢迎配置。"""
|
||||
stmt = select(SystemConfig).where(
|
||||
SystemConfig.config_key == "welcome_config"
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
config_row = result.scalars().first()
|
||||
|
||||
if config_row:
|
||||
try:
|
||||
data = json.loads(config_row.config_value)
|
||||
return WelcomeConfigData(**data)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
# 返回默认配置
|
||||
return WelcomeConfigData()
|
||||
|
||||
|
||||
async def _save_welcome_config(
|
||||
db: AsyncSession,
|
||||
config_data: WelcomeConfigData,
|
||||
) -> None:
|
||||
"""保存欢迎配置到数据库。"""
|
||||
stmt = select(SystemConfig).where(
|
||||
SystemConfig.config_key == "welcome_config"
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
config_row = result.scalars().first()
|
||||
|
||||
config_value = config_data.model_dump_json()
|
||||
|
||||
if config_row:
|
||||
config_row.config_value = config_value
|
||||
config_row.updated_at = datetime.now()
|
||||
else:
|
||||
new_config = SystemConfig(
|
||||
id=str(uuid.uuid4()),
|
||||
config_key="welcome_config",
|
||||
config_value=config_value,
|
||||
description="员工端H5欢迎与引导配置",
|
||||
)
|
||||
db.add(new_config)
|
||||
|
||||
await db.flush()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# API 端点
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
# ---------- GET /api/v1/admin/welcome-config ----------
|
||||
@router.get("")
|
||||
async def get_welcome_config(
|
||||
admin: Agent = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
"""获取欢迎与引导配置。
|
||||
|
||||
Args:
|
||||
admin: 管理员(权限校验)
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含全部配置
|
||||
"""
|
||||
config = await _get_welcome_config(db)
|
||||
return success_response(data=config.model_dump())
|
||||
|
||||
|
||||
# ---------- PUT /api/v1/admin/welcome-config ----------
|
||||
@router.put("")
|
||||
async def update_welcome_config(
|
||||
body: UpdateWelcomeConfigRequest,
|
||||
admin: Agent = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
"""更新欢迎与引导配置。
|
||||
|
||||
Args:
|
||||
body: 更新请求体
|
||||
admin: 管理员(权限校验)
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含更新后的配置
|
||||
"""
|
||||
# 获取当前配置
|
||||
current_config = await _get_welcome_config(db)
|
||||
|
||||
# 合并更新
|
||||
if body.welcome_page is not None:
|
||||
current_config.welcome_page = body.welcome_page
|
||||
if body.welcome_video is not None:
|
||||
current_config.welcome_video = body.welcome_video
|
||||
if body.tutorial is not None:
|
||||
current_config.tutorial = body.tutorial
|
||||
if body.theme is not None:
|
||||
current_config.theme = body.theme
|
||||
|
||||
# 保存配置
|
||||
await _save_welcome_config(db, current_config)
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"管理员更新欢迎配置: admin={admin.user_id}")
|
||||
return success_response(data=current_config.model_dump())
|
||||
|
||||
|
||||
# ---------- POST /api/v1/admin/welcome-video ----------
|
||||
@router.post("/video")
|
||||
async def upload_welcome_video(
|
||||
file: UploadFile = File(..., description="引导视频文件"),
|
||||
admin: Agent = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
"""上传引导视频。
|
||||
|
||||
Args:
|
||||
file: 视频文件
|
||||
admin: 管理员(权限校验)
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含视频信息
|
||||
"""
|
||||
# 校验文件类型
|
||||
allowed_extensions = {"mp4"}
|
||||
ext = file.filename.split(".")[-1].lower() if file.filename else ""
|
||||
|
||||
if ext not in allowed_extensions:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"不支持的视频格式: .{ext},仅支持 .mp4",
|
||||
)
|
||||
|
||||
# 校验文件大小
|
||||
content = await file.read()
|
||||
file_size = len(content)
|
||||
|
||||
if file_size > MAX_VIDEO_SIZE:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"视频大小 {file_size / 1024 / 1024:.1f}MB 超过限制(50MB)",
|
||||
)
|
||||
|
||||
# 生成存储路径(使用与普通文件上传相同的目录结构)
|
||||
now = datetime.now()
|
||||
file_id = uuid.uuid4().hex[:12]
|
||||
filename = f"{file_id}.{ext}"
|
||||
# 按日期分目录:uploads/videos/YYYY/MM/DD/
|
||||
video_dir = VIDEO_UPLOAD_DIR / f"{now.year}" / f"{now.month:02d}" / f"{now.day:02d}"
|
||||
video_dir.mkdir(parents=True, exist_ok=True)
|
||||
storage_path = video_dir / filename
|
||||
|
||||
# 保存文件
|
||||
try:
|
||||
with open(storage_path, "wb") as f:
|
||||
f.write(content)
|
||||
except OSError as e:
|
||||
logger.error(f"视频保存失败: {e}")
|
||||
raise HTTPException(status_code=500, detail="视频保存失败,请重试")
|
||||
|
||||
# 构建访问 URL(与普通文件上传格式一致)
|
||||
video_url = f"/api/media/videos/{now.year}/{now.month:02d}/{now.day:02d}/{filename}"
|
||||
|
||||
# 更新数据库配置
|
||||
config = await _get_welcome_config(db)
|
||||
config.welcome_video = WelcomeVideoConfig(
|
||||
url=video_url,
|
||||
filename=file.filename or filename,
|
||||
uploaded_at=now.isoformat(),
|
||||
)
|
||||
await _save_welcome_config(db, config)
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"管理员上传引导视频: {file.filename}, admin={admin.user_id}")
|
||||
|
||||
return success_response(data={
|
||||
"url": video_url,
|
||||
"filename": file.filename or filename,
|
||||
"uploaded_at": now.isoformat(),
|
||||
})
|
||||
|
||||
|
||||
# ---------- DELETE /api/admin/welcome-video ----------
|
||||
@router.delete("/video")
|
||||
async def delete_welcome_video(
|
||||
admin: Agent = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
"""删除引导视频。
|
||||
|
||||
Args:
|
||||
admin: 管理员(权限校验)
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式
|
||||
"""
|
||||
# 获取当前配置
|
||||
config = await _get_welcome_config(db)
|
||||
|
||||
if not config.welcome_video:
|
||||
raise AppException(4001, "暂无引导视频")
|
||||
|
||||
# 删除文件
|
||||
video_url = config.welcome_video.url
|
||||
if video_url and video_url.startswith("/api/media/videos/"):
|
||||
# 解析URL: /api/media/videos/YYYY/MM/DD/filename
|
||||
parts = video_url.split("/")
|
||||
if len(parts) >= 8:
|
||||
year = parts[-4]
|
||||
month = parts[-3]
|
||||
day = parts[-2]
|
||||
filename = parts[-1]
|
||||
file_path = VIDEO_UPLOAD_DIR / year / month / day / filename
|
||||
try:
|
||||
if file_path.exists():
|
||||
file_path.unlink()
|
||||
except OSError as e:
|
||||
logger.warning(f"删除视频文件失败: {e}")
|
||||
|
||||
# 清除配置
|
||||
config.welcome_video = None
|
||||
await _save_welcome_config(db, config)
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"管理员删除引导视频: admin={admin.user_id}")
|
||||
return success_response(data=None, message="视频已删除")
|
||||
|
||||
|
||||
# ---------- GET /api/media/videos/{year}/{month}/{day}/{filename} ----------
|
||||
@router.get("/media/videos/{year}/{month}/{day}/{filename}")
|
||||
async def serve_welcome_video(
|
||||
year: str,
|
||||
month: str,
|
||||
day: str,
|
||||
filename: str,
|
||||
):
|
||||
"""提供引导视频的静态访问。
|
||||
|
||||
Args:
|
||||
year: 年份
|
||||
month: 月份
|
||||
day: 日期
|
||||
filename: 文件名
|
||||
|
||||
Returns:
|
||||
FileResponse: 视频文件响应
|
||||
"""
|
||||
file_path = VIDEO_UPLOAD_DIR / year / month / day / filename
|
||||
|
||||
# 安全检查:防止路径遍历攻击
|
||||
try:
|
||||
resolved = file_path.resolve()
|
||||
upload_root = VIDEO_UPLOAD_DIR.resolve()
|
||||
if not str(resolved).startswith(str(upload_root)):
|
||||
raise HTTPException(status_code=403, detail="禁止访问")
|
||||
except (ValueError, OSError):
|
||||
raise HTTPException(status_code=403, detail="禁止访问")
|
||||
|
||||
if not file_path.exists():
|
||||
raise HTTPException(status_code=404, detail="视频文件不存在")
|
||||
|
||||
# 根据扩展名设置 Content-Type
|
||||
content_type = "video/mp4"
|
||||
return FileResponse(file_path, media_type=content_type)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 注册路由到主路由
|
||||
# --------------------------------------------------------------------------
|
||||
def register_welcome_routes(main_router: APIRouter) -> None:
|
||||
"""将欢迎配置路由注册到主路由。"""
|
||||
main_router.include_router(router)
|
||||
@@ -19,12 +19,14 @@ from datetime import datetime
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi import APIRouter, Depends, Form, Query, UploadFile
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.agents import get_current_agent
|
||||
from app.database import get_db
|
||||
from app.models.agent import Agent
|
||||
from app.models.device_inventory import DeviceInventory
|
||||
from app.services.device_import_service import DeviceImportService
|
||||
from app.schemas.admin import (
|
||||
AgentCreateRequest,
|
||||
AgentUpdateRequest,
|
||||
@@ -771,7 +773,7 @@ async def test_lianruan_connection(
|
||||
Dict: 包含 success(bool) 和 message(str)
|
||||
"""
|
||||
from app.integrations.lianruan.config import get_lianruan_client
|
||||
from app.integrations.lianruan.exceptions import LianruanConfigError
|
||||
from app.integrations.lianruan.exceptions import LianruanConfigError, LianruanAuthError, LianruanConnectionError
|
||||
|
||||
try:
|
||||
client = await get_lianruan_client(db)
|
||||
@@ -782,6 +784,16 @@ async def test_lianruan_connection(
|
||||
"success": False,
|
||||
"message": e.message,
|
||||
})
|
||||
except LianruanAuthError as e:
|
||||
return success_response(data={
|
||||
"success": False,
|
||||
"message": f"认证失败: {e.message}",
|
||||
})
|
||||
except LianruanConnectionError as e:
|
||||
return success_response(data={
|
||||
"success": False,
|
||||
"message": f"连接失败: {e.message}",
|
||||
})
|
||||
|
||||
|
||||
# ---------- GET /api/admin/integrations/lianruan/terminals ----------
|
||||
@@ -843,6 +855,7 @@ async def list_audit_conversations(
|
||||
keyword: Optional[str] = Query(None, description="按员工姓名/消息摘要搜索"),
|
||||
date_from: Optional[str] = Query(None, description="开始日期 YYYY-MM-DD"),
|
||||
date_to: Optional[str] = Query(None, description="结束日期 YYYY-MM-DD"),
|
||||
is_archived: Optional[bool] = Query(None, description="按是否归档筛选"),
|
||||
page: int = Query(1, ge=1, description="页码"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="每页条数"),
|
||||
admin: Agent = Depends(require_admin),
|
||||
@@ -851,7 +864,8 @@ async def list_audit_conversations(
|
||||
"""获取会话审计列表(支持分页+多条件筛选)。"""
|
||||
result = await admin_service.list_audit_conversations(
|
||||
db, status=status, agent_id=agent_id, keyword=keyword,
|
||||
date_from=date_from, date_to=date_to, page=page, page_size=page_size,
|
||||
date_from=date_from, date_to=date_to, is_archived=is_archived,
|
||||
page=page, page_size=page_size,
|
||||
)
|
||||
return success_response(data=result)
|
||||
|
||||
@@ -1108,3 +1122,83 @@ async def revoke_user_token(
|
||||
"revoked_count": revoked_count,
|
||||
"message": f"已撤销 {revoked_count} 个 Token" if revoked_count > 0 else "未找到该用户的有效 Token",
|
||||
})
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# 15. 设备清单导入(从联软/火绒导出文件)
|
||||
# ==========================================================================
|
||||
|
||||
# ---------- POST /api/admin/device-inventory/import ----------
|
||||
@router.post("/device-inventory/import")
|
||||
async def import_device_inventory(
|
||||
source: str = Form(..., description="数据来源: huorong 或 lianruan"),
|
||||
file: UploadFile = Form(..., description="Excel 文件"),
|
||||
admin: Agent = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""从 Excel 文件导入设备清单。
|
||||
|
||||
支持从联软/火绒导出的 Excel 文件中批量导入设备信息。
|
||||
导入后会根据员工账号自动匹配员工信息。
|
||||
"""
|
||||
import pandas as pd
|
||||
from io import BytesIO
|
||||
|
||||
# 读取文件内容
|
||||
content = await file.read()
|
||||
df = pd.read_excel(BytesIO(content))
|
||||
|
||||
# 转换为字典列表
|
||||
records = df.to_dict(orient="records")
|
||||
|
||||
# 导入
|
||||
import_service = DeviceImportService(db)
|
||||
if source == "huorong":
|
||||
count = await import_service.import_huorong(records)
|
||||
elif source == "lianruan":
|
||||
count = await import_service.import_lianruan(records)
|
||||
else:
|
||||
raise AppException(4001, "无效的数据来源,仅支持 huorong 或 lianruan")
|
||||
|
||||
return success_response(data={"imported": count, "source": source})
|
||||
|
||||
|
||||
# ---------- GET /api/admin/device-inventory ----------
|
||||
@router.get("/device-inventory")
|
||||
async def list_device_inventory(
|
||||
page: int = Query(1, ge=1, description="页码"),
|
||||
page_size: int = Query(50, ge=1, le=200, description="每页条数"),
|
||||
source: Optional[str] = Query(None, description="数据来源筛选"),
|
||||
employee_account: Optional[str] = Query(None, description="员工账号筛选"),
|
||||
admin: Agent = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""查询设备清单列表。"""
|
||||
from sqlalchemy import select, func
|
||||
|
||||
query = select(DeviceInventory)
|
||||
count_query = select(func.count(DeviceInventory.id))
|
||||
|
||||
if source:
|
||||
query = query.where(DeviceInventory.source == source)
|
||||
count_query = count_query.where(DeviceInventory.source == source)
|
||||
|
||||
if employee_account:
|
||||
query = query.where(DeviceInventory.employee_account == employee_account)
|
||||
count_query = count_query.where(DeviceInventory.employee_account == employee_account)
|
||||
|
||||
# 总数
|
||||
total = await db.scalar(count_query)
|
||||
|
||||
# 分页
|
||||
offset = (page - 1) * page_size
|
||||
query = query.offset(offset).limit(page_size).order_by(DeviceInventory.updated_at.desc())
|
||||
result = await db.execute(query)
|
||||
items = result.scalars().all()
|
||||
|
||||
return success_response(data={
|
||||
"items": [d.model_dump() for d in items],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
})
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
from collections import OrderedDict
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
@@ -23,7 +22,11 @@ from app.api.agents import get_current_agent
|
||||
from app.database import get_db
|
||||
from app.dependencies import dep_redis
|
||||
from app.models.agent import Agent
|
||||
from app.services.employee_directory import get_org_directory
|
||||
from app.services.employee_directory import (
|
||||
count_tree_employees,
|
||||
get_org_directory,
|
||||
get_org_tree_cached,
|
||||
)
|
||||
from app.utils.response import AppException, success_response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -63,7 +66,7 @@ async def search_employees(
|
||||
return success_response(data=[])
|
||||
|
||||
try:
|
||||
# 获取组织目录(含 10 分钟 Redis 缓存 + 本地降级,无需修改 employee_directory.py)
|
||||
# 获取组织目录(含 30 分钟 Redis 缓存 + 本地降级)
|
||||
directory, _ = await get_org_directory(db, redis)
|
||||
|
||||
kw_lower = kw.lower()
|
||||
@@ -107,24 +110,34 @@ async def get_org_tree(
|
||||
):
|
||||
"""获取组织架构树(部门层级 + 每个部门下的员工列表)。
|
||||
|
||||
复用 get_org_directory() 获取员工列表(已含 department 字段),
|
||||
在服务端按 department 分组构建树结构。排除当前登录坐席自己。
|
||||
利用企微 department/list 返回的 parentid 字段构建真正的层级树,
|
||||
不再按部门名扁平分组。部门ID加 ``dept_`` 前缀作为唯一 key,
|
||||
避免同名部门合并。员工可以出现在其所属的所有部门下。
|
||||
|
||||
树结构示例:
|
||||
树结构示例(多层级):
|
||||
[
|
||||
{
|
||||
"id": "研发一部",
|
||||
"label": "研发一部",
|
||||
"id": "dept_1",
|
||||
"label": "公司",
|
||||
"dept_id": 1,
|
||||
"parentid": 0,
|
||||
"children": [
|
||||
{"id": "zhangsan", "label": "张三", "isLeaf": true, "department": "研发一部"}
|
||||
{
|
||||
"id": "dept_2",
|
||||
"label": "研发一部",
|
||||
"dept_id": 2,
|
||||
"parentid": 1,
|
||||
"children": [
|
||||
{"id": "zhangsan", "label": "张三", "isLeaf": true, "department": "研发一部"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
规则:
|
||||
- department 为空的员工归到"未分配部门"分组
|
||||
- 企微返回多部门(逗号分隔)时,取第一个作为主部门
|
||||
- 部门按名称排序,部门内员工按姓名排序
|
||||
性能优化:
|
||||
- 树构建结果独立缓存(key: wecom:org_tree:agent,TTL 30 分钟)
|
||||
- 缓存中包含所有员工,读取后过滤掉当前登录坐席自己
|
||||
|
||||
Args:
|
||||
current_agent: 当前坐席
|
||||
@@ -135,58 +148,11 @@ async def get_org_tree(
|
||||
Dict: 统一响应格式,data 为树节点列表
|
||||
"""
|
||||
try:
|
||||
# 获取组织目录(含 Redis 缓存 + 本地降级)
|
||||
directory, _ = await get_org_directory(db, redis)
|
||||
# 获取组织架构树(含独立缓存 + 排除当前坐席)
|
||||
tree = await get_org_tree_cached(db, redis, "agent", current_agent.user_id)
|
||||
|
||||
# 按部门分组(OrderedDict 保持稳定插入顺序,后续再排序)
|
||||
dept_groups: "OrderedDict[str, List[Dict[str, Any]]]" = OrderedDict()
|
||||
|
||||
for emp in directory:
|
||||
# 排除当前坐席自己
|
||||
if emp.get("employee_id") == current_agent.user_id:
|
||||
continue
|
||||
|
||||
# 取部门名:为空则归"未分配部门";多部门(逗号分隔)取第一个
|
||||
dept = (emp.get("department") or "").strip()
|
||||
if not dept:
|
||||
dept = "未分配部门"
|
||||
else:
|
||||
dept = dept.split(",")[0].strip()
|
||||
if not dept:
|
||||
dept = "未分配部门"
|
||||
|
||||
if dept not in dept_groups:
|
||||
dept_groups[dept] = []
|
||||
dept_groups[dept].append(emp)
|
||||
|
||||
# 构建树节点(部门按名称排序,员工按姓名排序)
|
||||
tree: List[Dict[str, Any]] = []
|
||||
for dept_name in sorted(dept_groups.keys()):
|
||||
employees = dept_groups[dept_name]
|
||||
if not employees:
|
||||
# 跳过空部门(理论上不会出现,防御性编程)
|
||||
continue
|
||||
|
||||
# 部门内员工按姓名排序
|
||||
employees.sort(key=lambda e: e.get("name", ""))
|
||||
|
||||
tree.append({
|
||||
"id": dept_name,
|
||||
"label": dept_name,
|
||||
"children": [
|
||||
{
|
||||
"id": emp.get("employee_id", ""),
|
||||
"label": emp.get("name", ""),
|
||||
# isLeaf=true 标记为叶子节点(员工),前端 el-tree 据此区分部门/员工
|
||||
"isLeaf": True,
|
||||
"department": dept_name,
|
||||
}
|
||||
for emp in employees
|
||||
],
|
||||
})
|
||||
|
||||
total_employees = sum(len(node["children"]) for node in tree)
|
||||
logger.info(f"组织架构树: {len(tree)} 个部门, 共 {total_employees} 人")
|
||||
total_employees = count_tree_employees(tree)
|
||||
logger.info(f"组织架构树: {len(tree)} 个顶层节点, 共 {total_employees} 人")
|
||||
return success_response(data=tree)
|
||||
|
||||
except AppException:
|
||||
|
||||
@@ -30,7 +30,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_current_user, require_role, dep_wecom_service
|
||||
from app.dependencies import get_current_user, require_role, dep_wecom_service, UserInfo
|
||||
from app.models.agent import Agent
|
||||
from app.schemas.agent import AgentLogin, AgentResponse, AgentStatusUpdate
|
||||
from app.services.wecom_service import WecomService
|
||||
@@ -425,6 +425,7 @@ async def update_agent_status(
|
||||
async def list_agents(
|
||||
status: Optional[str] = Query(None, description="按状态过滤: online/busy/offline"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
):
|
||||
"""获取坐席列表。
|
||||
|
||||
|
||||
+20
-17
@@ -54,36 +54,39 @@ APPROVAL_TEMPLATES: dict[str, dict] = {
|
||||
"location": "企微审批",
|
||||
},
|
||||
# --- 设备申请 ---
|
||||
# IT资产领用 改用ITSM工单系统(企微审批模板 C4c8qt31... 已失效)
|
||||
"asset_receive": {
|
||||
"id": "asset_receive",
|
||||
"name": "资产领用登记",
|
||||
"name": "IT资产领用申请",
|
||||
"type": "jump",
|
||||
"keywords": ["资产领用", "领用登记", "设备领用"],
|
||||
"url": "https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=C4c8qt31AbSHwN9MuaFhYXt4Qwsx6ZLCftAFh6X1w&sp_id=&from=template_list",
|
||||
"location": "企微审批",
|
||||
"url": "https://itsm.servyou.com.cn/itsm-miniapp-mobile/",
|
||||
"location": "运维平台",
|
||||
},
|
||||
# IT资产借用 改用ITSM工单系统(企微审批模板 3TmACnFs... 已失效)
|
||||
"asset_borrow": {
|
||||
"id": "asset_borrow",
|
||||
"name": "资产借用申请",
|
||||
"name": "IT资产借用申请",
|
||||
"type": "jump",
|
||||
"keywords": ["资产借用", "借用", "借用设备"],
|
||||
"url": "https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=3TmACnFs8oqgYcasxVh4BfSMGNX7p9sb6ydBX77mK&sp_id=&from=template_list",
|
||||
"location": "企微审批",
|
||||
"url": "https://itsm.servyou.com.cn/itsm-miniapp-mobile/",
|
||||
"location": "运维平台",
|
||||
},
|
||||
# IT资产升级 改用ITSM工单系统(企微审批模板 Bs7ucTGs... 已失效)
|
||||
"asset_upgrade": {
|
||||
"id": "asset_upgrade",
|
||||
"name": "IT资产升级申请",
|
||||
"type": "jump",
|
||||
"keywords": ["资产升级", "设备升级", "升级"],
|
||||
"url": "https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=Bs7ucTGsPuFhxfk8pn8EydxrWxkVetB4JR8Pb6PHS&sp_id=&from=template_list",
|
||||
"location": "企微审批",
|
||||
"url": "https://itsupport.servyou.com.cn/h5/itsm-bridge.html?name=IT%E8%AE%BE%E5%A4%87%E5%8D%87%E7%BA%A7%E4%B8%8E%E7%A1%AC%E4%BB%B6%E7%BB%B4%E4%BF%AE",
|
||||
"location": "运维平台",
|
||||
},
|
||||
"it_device_repair": {
|
||||
"id": "it_device_repair",
|
||||
"name": "IT设备升级与硬件维修",
|
||||
"type": "jump",
|
||||
"keywords": ["设备升级", "硬件维修", "设备维修"],
|
||||
"url": "https://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=IT%E8%AE%BE%E5%A4%87%E5%8D%87%E7%BA%A7%E4%B8%8E%E7%A1%AC%E4%BB%B6%E7%BB%B4%E4%BF%AE",
|
||||
"url": "https://itsupport.servyou.com.cn/h5/itsm-bridge.html?name=IT%E8%AE%BE%E5%A4%87%E5%8D%87%E7%BA%A7%E4%B8%8E%E7%A1%AC%E4%BB%B6%E7%BB%B4%E4%BF%AE",
|
||||
"location": "运维平台",
|
||||
},
|
||||
# --- 账号权限申请 ---
|
||||
@@ -100,17 +103,17 @@ APPROVAL_TEMPLATES: dict[str, dict] = {
|
||||
"name": "员工零信任(原VPN)账号",
|
||||
"type": "jump",
|
||||
"keywords": ["VPN", "vpn", "零信任", "VPN账号"],
|
||||
"url": "https://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=%E5%91%98%E5%B7%A5%E9%9B%B6%E4%BF%A1%E4%BB%BB%EF%BC%88%E5%8E%9FVPN%EF%BC%89%E8%B4%A6%E5%8F%B7%E7%94%B3%E8%AF%B7",
|
||||
"url": "https://itsupport.servyou.com.cn/h5/itsm-bridge.html?name=%E5%91%98%E5%B7%A5%E9%9B%B6%E4%BF%A1%E4%BB%BB%EF%BC%88%E5%8E%9FVPN%EF%BC%89%E8%B4%A6%E5%8F%B7%E7%94%B3%E8%AF%B7",
|
||||
"location": "运维平台",
|
||||
},
|
||||
# --- 软件服务申请 ---
|
||||
# --- 软件服务申请 --- 改用ITSM工单系统(企微审批模板 3TmACf8D... 已失效)
|
||||
"software_service": {
|
||||
"id": "software_service",
|
||||
"name": "商业软件服务申请",
|
||||
"type": "jump",
|
||||
"keywords": ["软件", "商业软件", "软件服务"],
|
||||
"url": "https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=3TmACf8DsJy5yr7aymanLskywC4EDhFLuz1KuBBQK&sp_id=&from=template_list",
|
||||
"location": "企微审批",
|
||||
"url": "https://itsupport.servyou.com.cn/h5/itsm-bridge.html?name=%E5%95%86%E4%B8%9A%E8%BD%AF%E4%BB%B6%E6%9C%8D%E5%8A%A1%E7%94%B3%E8%AF%B7",
|
||||
"location": "运维平台",
|
||||
},
|
||||
# --- 资产处置申请 ---
|
||||
"asset_repair": {
|
||||
@@ -170,7 +173,7 @@ APPROVAL_TEMPLATES: dict[str, dict] = {
|
||||
"name": "终端设备网络准入申请",
|
||||
"type": "jump",
|
||||
"keywords": ["网络准入", "终端准入", "准入申请"],
|
||||
"url": "https://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=%E7%BB%88%E7%AB%AF%E8%AE%BE%E5%A4%87%E7%BD%91%E7%BB%9C%E5%87%86%E5%85%A5%E7%94%B3%E8%AF%B7",
|
||||
"url": "https://itsupport.servyou.com.cn/h5/itsm-bridge.html?name=%E7%BB%88%E7%AB%AF%E8%AE%BE%E5%A4%87%E7%BD%91%E7%BB%9C%E5%87%86%E5%85%A5%E7%94%B3%E8%AF%B7",
|
||||
"location": "运维平台",
|
||||
},
|
||||
# --- 活动与会议技术支持 ---
|
||||
@@ -179,7 +182,7 @@ APPROVAL_TEMPLATES: dict[str, dict] = {
|
||||
"name": "活动与会议技术支持",
|
||||
"type": "jump",
|
||||
"keywords": ["活动支持", "会议支持", "技术支持", "活动技术"],
|
||||
"url": "https://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=%E6%B4%BB%E5%8A%A8%E4%B8%8E%E4%BC%9A%E8%AE%AE%E6%8A%80%E6%9C%AF%E6%94%AF%E6%8C%81",
|
||||
"url": "https://itsupport.servyou.com.cn/h5/itsm-bridge.html?name=%E6%B4%BB%E5%8A%A8%E4%B8%8E%E4%BC%9A%E8%AE%AE%E6%8A%80%E6%9C%AF%E6%94%AF%E6%8C%81",
|
||||
"location": "运维平台",
|
||||
},
|
||||
# --- 员工IT支持与故障报修 ---
|
||||
@@ -188,7 +191,7 @@ APPROVAL_TEMPLATES: dict[str, dict] = {
|
||||
"name": "员工IT支持与故障报修",
|
||||
"type": "jump",
|
||||
"keywords": ["故障报修", "IT支持", "技术支持", "报修"],
|
||||
"url": "https://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=%E5%91%98%E5%B7%A5IT%E6%94%AF%E6%8C%81%E4%B8%8E%E6%95%85%E9%9A%9C%E6%8A%A5%E4%BF%AE",
|
||||
"url": "https://itsupport.servyou.com.cn/h5/itsm-bridge.html?name=%E5%91%98%E5%B7%A5IT%E6%94%AF%E6%8C%81%E4%B8%8E%E6%95%85%E9%9A%9C%E6%8A%A5%E4%BF%AE",
|
||||
"location": "运维平台",
|
||||
},
|
||||
# --- 公共邮箱账号申请 ---
|
||||
@@ -197,7 +200,7 @@ APPROVAL_TEMPLATES: dict[str, dict] = {
|
||||
"name": "公共邮箱账号申请",
|
||||
"type": "jump",
|
||||
"keywords": ["公共邮箱", "公共账号", "共享邮箱"],
|
||||
"url": "https://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=%E5%85%AC%E5%85%B1%E9%82%AE%E7%AE%B1%E8%B4%A6%E5%8F%B7%E7%94%B3%E8%AF%B7",
|
||||
"url": "https://itsupport.servyou.com.cn/h5/itsm-bridge.html?name=%E5%85%AC%E5%85%B1%E9%82%AE%E7%AE%B1%E8%B4%A6%E5%8F%B7%E7%94%B3%E8%AF%B7",
|
||||
"location": "运维平台",
|
||||
},
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -294,6 +294,7 @@ async def scan_qrcode(
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>登录成功 - IT智能服务台</title>
|
||||
<script src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"></script>
|
||||
<style>
|
||||
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
||||
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: linear-gradient(135deg, #07C160 0%, #06AD56 100%); min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px; }}
|
||||
@@ -304,8 +305,6 @@ body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
.status {{ display: inline-flex; align-items: center; gap: 6px; background: #dcfce7; color: #166534; padding: 10px 20px; border-radius: 50px; font-size: 14px; font-weight: 500; }}
|
||||
.footer {{ margin-top: 20px; color: #9ca3af; font-size: 12px; }}
|
||||
.back-btn {{ display: none; margin-top: 20px; padding: 12px 32px; background: #07C160; color: white; border: none; border-radius: 50px; font-size: 16px; font-weight: 500; cursor: pointer; }}
|
||||
.debug {{ margin-top: 16px; color: #6b7280; font-size: 11px; line-height: 1.6; word-break: break-all; text-align: left; background: #f3f4f6; padding: 10px 12px; border-radius: 8px; }}
|
||||
.debug b {{ color: #07C160; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -319,22 +318,34 @@ body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
<p class="subtitle">你好,{user_name}<br>请返回电脑端查看</p>
|
||||
<button class="back-btn" id="backBtn" onclick="manualClose()">点击返回企微</button>
|
||||
<div class="footer">页面即将自动关闭 · 税友集团</div>
|
||||
<div class="debug" id="debugInfo">
|
||||
<b>签名:</b> {'成功' if jsapi_signature else '未生成'}<br>
|
||||
<b>URL:</b> {current_url}<br>
|
||||
<b>状态:</b> <span id="jsStatus">JS加载中...</span>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function() {{
|
||||
var statusEl = document.getElementById('jsStatus');
|
||||
var btnEl = document.getElementById('backBtn');
|
||||
var startTime = Date.now();
|
||||
var tried = {{}};
|
||||
// 初始化企微 JS-SDK:注入后端生成的签名参数,使 wx.closeWindow() 生效
|
||||
if (typeof wx !== 'undefined') {{
|
||||
wx.config({{
|
||||
beta: true,
|
||||
debug: false,
|
||||
appId: '{jsapi_appid}',
|
||||
timestamp: {jsapi_timestamp},
|
||||
nonceStr: '{jsapi_nonce}',
|
||||
signature: '{jsapi_signature}',
|
||||
jsApiList: ['closeWindow']
|
||||
}});
|
||||
wx.ready(function() {{
|
||||
console.log('wx.config ready, closing window');
|
||||
wx.closeWindow();
|
||||
}});
|
||||
wx.error(function(res) {{
|
||||
console.error('wx.config error:', res);
|
||||
// 降级到下方轮询检测逻辑
|
||||
}});
|
||||
}} else {{
|
||||
console.warn('WeCom JS-SDK 未加载,使用降级关闭逻辑');
|
||||
}}
|
||||
|
||||
function setStatus(msg) {{
|
||||
if (statusEl) statusEl.textContent = msg + ' (' + (Date.now() - startTime) + 'ms)';
|
||||
}}
|
||||
(function() {{
|
||||
var btnEl = document.getElementById('backBtn');
|
||||
var tried = {{}};
|
||||
|
||||
function showBtn() {{
|
||||
if (btnEl) btnEl.style.display = 'inline-block';
|
||||
@@ -342,43 +353,43 @@ body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
|
||||
// 尝试关闭当前 WebView
|
||||
function tryClose(forceShowBtn) {{
|
||||
setStatus('尝试关闭');
|
||||
console.log('尝试关闭');
|
||||
|
||||
// 1. 企微/微信 JS-SDK wx.closeWindow
|
||||
if (!tried.wxClose && typeof wx !== 'undefined' && wx.closeWindow) {{
|
||||
tried.wxClose = true;
|
||||
try {{
|
||||
setStatus('wx.closeWindow');
|
||||
console.log('wx.closeWindow');
|
||||
wx.closeWindow();
|
||||
return true;
|
||||
}} catch(e) {{ setStatus('wx.closeWindow失败:' + (e.message || e)); }}
|
||||
}} catch(e) {{ console.log('wx.closeWindow失败:' + (e.message || e)); }}
|
||||
}}
|
||||
|
||||
// 2. wx.invoke closeWindow
|
||||
if (!tried.wxInvoke && typeof wx !== 'undefined' && wx.invoke) {{
|
||||
tried.wxInvoke = true;
|
||||
try {{
|
||||
setStatus('wx.invoke closeWindow');
|
||||
console.log('wx.invoke closeWindow');
|
||||
wx.invoke('closeWindow', {{}}, function(){{}});
|
||||
return true;
|
||||
}} catch(e) {{ setStatus('wx.invoke失败:' + (e.message || e)); }}
|
||||
}} catch(e) {{ console.log('wx.invoke失败:' + (e.message || e)); }}
|
||||
}}
|
||||
|
||||
// 3. 内置 WeixinJSBridge
|
||||
if (!tried.jsBridge && typeof WeixinJSBridge !== 'undefined' && WeixinJSBridge.call) {{
|
||||
tried.jsBridge = true;
|
||||
try {{
|
||||
setStatus('WeixinJSBridge.closeWindow');
|
||||
console.log('WeixinJSBridge.closeWindow');
|
||||
WeixinJSBridge.call('closeWindow');
|
||||
return true;
|
||||
}} catch(e) {{ setStatus('JSBridge失败:' + (e.message || e)); }}
|
||||
}} catch(e) {{ console.log('JSBridge失败:' + (e.message || e)); }}
|
||||
}}
|
||||
|
||||
// 4. window.close
|
||||
if (!tried.windowClose) {{
|
||||
tried.windowClose = true;
|
||||
try {{
|
||||
setStatus('window.close');
|
||||
console.log('window.close');
|
||||
window.close();
|
||||
return true;
|
||||
}} catch(e) {{}}
|
||||
@@ -388,14 +399,14 @@ body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
if (!tried.historyBack) {{
|
||||
tried.historyBack = true;
|
||||
try {{
|
||||
setStatus('history.back');
|
||||
console.log('history.back');
|
||||
history.back();
|
||||
return true;
|
||||
}} catch(e) {{}}
|
||||
}}
|
||||
|
||||
if (forceShowBtn) {{
|
||||
setStatus('无法自动关闭,请手动返回');
|
||||
console.log('无法自动关闭,请手动返回');
|
||||
showBtn();
|
||||
}}
|
||||
return false;
|
||||
@@ -405,6 +416,9 @@ body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
tryClose(true);
|
||||
}}
|
||||
|
||||
// 暴露到全局,供按钮 onclick 调用
|
||||
window.manualClose = manualClose;
|
||||
|
||||
// 轮询检测 WeixinJSBridge / wx,最多 5 秒
|
||||
var checkCount = 0;
|
||||
var maxChecks = 50;
|
||||
@@ -412,11 +426,11 @@ body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
checkCount++;
|
||||
var hasWx = typeof wx !== 'undefined';
|
||||
var hasBridge = typeof WeixinJSBridge !== 'undefined';
|
||||
setStatus('检测中 wx=' + hasWx + ' bridge=' + hasBridge + ' count=' + checkCount);
|
||||
console.log('检测中 wx=' + hasWx + ' bridge=' + hasBridge + ' count=' + checkCount);
|
||||
|
||||
if (hasWx || hasBridge) {{
|
||||
clearInterval(interval);
|
||||
setStatus('已检测到关闭API,1秒后尝试关闭');
|
||||
console.log('已检测到关闭API,1秒后尝试关闭');
|
||||
setTimeout(function() {{
|
||||
tryClose(true);
|
||||
}}, 1000);
|
||||
@@ -425,7 +439,7 @@ body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
|
||||
if (checkCount >= maxChecks) {{
|
||||
clearInterval(interval);
|
||||
setStatus('未检测到API,直接尝试关闭');
|
||||
console.log('未检测到API,直接尝试关闭');
|
||||
tryClose(true);
|
||||
}}
|
||||
}}, 100);
|
||||
|
||||
+36
-63
@@ -24,7 +24,6 @@ import json
|
||||
import logging
|
||||
import re
|
||||
import secrets
|
||||
from collections import OrderedDict
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
from urllib.parse import quote
|
||||
@@ -65,7 +64,11 @@ from app.tasks.h5_ai_task import process_h5_ai_reply
|
||||
from app.services.funny_phrase_service import FunnyPhraseService
|
||||
from app.services.ws_manager import manager as ws_manager
|
||||
from app.services.wecom_service import WecomService
|
||||
from app.services.employee_directory import get_org_directory
|
||||
from app.services.employee_directory import (
|
||||
count_tree_employees,
|
||||
get_org_directory,
|
||||
get_org_tree_cached,
|
||||
)
|
||||
from app.utils.response import AppException, ERR_UNAUTHORIZED, success_response
|
||||
from app.services.closing_service import ClosingService
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -919,7 +922,12 @@ async def h5_send_message(
|
||||
# WS 广播失败不阻塞消息存储,只记录 warning
|
||||
logger.warning(f"WS 广播用户消息失败(消息已存储): {ws_err}")
|
||||
|
||||
# 4. 启动后台 AI 任务(异步,不阻塞 HTTP 返回)
|
||||
# 4. 提交当前事务,确保后台任务能读到刚创建的 conversation/message
|
||||
# 为什么:asyncio.create_task 立即运行,若 HTTP 事务未提交,
|
||||
# 后台 DB session 会报"会话不存在"(race condition)
|
||||
await db.commit()
|
||||
|
||||
# 5. 启动后台 AI 任务(异步,不阻塞 HTTP 返回)
|
||||
# 为什么:AI 推理(Dify)慢(3~15s),放后台经 WS 流式推回,
|
||||
# 发送接口瞬时返回,前端不再卡"发送中"
|
||||
# 约束:后台任务使用独立 DB session,且需单 worker(见 h5_ai_task.py)
|
||||
@@ -1734,7 +1742,7 @@ async def h5_search_employees(
|
||||
return success_response(data=[])
|
||||
|
||||
try:
|
||||
# 获取组织目录(含 10 分钟 Redis 缓存 + 本地降级)
|
||||
# 获取组织目录(含 30 分钟 Redis 缓存 + 本地降级)
|
||||
directory, _ = await get_org_directory(db, redis)
|
||||
|
||||
kw_lower = kw.lower()
|
||||
@@ -1774,24 +1782,34 @@ async def h5_get_org_tree(
|
||||
):
|
||||
"""H5 员工端获取组织架构树(部门层级 + 每个部门下的员工列表)。
|
||||
|
||||
复用 get_org_directory() 获取员工列表(已含 department 字段),
|
||||
在服务端按 department 分组构建树结构。排除当前登录员工自己。
|
||||
利用企微 department/list 返回的 parentid 字段构建真正的层级树,
|
||||
不再按部门名扁平分组。部门ID加 ``dept_`` 前缀作为唯一 key,
|
||||
避免同名部门合并。员工可以出现在其所属的所有部门下。
|
||||
|
||||
树结构示例:
|
||||
树结构示例(多层级):
|
||||
[
|
||||
{
|
||||
"id": "研发一部",
|
||||
"label": "研发一部",
|
||||
"id": "dept_1",
|
||||
"label": "公司",
|
||||
"dept_id": 1,
|
||||
"parentid": 0,
|
||||
"children": [
|
||||
{"id": "zhangsan", "label": "张三", "isLeaf": true, "department": "研发一部"}
|
||||
{
|
||||
"id": "dept_2",
|
||||
"label": "研发一部",
|
||||
"dept_id": 2,
|
||||
"parentid": 1,
|
||||
"children": [
|
||||
{"id": "zhangsan", "label": "张三", "isLeaf": true, "department": "研发一部"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
规则:
|
||||
- department 为空的员工归到"未分配部门"分组
|
||||
- 企微返回多部门(逗号分隔)时,取第一个作为主部门
|
||||
- 部门按名称排序,部门内员工按姓名排序
|
||||
性能优化:
|
||||
- 树构建结果独立缓存(key: wecom:org_tree:h5,TTL 30 分钟)
|
||||
- 缓存中包含所有员工,读取后过滤掉当前登录员工自己
|
||||
|
||||
Args:
|
||||
employee_id: 当前登录员工ID
|
||||
@@ -1802,56 +1820,11 @@ async def h5_get_org_tree(
|
||||
Dict: 统一响应格式,data 为树节点列表
|
||||
"""
|
||||
try:
|
||||
# 获取组织目录(含 Redis 缓存 + 本地降级)
|
||||
directory, _ = await get_org_directory(db, redis)
|
||||
# 获取组织架构树(含独立缓存 + 排除当前员工)
|
||||
tree = await get_org_tree_cached(db, redis, "h5", employee_id)
|
||||
|
||||
# 按部门分组(OrderedDict 保持稳定插入顺序,后续再排序)
|
||||
dept_groups: "OrderedDict[str, List[Dict[str, Any]]]" = OrderedDict()
|
||||
|
||||
for emp in directory:
|
||||
# 排除当前员工自己
|
||||
if emp.get("employee_id") == employee_id:
|
||||
continue
|
||||
|
||||
# 取部门名:为空则归"未分配部门";多部门(逗号分隔)取第一个
|
||||
dept = (emp.get("department") or "").strip()
|
||||
if not dept:
|
||||
dept = "未分配部门"
|
||||
else:
|
||||
dept = dept.split(",")[0].strip()
|
||||
if not dept:
|
||||
dept = "未分配部门"
|
||||
|
||||
if dept not in dept_groups:
|
||||
dept_groups[dept] = []
|
||||
dept_groups[dept].append(emp)
|
||||
|
||||
# 构建树节点(部门按名称排序,员工按姓名排序)
|
||||
tree: List[Dict[str, Any]] = []
|
||||
for dept_name in sorted(dept_groups.keys()):
|
||||
employees = dept_groups[dept_name]
|
||||
if not employees:
|
||||
continue
|
||||
|
||||
# 部门内员工按姓名排序
|
||||
employees.sort(key=lambda e: e.get("name", ""))
|
||||
|
||||
tree.append({
|
||||
"id": dept_name,
|
||||
"label": dept_name,
|
||||
"children": [
|
||||
{
|
||||
"id": emp.get("employee_id", ""),
|
||||
"label": emp.get("name", ""),
|
||||
"isLeaf": True,
|
||||
"department": dept_name,
|
||||
}
|
||||
for emp in employees
|
||||
],
|
||||
})
|
||||
|
||||
total_employees = sum(len(node["children"]) for node in tree)
|
||||
logger.info(f"H5组织架构树: {len(tree)} 个部门, 共 {total_employees} 人")
|
||||
total_employees = count_tree_employees(tree)
|
||||
logger.info(f"H5组织架构树: {len(tree)} 个顶层节点, 共 {total_employees} 人")
|
||||
return success_response(data=tree)
|
||||
|
||||
except AppException:
|
||||
|
||||
@@ -37,6 +37,7 @@ from app.dependencies import require_permission, get_current_user, UserInfo
|
||||
|
||||
from app.services.ws_manager import manager
|
||||
from app.services.session_service import SessionService
|
||||
from app.services.conversation.session_query_service import SessionQueryService
|
||||
from app.utils.response import AppException, ERR_CONVERSATION_NOT_FOUND, ERR_CONVERSATION_RESOLVED, success_response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -765,4 +766,58 @@ async def send_typing_event(
|
||||
elif pid in manager.employee_connections:
|
||||
await manager.send_to_employee(pid, payload)
|
||||
|
||||
return success_response(message="typing 事件已发送")
|
||||
return success_response(message="typing 事件已发送")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# GET /api/employees/{employee_id}/history-messages — 获取员工历史消息(跨会话聚合)
|
||||
# --------------------------------------------------------------------------
|
||||
@router.get("/employees/{employee_id}/history-messages")
|
||||
@require_permission("conversation", "read", "all")
|
||||
async def get_employee_history_messages(
|
||||
employee_id: str,
|
||||
limit: int = Query(50, ge=1, le=100, description="每页消息数量"),
|
||||
before: Optional[str] = Query(None, description="游标:加载此消息ID之前的消息(向上翻页)"),
|
||||
current_conversation_id: Optional[str] = Query(None, description="当前会话ID(用于标记当前会话的分隔条)"),
|
||||
current_agent: Agent = Depends(get_current_agent),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取员工历史消息(跨会话聚合)。
|
||||
|
||||
将同一员工的所有会话消息合并为一条时间线,按时间排序。
|
||||
用于"历史会话"功能,让坐席查看员工过去的所有咨询记录。
|
||||
|
||||
Args:
|
||||
employee_id: 员工企微 UserID
|
||||
limit: 每页消息数量(1~100)
|
||||
before: 游标消息ID,只查该消息之前的消息(向上翻页)
|
||||
current_conversation_id: 当前会话ID(用于标记当前会话的分隔条)
|
||||
current_agent: 当前坐席(鉴权依赖注入)
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含消息列表、是否还有更多、会话摘要映射
|
||||
"""
|
||||
service = SessionQueryService(db)
|
||||
messages, has_more, conversation_summaries = await service.get_employee_history_messages(
|
||||
employee_id=employee_id,
|
||||
limit=limit,
|
||||
before=before,
|
||||
current_conversation_id=current_conversation_id,
|
||||
)
|
||||
|
||||
# 转换为响应格式(补充发送者头像)
|
||||
items = [
|
||||
await _enrich_message_with_avatar(
|
||||
MessageResponse.model_validate(m).model_dump(), db
|
||||
)
|
||||
for m in messages
|
||||
]
|
||||
|
||||
return success_response(
|
||||
data={
|
||||
"items": items,
|
||||
"has_more": has_more,
|
||||
"conversation_summaries": conversation_summaries,
|
||||
}
|
||||
)
|
||||
@@ -347,6 +347,7 @@ async def unbind_otp(
|
||||
@require_role("admin")
|
||||
async def admin_reset_otp(
|
||||
employee_id: str,
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
redis: aioredis.Redis = Depends(_get_redis),
|
||||
):
|
||||
@@ -386,6 +387,7 @@ async def admin_reset_otp(
|
||||
@router.get("/otp-admin-users", response_model=None)
|
||||
@require_role("admin")
|
||||
async def admin_list_otp_users(
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
keyword: str = None,
|
||||
bound: str = None,
|
||||
|
||||
@@ -209,6 +209,14 @@ api_router.include_router(admin_router, tags=["管理后台"])
|
||||
# DELETE /api/admin/roles/mapping-rules/{id} — 删除映射规则
|
||||
api_router.include_router(admin_roles_router, tags=["角色管理"])
|
||||
|
||||
# 欢迎与引导配置 API
|
||||
# GET /api/admin/welcome-config — 获取全部配置
|
||||
# PUT /api/admin/welcome-config — 更新配置
|
||||
# POST /api/admin/welcome-config/video — 上传视频
|
||||
# DELETE /api/admin/welcome-config/video — 删除视频
|
||||
from app.api.admin.welcome import router as welcome_config_router
|
||||
api_router.include_router(welcome_config_router, tags=["欢迎与引导配置"])
|
||||
|
||||
# 终端安全对比 API
|
||||
# GET /api/admin/security/comparison/summary — 比对汇总
|
||||
# GET /api/admin/security/comparison/no-huorong — 未安装火绒清单
|
||||
|
||||
@@ -48,6 +48,17 @@ async def get_baidu_token() -> str:
|
||||
Raises:
|
||||
HTTPException: 获取 token 失败时抛出 500 错误
|
||||
"""
|
||||
# 防御性检查:确保百度 ASR 凭证已配置
|
||||
if not settings.baidu_asr_api_key or not settings.baidu_asr_secret_key:
|
||||
logger.error(
|
||||
"[BaiduASR] 配置缺失:BAIDU_ASR_API_KEY 或 SECRET_KEY 未设置,"
|
||||
"无法获取 access_token"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="百度ASR配置缺失:BAIDU_ASR_API_KEY或SECRET_KEY未设置",
|
||||
)
|
||||
|
||||
redis = settings.create_redis_client()
|
||||
try:
|
||||
# 1. 尝试从 Redis 缓存读取 token
|
||||
@@ -120,6 +131,24 @@ async def transcribe_audio(
|
||||
f"filename={audio.filename}"
|
||||
)
|
||||
|
||||
# 防御性检查:确保百度 ASR AppID 已配置
|
||||
# cuid 为空时百度会返回 "url param cuid error"
|
||||
if not settings.baidu_asr_app_id:
|
||||
logger.error(
|
||||
"[BaiduASR] 配置缺失:BAIDU_ASR_APP_ID 未设置,"
|
||||
"cuid 将为空,百度会返回 url param cuid error"
|
||||
)
|
||||
return error_response(
|
||||
code=3001,
|
||||
message="百度ASR配置缺失:BAIDU_ASR_APP_ID未设置",
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"[BaiduASR] 配置检查通过: app_id={settings.baidu_asr_app_id}, "
|
||||
f"api_key={'已设置' if settings.baidu_asr_api_key else '未设置'}, "
|
||||
f"secret_key={'已设置' if settings.baidu_asr_secret_key else '未设置'}"
|
||||
)
|
||||
|
||||
# 2. 获取百度 access_token
|
||||
try:
|
||||
token = await get_baidu_token()
|
||||
|
||||
@@ -207,6 +207,33 @@ async def receive_message(
|
||||
extra_data: dict = {}
|
||||
if msg_type == "image":
|
||||
extra_data["pic_url"] = pic_url
|
||||
# 下载企微图片到本地服务器,避免临时URL过期
|
||||
if media_id:
|
||||
try:
|
||||
import os
|
||||
import uuid
|
||||
|
||||
# 下载图片二进制数据
|
||||
image_data = await wecom_service.download_temp_media(media_id)
|
||||
|
||||
# 生成保存路径
|
||||
file_ext = ".jpg" # 企微返回的图片通常是 jpg
|
||||
file_name = f"{uuid.uuid4()}{file_ext}"
|
||||
upload_dir = os.path.join("uploads", "images")
|
||||
os.makedirs(upload_dir, exist_ok=True)
|
||||
file_path = os.path.join(upload_dir, file_name)
|
||||
|
||||
# 保存到本地
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(image_data)
|
||||
|
||||
# 构建本地访问 URL
|
||||
local_media_url = f"/media/images/{file_name}"
|
||||
extra_data["local_media_url"] = local_media_url
|
||||
logger.info(f"企微图片已下载到本地: media_id={media_id}, path={file_path}")
|
||||
except Exception as img_err:
|
||||
# 下载失败不影响消息处理,记录错误继续
|
||||
logger.warning(f"企微图片下载失败: media_id={media_id}, error={img_err}")
|
||||
elif msg_type == "voice":
|
||||
extra_data["format"] = msg_format
|
||||
elif msg_type == "video":
|
||||
|
||||
+190
-4
@@ -1,10 +1,18 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — AI Wingman API 路由
|
||||
# =============================================================================
|
||||
# 说明:坐席端 AI 智能副驾驶 API,包含 3 个核心端点:
|
||||
# 1. POST /api/conversations/{id}/wingman/draft — 生成 AI 草稿回复
|
||||
# 2. POST /api/conversations/{id}/wingman/summary — 生成会话自动摘要
|
||||
# 3. POST /api/conversations/{id}/wingman/tags — 生成自动标签建议
|
||||
# 说明:坐席端 AI 智能副驾驶 API,包含以下端点:
|
||||
#
|
||||
# 基础能力(3 个):
|
||||
# 1. POST /api/conversations/{id}/wingman/draft — 生成 AI 草稿回复
|
||||
# 2. POST /api/conversations/{id}/wingman/summary — 生成会话自动摘要
|
||||
# 3. POST /api/conversations/{id}/wingman/tags — 生成自动标签建议
|
||||
#
|
||||
# AI 辅助消息框(4 个,v_next):
|
||||
# 4. POST /api/conversations/{id}/wingman/autocomplete — 自动补齐
|
||||
# 5. POST /api/conversations/{id}/wingman/tone-adjust — 语气调整
|
||||
# 6. POST /api/conversations/{id}/wingman/polish — 文字润色
|
||||
# 7. POST /api/conversations/{id}/wingman/rewrite — 智能改写
|
||||
#
|
||||
# 所有端点需要坐席认证(get_current_agent)
|
||||
# =============================================================================
|
||||
@@ -21,6 +29,12 @@ from app.models.agent import Agent
|
||||
from app.models.conversation import Conversation
|
||||
from app.models.message import Message
|
||||
from app.services.wingman_service import WingmanService
|
||||
from app.schemas.wingman_assist import (
|
||||
AutocompleteRequest,
|
||||
ToneAdjustRequest,
|
||||
PolishRequest,
|
||||
RewriteRequest,
|
||||
)
|
||||
from app.utils.response import ERR_NOT_FOUND, success_response
|
||||
|
||||
# 复用坐席认证依赖
|
||||
@@ -225,3 +239,175 @@ async def suggest_tags(
|
||||
)
|
||||
|
||||
return success_response(data=result)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# POST /api/conversations/{conversation_id}/wingman/autocomplete
|
||||
# --------------------------------------------------------------------------
|
||||
@router.post("/conversations/{conversation_id}/wingman/autocomplete")
|
||||
async def autocomplete(
|
||||
conversation_id: str,
|
||||
request: AutocompleteRequest,
|
||||
agent: Agent = Depends(get_current_agent),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
wingman_service: WingmanService = Depends(dep_wingman_service),
|
||||
):
|
||||
"""自动补齐。
|
||||
|
||||
坐席输入停顿超过 800ms 后,前端请求 AI 生成下一句补齐建议。
|
||||
|
||||
Args:
|
||||
conversation_id: 会话ID
|
||||
request: 补齐请求(当前文本、光标位置、最大长度)
|
||||
agent: 当前坐席
|
||||
db: 数据库会话
|
||||
wingman_service: Wingman 服务实例
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含补齐文本和置信度
|
||||
"""
|
||||
# 1. 验证坐席身份 + 会话存在性
|
||||
await _validate_conversation(conversation_id, agent, db)
|
||||
|
||||
# 2. 获取最近 5 条消息作为上下文
|
||||
messages = await _get_recent_messages(conversation_id, db, limit=5)
|
||||
|
||||
# 3. 调用 WingmanService 生成补齐
|
||||
result = await wingman_service.generate_completion(
|
||||
conversation_id=conversation_id,
|
||||
current_text=request.current_text,
|
||||
messages=messages,
|
||||
max_length=request.max_length,
|
||||
)
|
||||
|
||||
return success_response(data=result)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# POST /api/conversations/{conversation_id}/wingman/tone-adjust
|
||||
# --------------------------------------------------------------------------
|
||||
@router.post("/conversations/{conversation_id}/wingman/tone-adjust")
|
||||
async def tone_adjust(
|
||||
conversation_id: str,
|
||||
request: ToneAdjustRequest,
|
||||
agent: Agent = Depends(get_current_agent),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
wingman_service: WingmanService = Depends(dep_wingman_service),
|
||||
):
|
||||
"""语气调整。
|
||||
|
||||
坐席选中一段文字后,选择目标语气(专业/友好/简洁),AI 对选中文字改写。
|
||||
|
||||
Args:
|
||||
conversation_id: 会话ID
|
||||
request: 语气调整请求(选中文字、完整输入框内容、目标语气)
|
||||
agent: 当前坐席
|
||||
db: 数据库会话
|
||||
wingman_service: Wingman 服务实例
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含改写后的文字、语气和变更摘要
|
||||
"""
|
||||
# 1. 验证坐席身份 + 会话存在性
|
||||
await _validate_conversation(conversation_id, agent, db)
|
||||
|
||||
# 2. 获取最近 5 条消息作为上下文
|
||||
messages = await _get_recent_messages(conversation_id, db, limit=5)
|
||||
|
||||
# 3. 调用 WingmanService 进行语气调整
|
||||
result = await wingman_service.adjust_tone(
|
||||
conversation_id=conversation_id,
|
||||
selected_text=request.selected_text,
|
||||
full_text=request.full_text,
|
||||
tone=request.tone,
|
||||
messages=messages,
|
||||
)
|
||||
|
||||
return success_response(data=result)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# POST /api/conversations/{conversation_id}/wingman/polish
|
||||
# --------------------------------------------------------------------------
|
||||
@router.post("/conversations/{conversation_id}/wingman/polish")
|
||||
async def polish(
|
||||
conversation_id: str,
|
||||
request: PolishRequest,
|
||||
agent: Agent = Depends(get_current_agent),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
wingman_service: WingmanService = Depends(dep_wingman_service),
|
||||
):
|
||||
"""文字润色。
|
||||
|
||||
对坐席输入的文字进行扩写/压缩/纠错处理。
|
||||
|
||||
Args:
|
||||
conversation_id: 会话ID
|
||||
request: 润色请求(待润色文字、操作类型、是否携带上下文)
|
||||
agent: 当前坐席
|
||||
db: 数据库会话
|
||||
wingman_service: Wingman 服务实例
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含润色后的文字、操作类型和变更摘要
|
||||
"""
|
||||
# 1. 验证坐席身份 + 会话存在性
|
||||
await _validate_conversation(conversation_id, agent, db)
|
||||
|
||||
# 2. 可选携带对话上下文(由请求参数控制)
|
||||
messages = []
|
||||
if request.conversation_context:
|
||||
messages = await _get_recent_messages(conversation_id, db, limit=5)
|
||||
|
||||
# 3. 调用 WingmanService 进行润色
|
||||
result = await wingman_service.polish_text(
|
||||
conversation_id=conversation_id,
|
||||
text=request.text,
|
||||
action=request.action,
|
||||
messages=messages,
|
||||
)
|
||||
|
||||
return success_response(data=result)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# POST /api/conversations/{conversation_id}/wingman/rewrite
|
||||
# --------------------------------------------------------------------------
|
||||
@router.post("/conversations/{conversation_id}/wingman/rewrite")
|
||||
async def rewrite(
|
||||
conversation_id: str,
|
||||
request: RewriteRequest,
|
||||
agent: Agent = Depends(get_current_agent),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
wingman_service: WingmanService = Depends(dep_wingman_service),
|
||||
):
|
||||
"""智能改写。
|
||||
|
||||
基于对话上下文和知识库,为坐席生成多个不同风格的备选回复。
|
||||
|
||||
Args:
|
||||
conversation_id: 会话ID
|
||||
request: 改写请求(当前输入文本、生成版本数、是否包含知识库引用)
|
||||
agent: 当前坐席
|
||||
db: 数据库会话
|
||||
wingman_service: Wingman 服务实例
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含多个版本的回复文本、风格标签和来源
|
||||
"""
|
||||
# 1. 验证坐席身份 + 会话存在性
|
||||
await _validate_conversation(conversation_id, agent, db)
|
||||
|
||||
# 2. 获取最近 10 条消息作为上下文(改写需要更多上下文)
|
||||
messages = await _get_recent_messages(conversation_id, db, limit=10)
|
||||
|
||||
# 3. 调用 WingmanService 进行改写
|
||||
result = await wingman_service.rewrite_versions(
|
||||
conversation_id=conversation_id,
|
||||
current_text=request.current_text,
|
||||
messages=messages,
|
||||
generate_count=request.generate_count,
|
||||
include_knowledge=request.include_knowledge,
|
||||
)
|
||||
|
||||
return success_response(data=result)
|
||||
|
||||
+121
-1
@@ -18,11 +18,16 @@
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
import asyncio
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
|
||||
from app.services.ws_manager import manager as ws_manager
|
||||
from app.services.cache_service import cache_service
|
||||
from app.database import _get_session_factory
|
||||
from app.models.conversation import Conversation
|
||||
from app.models.message import Message
|
||||
from app.tasks.h5_ai_task import process_h5_ai_reply
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -212,6 +217,91 @@ async def websocket_endpoint(
|
||||
# H5员工 WebSocket 端点
|
||||
# ==========================================================================
|
||||
|
||||
async def _handle_option_select(
|
||||
conversation_id: str,
|
||||
employee_id: str,
|
||||
option_label: str,
|
||||
):
|
||||
"""处理员工点击 AI 选项按钮的后端逻辑(v2.0 新增)。
|
||||
|
||||
做什么:
|
||||
1. 在 DB 中存储员工的选项选择为一条 employee 消息
|
||||
2. 广播该消息给坐席端(让坐席看到员工选了什么)
|
||||
3. 触发 process_h5_ai_reply() → Dify 接收选项文本作为用户消息 → 返回下一轮 AI 回复
|
||||
|
||||
为什么:前端 sendOptionSelect() 通过 WS 发送 option_select 消息,
|
||||
后端必须接收并触发 AI 回复,否则用户点击选项后无响应。
|
||||
|
||||
Args:
|
||||
conversation_id: 会话 ID
|
||||
employee_id: 员工企微 UserID
|
||||
option_label: 选项的显示文本(如"企微密码"),作为用户消息发给 Dify
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
try:
|
||||
async with _get_session_factory()() as db:
|
||||
# 1. 查找会话(获取 dify_conversation_id 用于多轮上下文)
|
||||
conversation = await db.get(Conversation, conversation_id)
|
||||
if not conversation:
|
||||
logger.warning(f"option_select: 会话不存在 {conversation_id}")
|
||||
return
|
||||
|
||||
# 2. 存储员工消息(选项选择作为文本消息)
|
||||
emp_msg = Message(
|
||||
conversation_id=conversation_id,
|
||||
sender_type="employee",
|
||||
sender_id=employee_id,
|
||||
sender_name="", # 前端会从 employeeStore 补全
|
||||
content=option_label,
|
||||
msg_type="text",
|
||||
is_read=False,
|
||||
)
|
||||
db.add(emp_msg)
|
||||
await db.flush()
|
||||
|
||||
# 更新会话时间
|
||||
conversation.updated_at = datetime.now()
|
||||
db.add(conversation)
|
||||
await db.flush()
|
||||
await db.commit()
|
||||
|
||||
# 3. 广播给坐席端(让坐席看到员工的选择)
|
||||
try:
|
||||
await ws_manager.broadcast({
|
||||
"type": "new_message",
|
||||
"data": {
|
||||
"conversation_id": str(conversation_id),
|
||||
"message_id": str(emp_msg.id),
|
||||
"sender_type": "employee",
|
||||
"sender_id": employee_id,
|
||||
"content": option_label,
|
||||
"msg_type": "text",
|
||||
},
|
||||
})
|
||||
except Exception as ws_err:
|
||||
logger.warning(f"option_select: WS 广播坐席失败: {ws_err}")
|
||||
|
||||
# 4. 触发 AI 回复(异步后台任务,不阻塞)
|
||||
# dify_conversation_id 从 conversation 对象获取(保持多轮上下文)
|
||||
asyncio.create_task(
|
||||
process_h5_ai_reply(
|
||||
conversation_id=conversation_id,
|
||||
employee_id=employee_id,
|
||||
content=option_label,
|
||||
dify_conversation_id=conversation.dify_conversation_id,
|
||||
msg_type="text",
|
||||
)
|
||||
)
|
||||
logger.info(
|
||||
f"option_select 已触发 AI 回复: conv={conversation_id}, "
|
||||
f"option={option_label}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"option_select 处理异常: {e}", exc_info=True)
|
||||
|
||||
|
||||
@router.websocket("/ws/h5/{employee_id}")
|
||||
async def h5_websocket_endpoint(
|
||||
websocket: WebSocket,
|
||||
@@ -314,7 +404,7 @@ async def h5_websocket_endpoint(
|
||||
|
||||
try:
|
||||
# 消息接收循环
|
||||
# H5员工端目前只发送心跳 ping,不需要发送 typing 等事件
|
||||
# H5员工端发送心跳 ping 和 option_select(选项按钮点击)
|
||||
while True:
|
||||
data = await websocket.receive_json()
|
||||
|
||||
@@ -323,6 +413,36 @@ async def h5_websocket_endpoint(
|
||||
await websocket.send_json({"type": "pong"})
|
||||
logger.debug(f"H5 WebSocket 心跳: employee_id={employee_id}")
|
||||
|
||||
# v2.0: 处理选项按钮点击(option_select)
|
||||
# 做什么:员工点击 AI 结构化消息中的选项按钮后,前端通过 WS 发送 option_select
|
||||
# 后端接收后触发 AI 回复流程(与普通发消息等效),实现交互式排查闭环
|
||||
elif data.get("type") == "option_select":
|
||||
option_data = data.get("data", {})
|
||||
conv_id = option_data.get("conversation_id")
|
||||
option_label = option_data.get("option_label", "")
|
||||
option_value = option_data.get("option_value", "")
|
||||
|
||||
if conv_id and option_label:
|
||||
logger.info(
|
||||
f"H5 WS option_select: employee={employee_id}, "
|
||||
f"conv={conv_id}, option={option_value}"
|
||||
)
|
||||
# 异步触发 AI 回复(不阻塞 WS 循环)
|
||||
# process_h5_ai_reply 内部创建独立 DB session,
|
||||
# dify_conversation_id 传 None 时会从 conversation 对象回退读取
|
||||
asyncio.create_task(
|
||||
_handle_option_select(
|
||||
conversation_id=conv_id,
|
||||
employee_id=employee_id,
|
||||
option_label=option_label,
|
||||
)
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"H5 WS option_select 数据不完整: employee={employee_id}, "
|
||||
f"conv_id={conv_id}, label={option_label}"
|
||||
)
|
||||
|
||||
else:
|
||||
logger.debug(
|
||||
f"H5 WebSocket 收到未知消息: employee_id={employee_id}, "
|
||||
|
||||
Reference in New Issue
Block a user