v3.1 + 批次0: 智能回复重构基线 - ApprovalMatcher + 关键词降级 + 文档速修 + v4.0任务书面化

This commit is contained in:
Simon
2026-07-17 23:08:59 +08:00
parent 5a77a89ab1
commit 3ed86d5fb3
181 changed files with 19738 additions and 2655 deletions
+387
View File
@@ -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)
+97 -3
View File
@@ -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,
})
+30 -64
View File
@@ -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:agentTTL 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:
+2 -1
View File
@@ -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
View File
@@ -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
+42 -28
View File
@@ -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('已检测到关闭API1秒后尝试关闭');
console.log('已检测到关闭API1秒后尝试关闭');
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
View File
@@ -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:h5TTL 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:
+56 -1
View File
@@ -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,
}
)
+2
View File
@@ -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,
+8
View File
@@ -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 — 未安装火绒清单
+29
View File
@@ -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()
+27
View File
@@ -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
View File
@@ -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
View File
@@ -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}, "