feat: 2026-07-12~13 全量更新 - AI对话链路改造+H5 v4/v5+坐席端v5+上下文感知诊断+知识库迭代3
## H5 员工端 v4 (2026-07-13 00:48 已部署)
- 人工按钮三态文案统一为"人工坐席"
- 按钮位置移至发送键和语音按钮上方(垂直堆叠)
- 点按钮直接调 store.shakeAgent(),删除 CallAgentModal 弹窗动画
- 截图快捷键提示改为"截图->粘贴:Alt+Shift+A-Ctrl+V ---> Ctrl+V"
- 移动端隐藏截图提示(CSS 媒体查询)
- AI转人工提示改为"已为您呼叫人工坐席,请稍等!"
- 坐席接入提示改为"坐席正在查看您的信息,请等待处理回复!"
- 删除"摇铃呼叫坐席"入口和文案
- 删除孤儿组件 MessageList.vue + shake 动画 CSS
## H5 员工端 v5 (2026-07-13 02:08 已部署)
- RightPanel v2.1:删除"软件安装"和"资源权限"标签页
- 移除标签栏,智能推荐(DynamicRecommend)直接展示
- 删除 SoftwareDownloads/ApprovalLinks 引用和相关 CSS
## AI 对话链路全栈改造 Phase 1-6 (已部署)
- Phase 1: Dify JSON输出 + 后端blocking解析 + 双WS推送 + 错误降级
- Phase 2: 关键词收窄(~25强意图词) + 两级分类Prompt + 删除前端checkApprovalIntent
- Phase 3: WS扩展(ai_thinking+dynamic_recommend) + ai_structured气泡 + RightPanel v2 + 选项回传
- Phase 4: VisionService接入 + 图片消息融合(5秒窗口) + 降级策略
- Phase 5: 坐席端ai_thinking指示器 + ai_structured/byod_card渲染 + handleNewMessage修复
- Phase 6: diagnosis_stage(6值) + response_time_ms计时 + 慢响应告警(>10s)
## 坐席端 v5 (2026-07-13 01:38 已部署)
- ai_structured/byod_card 只读渲染
- AI思考指示器 UI
- handleNewMessage 透传 msg_type/extra_data 修复
- 布局优化v2.0: QuickReplyBar L1+L2悬浮 + ReplyBox左右分区 + 右栏260/560px切换
- 键盘快捷键v2.3: 纯数字路由 + ESC分层撤销 + Shift+Space用event.code
## 上下文感知智能诊断闭环 (2026-07-12 已部署)
- 三层诊断(API→Script→AI) + 三段排队(VIP→info_locked→not locked)
- 答题插队 + 五场景关闭
- 迁移052(6表+6列) + queue_service + quiz_service + closing_service
- H5前端: QueueWaiting + RightPanel双Tab + InputBar三态 + ResolveConfirmCard
- 坐席前端: pending_close结单流程 + 信息锁定(Dify步骤完成+有效回答率≥70%)
## 知识库迭代3 (2026-07-12 已部署)
- 分诊交互(H5+坐席+Dify独立应用)
- 拓扑预览(ECharts只读)
- 代答排除(4种匹配器: keyword/regex/intent/category)
- 迁移051 + 44文件43测试通过
## 后端变更
- 6个Python文件改造(h5_ai_task.py/h5.py/ai_service.py/closing_service.py等)
- funny_phrase_service.py: shake/connected/keyword 默认文案更新
- session_service.py: 企微消息文案同步
- 新增: queue.py/quiz.py/triage.py/exclusion_rules.py 等API端点
- 新增: diagnostic.py/quiz.py/triage_session.py 等模型
- 新增: closing_service/queue_service/quiz_service/triage_service 等服务
## 文档更新
- CHANGELOG.md: 新增 [未发布] 区全部变更记录
- 项目管理主文档 v2.5: 新增v0.7.3版本 + 已完成看板 + 最近搞定
- 版本记录: 新增v0.7.3条目
- AI对话链路实施计划: Phase 1-6 全部标记✅已实施
- 新增架构图/时序图/类图(mermaid)
## 部署路径修正
- 服务器项目根路径: /opt/wecom-it-desk/
- 所有前端dist均为ro bind mount,只能在宿主机源路径操作
- 服务器nginx /h5/ 是静态文件服务(非proxy_pass)
- elFinder上传二进制不可靠(MD5不匹配),改用base64分块上传
This commit is contained in:
+31
-36
@@ -209,35 +209,31 @@ APPROVAL_TEMPLATES: dict[str, dict] = {
|
||||
# 说明:覆盖 12 种审批类型、18 个审批流程的关键词,用于快速过滤非审批消息,
|
||||
# 避免 Dify 被每条消息调用。当 Dify 不可用时降级为关键词匹配。
|
||||
|
||||
# 审批关键词预过滤列表(覆盖 12 种审批类型,用于快速过滤非审批消息)
|
||||
# 审批关键词预过滤列表(v2.0 收窄版 — 2026-07-13)
|
||||
# ------------------------------------------------------------
|
||||
# 设计原则:只保留「强意图词」和「复合专有词」,移除高频泛化词。
|
||||
#
|
||||
# 改造前 ~40 词(含"设备""电脑""邮箱""权限""软件""报修"等),
|
||||
# 几乎覆盖所有 IT 消息 → 预过滤命中率 ~60% → 大量无效 Dify 调用。
|
||||
#
|
||||
# 改造后策略:
|
||||
# - 强意图动词:用户明确表达"申请/提交"意愿时才触发
|
||||
# - 复合专有词:仅在审批场景出现,不会在普通 IT 咨询中出现
|
||||
# - 移除的泛化词(设备/电脑/邮箱/权限/软件/报修/变更/会议室等)
|
||||
# 改由 Dify 意图识别内部判断,不作为预过滤触发条件
|
||||
#
|
||||
# 预期效果:预过滤命中率从 ~60% 降至 ~15%,减少 75% 无效 Dify 调用
|
||||
APPROVAL_PREFILTER_KEYWORDS: list[str] = [
|
||||
# 通用
|
||||
"申请", "资源",
|
||||
# 设备申请
|
||||
"设备", "电脑", "笔记本", "显示器", "资产领用", "资产借用", "资产升级",
|
||||
# 账号权限申请
|
||||
"VPN", "vpn", "账号", "邮箱", "外联", "权限",
|
||||
# 软件服务申请
|
||||
"软件", "业务系统",
|
||||
# 资产处置申请
|
||||
"外修", "报废", "退还",
|
||||
# 办公用品申请
|
||||
"办公用品", "超额",
|
||||
# 会议室故障报修
|
||||
"会议室", "故障报修", "报修",
|
||||
# 企业应用管理
|
||||
"企业应用", "应用管理", "应用开通",
|
||||
# 资产变更确认
|
||||
"资产变更", "变更确认", "变更",
|
||||
# 终端设备网络准入
|
||||
"网络准入", "终端准入", "准入申请",
|
||||
# 活动与会议技术支持
|
||||
"活动支持", "会议支持", "活动技术",
|
||||
# 员工IT支持与故障报修
|
||||
"IT支持", "故障报修",
|
||||
# 公共邮箱账号申请
|
||||
"公共邮箱", "公共账号", "共享邮箱",
|
||||
# 自备电脑补贴(BYOD)— 确保 BYOD 相关消息能被预过滤捕获
|
||||
# === 强意图动词/短语(用户明确表达申请意愿)===
|
||||
"申请", "审批", "提交", "表单", "走流程",
|
||||
"帮我申请", "我要申请", "需要申请", "想申请",
|
||||
# === 复合专有词(仅在审批场景出现,日常 IT 咨询不会用)===
|
||||
"资产领用", "资产借用", "资产升级", "资产变更", "资产处置",
|
||||
"网络准入", "终端准入",
|
||||
"办公用品", "超额领用",
|
||||
"企业应用管理", "应用开通",
|
||||
"公共邮箱", "共享邮箱", "公共账号",
|
||||
"故障报修", "IT支持",
|
||||
"自备电脑", "电脑补贴", "BYOD", "byod",
|
||||
]
|
||||
|
||||
@@ -955,10 +951,12 @@ async def get_approval_keywords():
|
||||
# =============================================================================
|
||||
|
||||
def _keyword_prefilter(text: str) -> bool:
|
||||
"""关键词预过滤:检查文本是否包含审批相关关键词。
|
||||
"""关键词预过滤:检查文本是否包含审批相关关键词(v2.0 收窄版)。
|
||||
|
||||
合并 APPROVAL_TEMPLATES 的 keywords 和 APPROVAL_PREFILTER_KEYWORDS,
|
||||
只要命中任意一个关键词即返回 True,未命中返回 False。
|
||||
v2.0 变更(2026-07-13):
|
||||
- 不再合并 APPROVAL_TEMPLATES 的 keywords(包含"借用""升级""外联"等泛化词)
|
||||
- 仅使用 APPROVAL_PREFILTER_KEYWORDS(强意图词 + 复合专有词)
|
||||
- 模板 keywords 仍保留在 KEYWORD_TO_APPROVAL_TYPE 中,仅用于 Dify 不可用时的降级兜底
|
||||
|
||||
Args:
|
||||
text: 用户消息文本
|
||||
@@ -969,11 +967,8 @@ def _keyword_prefilter(text: str) -> bool:
|
||||
if not text:
|
||||
return False
|
||||
lower_text = text.lower()
|
||||
# 合并 APPROVAL_TEMPLATES 的 keywords 和预定义关键词
|
||||
all_keywords: set[str] = set(APPROVAL_PREFILTER_KEYWORDS)
|
||||
for template in APPROVAL_TEMPLATES.values():
|
||||
all_keywords.update(template.get("keywords", []))
|
||||
return any(kw.lower() in lower_text for kw in all_keywords)
|
||||
# v2.0: 仅使用预过滤关键词列表,不合并模板 keywords
|
||||
return any(kw.lower() in lower_text for kw in APPROVAL_PREFILTER_KEYWORDS)
|
||||
|
||||
|
||||
def _fallback_detect(text: str) -> tuple[bool, float, Optional[str]]:
|
||||
|
||||
@@ -17,6 +17,7 @@ from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -307,92 +308,55 @@ async def assign_conversation(
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# POST /api/conversations/{id}/resolve — 结单
|
||||
# POST /api/conversations/{id}/resolve — 坐席发起结单(触发员工确认)
|
||||
# --------------------------------------------------------------------------
|
||||
# 决策 G1:坐席发起→员工确认(员工有权否决)
|
||||
# 流程变更:原直接 resolved → 新流程 pending_close → 员工确认 → resolved
|
||||
class AgentResolveRequest(BaseModel):
|
||||
"""坐席结单请求体。"""
|
||||
resolve_summary: str = Field(..., description="结单摘要(问题类型+根因+解决方式)")
|
||||
|
||||
|
||||
@router.post("/conversations/{conversation_id}/resolve")
|
||||
@require_permission("conversation", "update", "own")
|
||||
async def resolve_conversation(
|
||||
conversation_id: str,
|
||||
body: AgentResolveRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_agent: Agent = Depends(get_current_agent),
|
||||
):
|
||||
"""结单。
|
||||
"""坐席发起结单(触发员工确认流程)。
|
||||
|
||||
坐席点击"结单"按钮时调用,将会话状态改为 resolved。
|
||||
结单完成后异步触发知识建议生成(通道 A 全链路闭环)。
|
||||
改造说明(决策 G1):
|
||||
- 原逻辑:直接将会话状态改为 resolved
|
||||
- 新逻辑:状态改为 pending_close → 推送确认卡片给员工
|
||||
- 员工确认后 → resolved
|
||||
- 员工拒绝 → 恢复 serving
|
||||
- 5分钟超时 → 自动 resolved
|
||||
|
||||
权限控制:只有主责坐席(assigned_agent_id)才能结单。
|
||||
协作坐席和其他坐席不能结单。
|
||||
|
||||
Args:
|
||||
conversation_id: 会话ID
|
||||
body: 结单请求体(含 resolve_summary)
|
||||
db: 数据库会话
|
||||
current_agent: 当前坐席(认证依赖注入)
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含更新后的会话信息
|
||||
Dict: 统一响应格式,包含更新后的会话信息(状态为 pending_close)
|
||||
"""
|
||||
session_service = SessionService(db)
|
||||
from app.services.closing_service import ClosingService
|
||||
|
||||
# 先查询会话,验证主责坐席身份
|
||||
from sqlalchemy import select as _select
|
||||
from app.models.conversation import Conversation as _Conversation
|
||||
stmt = _select(_Conversation).where(_Conversation.id == conversation_id)
|
||||
result = await db.execute(stmt)
|
||||
conv = result.scalars().first()
|
||||
if not conv:
|
||||
raise AppException(3003, "会话不存在")
|
||||
if conv.assigned_agent_id != current_agent.user_id:
|
||||
raise AppException(3027, "只有主责坐席才能结单")
|
||||
|
||||
conversation = await session_service.resolve_conversation(conversation_id)
|
||||
closing_service = ClosingService(db)
|
||||
conversation = await closing_service.agent_initiate_resolve(
|
||||
conversation_id=conversation_id,
|
||||
agent_id=current_agent.user_id,
|
||||
resolve_summary=body.resolve_summary,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
response_data = ConversationResponse.model_validate(conversation).model_dump()
|
||||
|
||||
# ── 任务1(P0):会话关闭→异步触发知识建议生成 ──
|
||||
# 在结单响应返回后,异步调用 Dify 生成知识迭代建议。
|
||||
# 使用 FastAPI BackgroundTasks 确保不阻塞结单响应。
|
||||
try:
|
||||
from fastapi import BackgroundTasks
|
||||
import asyncio as _asyncio
|
||||
|
||||
async def _trigger_knowledge_suggestion():
|
||||
"""异步生成知识建议的后台任务(独立 db session)。"""
|
||||
from app.database import _get_session_factory
|
||||
from app.services.knowledge_iteration_service import KnowledgeIterationService
|
||||
|
||||
factory = _get_session_factory()
|
||||
async with factory() as bg_db:
|
||||
try:
|
||||
knowledge_service = KnowledgeIterationService()
|
||||
suggestion = await knowledge_service.generate_knowledge_suggestion(
|
||||
db=bg_db,
|
||||
source_type="conversation",
|
||||
source_data=[str(conversation_id)],
|
||||
reason=f"会话'{conversation_id}'已结单,自动生成知识迭代建议",
|
||||
)
|
||||
if suggestion:
|
||||
bg_db.add(suggestion)
|
||||
await bg_db.commit()
|
||||
logger.info(
|
||||
f"会话关闭→知识建议已生成: conv_id={conversation_id}, "
|
||||
f"suggestion_id={suggestion.id}, type={suggestion.suggestion_type}"
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f"会话关闭→无知识建议生成(Dify不可用或无需建议): "
|
||||
f"conv_id={conversation_id}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"会话关闭→知识建议生成失败: conv_id={conversation_id}, error={e}")
|
||||
|
||||
# 创建后台任务(不阻塞结单响应)
|
||||
_asyncio.ensure_future(_trigger_knowledge_suggestion())
|
||||
logger.info(f"会话结单完成,已触发异步知识建议生成: conv_id={conversation_id}")
|
||||
except Exception as e:
|
||||
# 知识建议生成失败不影响结单主流程
|
||||
logger.warning(f"触发异步知识建议生成失败(不影响结单): {e}")
|
||||
|
||||
response_data["message"] = "结单请求已发送,等待员工确认(5分钟内未响应将自动关闭)。"
|
||||
return success_response(data=response_data)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 代答排除管理 API
|
||||
# =============================================================================
|
||||
# 说明:代答排除规则的管理后台接口,共 8 个端点。
|
||||
# 路由前缀:/admin/exclusion-rules(router.py 已注册)
|
||||
# 认证:@require_admin
|
||||
#
|
||||
# 1. GET /admin/exclusion-rules — 规则列表(分页+筛选)
|
||||
# 2. POST /admin/exclusion-rules — 新建规则
|
||||
# 3. GET /admin/exclusion-rules/{id} — 规则详情
|
||||
# 4. PUT /admin/exclusion-rules/{id} — 编辑规则
|
||||
# 5. DELETE /admin/exclusion-rules/{id} — 删除规则
|
||||
# 6. POST /admin/exclusion-rules/{id}/toggle — 启用/停用
|
||||
# 7. POST /admin/exclusion-rules/test — 测试匹配
|
||||
# 8. GET /admin/exclusion-rules/stats — 统计概要
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import or_, select, func, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_current_user, require_admin, UserInfo
|
||||
from app.models.exclusion_rule import ExclusionRule
|
||||
from app.schemas.exclusion import (
|
||||
ExclusionRuleCreate,
|
||||
ExclusionRuleUpdate,
|
||||
ExclusionRuleToggle,
|
||||
ExclusionTestRequest,
|
||||
ExclusionRuleResponse,
|
||||
ExclusionTestResponse,
|
||||
ExclusionStatsResponse,
|
||||
)
|
||||
from app.services.exclusion_service import get_exclusion_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 工具函数
|
||||
# =============================================================================
|
||||
|
||||
def _rule_to_response(rule: ExclusionRule) -> dict:
|
||||
"""将 ORM 对象转为响应字典。"""
|
||||
return {
|
||||
"id": rule.id,
|
||||
"rule_name": rule.rule_name,
|
||||
"rule_description": rule.rule_description,
|
||||
"priority": rule.priority,
|
||||
"match_type": rule.match_type,
|
||||
"match_condition": rule.match_condition,
|
||||
"match_scope": rule.match_scope or [],
|
||||
"action_type": rule.action_type,
|
||||
"transfer_message": rule.transfer_message,
|
||||
"status": rule.status,
|
||||
"hit_count": rule.hit_count,
|
||||
"created_by": rule.created_by,
|
||||
"created_at": rule.created_at.isoformat() if rule.created_at else None,
|
||||
"updated_at": rule.updated_at.isoformat() if rule.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 1. 规则列表(分页+筛选)
|
||||
# =============================================================================
|
||||
|
||||
@router.get("")
|
||||
@require_admin
|
||||
async def list_rules(
|
||||
status: Optional[str] = Query(default=None, description="状态筛选:enabled/disabled"),
|
||||
match_type: Optional[str] = Query(default=None, description="匹配方式筛选"),
|
||||
priority: Optional[str] = Query(default=None, description="优先级筛选:P0/P1/P2/P3"),
|
||||
keyword: Optional[str] = Query(default=None, description="关键词搜索(规则名称/描述)"),
|
||||
page: int = Query(default=1, ge=1, description="页码"),
|
||||
page_size: int = Query(default=20, ge=1, le=100, description="每页数量"),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取排除规则列表(分页+筛选)。
|
||||
|
||||
支持按状态、匹配方式、优先级筛选,以及关键词搜索规则名称和描述。
|
||||
"""
|
||||
conditions = []
|
||||
if status:
|
||||
conditions.append(ExclusionRule.status == status)
|
||||
if match_type:
|
||||
conditions.append(ExclusionRule.match_type == match_type)
|
||||
if priority:
|
||||
conditions.append(ExclusionRule.priority == priority)
|
||||
if keyword:
|
||||
conditions.append(
|
||||
or_(
|
||||
ExclusionRule.rule_name.ilike(f"%{keyword}%"),
|
||||
ExclusionRule.rule_description.ilike(f"%{keyword}%"),
|
||||
)
|
||||
)
|
||||
|
||||
# 统计总数
|
||||
count_stmt = select(func.count()).select_from(ExclusionRule)
|
||||
if conditions:
|
||||
count_stmt = count_stmt.where(and_(*conditions))
|
||||
total_result = await db.execute(count_stmt)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
# 分页查询
|
||||
stmt = select(ExclusionRule).order_by(
|
||||
ExclusionRule.priority,
|
||||
ExclusionRule.created_at.desc(),
|
||||
)
|
||||
if conditions:
|
||||
stmt = stmt.where(and_(*conditions))
|
||||
offset = (page - 1) * page_size
|
||||
stmt = stmt.offset(offset).limit(page_size)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
rules = result.scalars().all()
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"total": total,
|
||||
"items": [_rule_to_response(r) for r in rules],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 2. 新建规则
|
||||
# =============================================================================
|
||||
|
||||
@router.post("")
|
||||
@require_admin
|
||||
async def create_rule(
|
||||
body: ExclusionRuleCreate,
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""新建排除规则。"""
|
||||
# 检查规则名称唯一性
|
||||
existing = await db.execute(
|
||||
select(ExclusionRule).where(ExclusionRule.rule_name == body.rule_name)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
return {"code": 400, "message": f"规则名称已存在: {body.rule_name}", "data": None}
|
||||
|
||||
rule = ExclusionRule(
|
||||
rule_name=body.rule_name,
|
||||
rule_description=body.rule_description,
|
||||
priority=body.priority,
|
||||
match_type=body.match_type,
|
||||
match_condition=body.match_condition,
|
||||
match_scope=body.match_scope,
|
||||
action_type=body.action_type,
|
||||
transfer_message=body.transfer_message,
|
||||
status="enabled",
|
||||
hit_count=0,
|
||||
created_by=current_user.employee_id,
|
||||
)
|
||||
db.add(rule)
|
||||
await db.commit()
|
||||
await db.refresh(rule)
|
||||
|
||||
logger.info("排除规则已创建: %s, by=%s", rule.rule_name, current_user.employee_id)
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "规则创建成功",
|
||||
"data": _rule_to_response(rule),
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 3. 测试匹配(必须在 /{rule_id} 之前注册,避免路由冲突)
|
||||
# =============================================================================
|
||||
|
||||
@router.post("/test")
|
||||
@require_admin
|
||||
async def test_match(
|
||||
body: ExclusionTestRequest,
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""测试消息匹配排除规则。
|
||||
|
||||
可指定 rule_id 测试单条规则,不指定则测试所有启用规则。
|
||||
测试不会记录日志、不会更新 hit_count。
|
||||
"""
|
||||
service = get_exclusion_service()
|
||||
result = await service.test_match(
|
||||
db=db,
|
||||
message=body.message,
|
||||
rule_id=body.rule_id,
|
||||
)
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"matched": result.matched,
|
||||
"matched_detail": result.matched_detail if result.matched else None,
|
||||
"rule_name": result.rule_name if result.matched else None,
|
||||
"action_type": result.action_type if result.matched else None,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 4. 统计概要(必须在 /{rule_id} 之前注册,避免路由冲突)
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/stats")
|
||||
@require_admin
|
||||
async def get_stats(
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取排除规则统计概要。"""
|
||||
service = get_exclusion_service()
|
||||
stats = await service.get_stats(db=db)
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": stats,
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 5. 规则详情
|
||||
# 注意:/test 和 /stats 必须在此路由之前注册,否则会被 /{rule_id} 匹配
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/{rule_id}")
|
||||
@require_admin
|
||||
async def get_rule(
|
||||
rule_id: str,
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取规则详情。"""
|
||||
result = await db.execute(
|
||||
select(ExclusionRule).where(ExclusionRule.id == rule_id)
|
||||
)
|
||||
rule = result.scalar_one_or_none()
|
||||
if not rule:
|
||||
return {"code": 404, "message": "规则不存在", "data": None}
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": _rule_to_response(rule),
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 6. 编辑规则
|
||||
# =============================================================================
|
||||
|
||||
@router.put("/{rule_id}")
|
||||
@require_admin
|
||||
async def update_rule(
|
||||
rule_id: str,
|
||||
body: ExclusionRuleUpdate,
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""编辑排除规则。"""
|
||||
result = await db.execute(
|
||||
select(ExclusionRule).where(ExclusionRule.id == rule_id)
|
||||
)
|
||||
rule = result.scalar_one_or_none()
|
||||
if not rule:
|
||||
return {"code": 404, "message": "规则不存在", "data": None}
|
||||
|
||||
# 如果修改了规则名称,检查唯一性
|
||||
if body.rule_name and body.rule_name != rule.rule_name:
|
||||
existing = await db.execute(
|
||||
select(ExclusionRule).where(ExclusionRule.rule_name == body.rule_name)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
return {"code": 400, "message": f"规则名称已存在: {body.rule_name}", "data": None}
|
||||
|
||||
# 更新字段(仅更新传入的字段)
|
||||
update_data = body.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(rule, field, value)
|
||||
|
||||
rule.updated_at = datetime.now()
|
||||
await db.commit()
|
||||
await db.refresh(rule)
|
||||
|
||||
logger.info("排除规则已更新: %s, by=%s", rule.rule_name, current_user.employee_id)
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "规则更新成功",
|
||||
"data": _rule_to_response(rule),
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 7. 删除规则
|
||||
# =============================================================================
|
||||
|
||||
@router.delete("/{rule_id}")
|
||||
@require_admin
|
||||
async def delete_rule(
|
||||
rule_id: str,
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""删除排除规则。"""
|
||||
result = await db.execute(
|
||||
select(ExclusionRule).where(ExclusionRule.id == rule_id)
|
||||
)
|
||||
rule = result.scalar_one_or_none()
|
||||
if not rule:
|
||||
return {"code": 404, "message": "规则不存在", "data": None}
|
||||
|
||||
rule_name = rule.rule_name
|
||||
await db.delete(rule)
|
||||
await db.commit()
|
||||
|
||||
logger.info("排除规则已删除: %s, by=%s", rule_name, current_user.employee_id)
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "删除成功",
|
||||
"data": None,
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 8. 启用/停用
|
||||
# =============================================================================
|
||||
|
||||
@router.post("/{rule_id}/toggle")
|
||||
@require_admin
|
||||
async def toggle_rule(
|
||||
rule_id: str,
|
||||
body: ExclusionRuleToggle,
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""启用/停用排除规则。"""
|
||||
result = await db.execute(
|
||||
select(ExclusionRule).where(ExclusionRule.id == rule_id)
|
||||
)
|
||||
rule = result.scalar_one_or_none()
|
||||
if not rule:
|
||||
return {"code": 404, "message": "规则不存在", "data": None}
|
||||
|
||||
if body.status not in ("enabled", "disabled"):
|
||||
return {"code": 400, "message": "无效状态,仅支持 enabled/disabled", "data": None}
|
||||
|
||||
rule.status = body.status
|
||||
rule.updated_at = datetime.now()
|
||||
await db.commit()
|
||||
await db.refresh(rule)
|
||||
|
||||
logger.info(
|
||||
"排除规则状态切换: %s → %s, by=%s",
|
||||
rule.rule_name, rule.status, current_user.employee_id,
|
||||
)
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": f"规则已{'启用' if rule.status == 'enabled' else '停用'}",
|
||||
"data": _rule_to_response(rule),
|
||||
}
|
||||
|
||||
|
||||
# 注意:/test 和 /stats 路由已在文件上方(/{rule_id} 之前)注册,
|
||||
# 避免 FastAPI 路由匹配将 "test"/"stats" 误认为 rule_id。
|
||||
+288
-21
@@ -67,6 +67,8 @@ 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.utils.response import AppException, ERR_UNAUTHORIZED, success_response
|
||||
from app.services.closing_service import ClosingService
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -921,12 +923,15 @@ async def h5_send_message(
|
||||
# 为什么:AI 推理(Dify)慢(3~15s),放后台经 WS 流式推回,
|
||||
# 发送接口瞬时返回,前端不再卡"发送中"
|
||||
# 约束:后台任务使用独立 DB session,且需单 worker(见 h5_ai_task.py)
|
||||
# v2.1(Phase 4):传递 msg_type 和 media_url,支持图片消息 VisionService 分析
|
||||
asyncio.create_task(
|
||||
process_h5_ai_reply(
|
||||
conversation_id=str(conversation.id),
|
||||
employee_id=employee_id,
|
||||
content=content,
|
||||
dify_conversation_id=conversation.dify_conversation_id,
|
||||
msg_type=msg_type,
|
||||
media_url=media_url,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1130,21 +1135,43 @@ async def shake(
|
||||
"请先描述您的问题,Duckula(达寇拉)需要先帮您分析。至少互动3轮后才能呼叫人工坐席哦~"
|
||||
)
|
||||
|
||||
# ================================================================
|
||||
# 决策 E2/E3:紧急关键词直通检测
|
||||
# ================================================================
|
||||
# 检查最近消息是否包含紧急关键词
|
||||
# 如果命中 → 绕过3轮AI互动限制,直接进入排队/分配(紧急直通)
|
||||
from app.services.triage_service import URGENCY_HIGH_KEYWORDS
|
||||
|
||||
last_msg_summary = (conversation.last_message_summary or "").lower()
|
||||
is_emergency = any(kw in last_msg_summary for kw in URGENCY_HIGH_KEYWORDS)
|
||||
|
||||
# 前置校验:必须满足 AI 实质性回复 >= 3 次才能呼叫坐席
|
||||
if conversation.ai_substantive_reply_count < 3:
|
||||
# 例外:紧急关键词命中时绕过此限制(决策 E2 紧急直通)
|
||||
if not is_emergency and conversation.ai_substantive_reply_count < 3:
|
||||
raise AppException(
|
||||
1003,
|
||||
"请先描述您的问题,Duckula(达寇拉)需要先帮您分析。至少互动3轮后才能呼叫人工坐席哦~"
|
||||
)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 决策 E4:未梳理提醒
|
||||
# ================================================================
|
||||
# 如果信息未锁定(info_locked=False),返回 needs_info_confirm 标记
|
||||
# 前端据此弹窗提示"完成信息梳理可进入快速通道"
|
||||
needs_info_confirm = not conversation.info_locked
|
||||
|
||||
# 更新员工姓名
|
||||
if employee_name and not conversation.employee_name:
|
||||
conversation.employee_name = employee_name
|
||||
# 设置举手标记
|
||||
tags = dict(conversation.tags) if conversation.tags else {}
|
||||
tags["hand_raise"] = True
|
||||
if is_emergency:
|
||||
tags["emergency_direct_connect"] = True # 紧急直通标记
|
||||
conversation.tags = tags
|
||||
conversation.urgency_score = max(conversation.urgency_score, 2)
|
||||
if is_emergency:
|
||||
conversation.urgency_score = max(conversation.urgency_score, 5) # 紧急直通设最高紧急度
|
||||
conversation.last_message_at = datetime.now()
|
||||
conversation.updated_at = datetime.now()
|
||||
db.add(conversation)
|
||||
@@ -1215,12 +1242,15 @@ async def shake(
|
||||
"hand_raise": True,
|
||||
"assigned_agent_id": assigned_agent_id,
|
||||
"assign_result": assign_result,
|
||||
"is_emergency": is_emergency, # 紧急直通标记
|
||||
"info_locked": conversation.info_locked, # 信息梳理状态
|
||||
"queue_priority": conversation.queue_priority, # 答题插队优先级
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f"WebSocket广播失败(不阻塞流程): {e}")
|
||||
|
||||
logger.info(f"举手触发: employee_id={employee_id}, conv_id={conversation.id}, assign_result={assign_result}")
|
||||
logger.info(f"举手触发: employee_id={employee_id}, conv_id={conversation.id}, assign_result={assign_result}, emergency={is_emergency}")
|
||||
|
||||
# 7. 返回会话信息和话术
|
||||
conv_data = ConversationResponse.model_validate(conversation).model_dump()
|
||||
@@ -1230,6 +1260,9 @@ async def shake(
|
||||
"funny_phrase": phrase,
|
||||
"assign_result": assign_result,
|
||||
"assigned_agent_id": assigned_agent_id,
|
||||
"is_emergency": is_emergency, # 紧急直通标记
|
||||
"needs_info_confirm": needs_info_confirm, # 信息未梳理提醒
|
||||
"info_locked": conversation.info_locked, # 当前信息锁定状态
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1367,18 +1400,22 @@ async def get_queue_status(
|
||||
employee_id: str = Query(..., description="员工ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""查询当前排队状态。
|
||||
"""查询当前排队状态(三段排序版)。
|
||||
|
||||
返回当前会话的排队位置和预计等待时间。
|
||||
排队三段排序:
|
||||
1. VIP段(is_vip=true)
|
||||
2. 已梳理段(info_locked=true)
|
||||
3. 待梳理段(info_locked=false)
|
||||
|
||||
段内排序:queue_priority DESC → urgency_score DESC → created_at ASC
|
||||
|
||||
Args:
|
||||
employee_id: 员工ID
|
||||
|
||||
Returns:
|
||||
Dict: 排队状态信息
|
||||
Dict: 排队状态信息(含段位、位置、预估等待时间)
|
||||
"""
|
||||
from sqlalchemy import select, func
|
||||
from app.models.conversation import Conversation
|
||||
from app.services.queue_service import get_queue_service
|
||||
|
||||
# 1. 查找该员工的排队会话
|
||||
stmt = select(Conversation).where(
|
||||
@@ -1390,7 +1427,7 @@ async def get_queue_status(
|
||||
conversation = result.scalars().first()
|
||||
|
||||
if not conversation:
|
||||
# 不在排队中,可能是已分配或无会话
|
||||
# 不在排队中
|
||||
return success_response(data={
|
||||
"in_queue": False,
|
||||
"status": None,
|
||||
@@ -1398,23 +1435,22 @@ async def get_queue_status(
|
||||
"estimated_wait_seconds": 0,
|
||||
})
|
||||
|
||||
# 2. 计算排队位置(按创建时间排序)
|
||||
count_stmt = select(func.count(Conversation.id)).where(
|
||||
Conversation.status == "queued",
|
||||
Conversation.created_at < conversation.created_at,
|
||||
)
|
||||
count_result = await db.execute(count_stmt)
|
||||
queue_position = count_result.scalar() or 0
|
||||
|
||||
# 3. 计算预计等待时间(基于平均处理时长5分钟)
|
||||
estimated_wait_seconds = queue_position * 300 # 5分钟/人
|
||||
# 2. 使用 QueueService 计算三段排序位置
|
||||
queue_service = get_queue_service()
|
||||
queue_info = await queue_service.calculate_queue_position(db, conversation)
|
||||
|
||||
return success_response(data={
|
||||
"in_queue": True,
|
||||
"status": conversation.status,
|
||||
"queue_position": queue_position + 1,
|
||||
"estimated_wait_seconds": estimated_wait_seconds,
|
||||
"conversation_id": str(conversation.id),
|
||||
"queue_position": queue_info["position"],
|
||||
"estimated_wait_seconds": queue_info["estimated_wait_sec"],
|
||||
"segment": queue_info["segment"],
|
||||
"segment_label": queue_info["segment_label"],
|
||||
"ahead_count": queue_info["ahead_count"],
|
||||
"queue_priority": queue_info["queue_priority"],
|
||||
"info_locked": conversation.info_locked,
|
||||
"is_vip": conversation.is_vip,
|
||||
})
|
||||
|
||||
|
||||
@@ -1865,3 +1901,234 @@ async def h5_invite_participant(
|
||||
|
||||
response_data = ConversationResponse.model_validate(conversation).model_dump()
|
||||
return success_response(data=response_data)
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# 关闭机制 API(决策 G1-G5)
|
||||
# ==========================================================================
|
||||
# 五种关闭场景的 H5 端点:
|
||||
# POST /api/h5/conversations/current/resolve — 员工确认AI已解决
|
||||
# POST /api/h5/conversations/current/close — 员工主动关闭
|
||||
# POST /api/h5/conversations/current/resolve/confirm — 员工确认坐席结单
|
||||
# POST /api/h5/conversations/current/resolve/reject — 员工拒绝坐席结单
|
||||
# POST /api/h5/conversations/current/reopen — 24h内重开
|
||||
# ==========================================================================
|
||||
|
||||
|
||||
class SelfResolveRequest(BaseModel):
|
||||
"""员工确认AI已解决请求体。"""
|
||||
resolve_summary: Optional[str] = Field(None, description="解决摘要(可选)")
|
||||
|
||||
|
||||
class EmployeeCloseRequest(BaseModel):
|
||||
"""员工主动关闭请求体。"""
|
||||
close_reason: Optional[str] = Field(None, description="关闭原因(可选)")
|
||||
|
||||
|
||||
class ResolveConfirmRequest(BaseModel):
|
||||
"""员工确认/拒绝坐席结单请求体。"""
|
||||
action: str = Field(..., description="confirm=确认, reject=拒绝")
|
||||
reason: Optional[str] = Field(None, description="拒绝原因(拒绝时可选)")
|
||||
|
||||
|
||||
class ReopenRequest(BaseModel):
|
||||
"""重开会话请求体。"""
|
||||
original_conversation_id: str = Field(..., description="原会话ID")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# POST /api/h5/conversations/current/resolve — 员工确认AI已解决
|
||||
# --------------------------------------------------------------------------
|
||||
@router.post("/h5/conversations/current/resolve")
|
||||
async def h5_self_resolve(
|
||||
body: SelfResolveRequest,
|
||||
employee_id: str = Depends(_get_current_employee),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""员工确认AI已解决问题(AI自助场景)。
|
||||
|
||||
触发场景:
|
||||
- 对话流中"已解决"确认卡片按钮
|
||||
- AI检测到关闭关键词后推送的确认卡片
|
||||
|
||||
状态转换:ai_handling → resolved
|
||||
关闭方:employee / 关闭方式:ai_self
|
||||
|
||||
Args:
|
||||
body: 请求体(可选 resolve_summary)
|
||||
employee_id: 当前登录员工ID
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含已关闭的会话信息
|
||||
"""
|
||||
closing_service = ClosingService(db)
|
||||
conversation = await closing_service.employee_self_resolve(
|
||||
employee_id=employee_id,
|
||||
resolve_summary=body.resolve_summary,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
response_data = ConversationResponse.model_validate(conversation).model_dump()
|
||||
return success_response(data=response_data)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# POST /api/h5/conversations/current/close — 员工主动关闭
|
||||
# --------------------------------------------------------------------------
|
||||
@router.post("/h5/conversations/current/close")
|
||||
async def h5_employee_close(
|
||||
body: EmployeeCloseRequest,
|
||||
employee_id: str = Depends(_get_current_employee),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""员工主动关闭会话。
|
||||
|
||||
适用场景:
|
||||
- 问题自行解决,不需要AI或坐席帮助
|
||||
- 不想继续等待
|
||||
- 问题已通过其他渠道解决
|
||||
|
||||
状态转换:任意活跃状态 → resolved
|
||||
关闭方:employee / 关闭方式:employee_initiative
|
||||
|
||||
Args:
|
||||
body: 请求体(可选 close_reason)
|
||||
employee_id: 当前登录员工ID
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含已关闭的会话信息
|
||||
"""
|
||||
closing_service = ClosingService(db)
|
||||
conversation = await closing_service.employee_initiative_close(
|
||||
employee_id=employee_id,
|
||||
close_reason=body.close_reason,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
response_data = ConversationResponse.model_validate(conversation).model_dump()
|
||||
return success_response(data=response_data)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# POST /api/h5/conversations/current/resolve/confirm — 员工确认/拒绝坐席结单
|
||||
# --------------------------------------------------------------------------
|
||||
@router.post("/h5/conversations/current/resolve/confirm")
|
||||
async def h5_resolve_confirm(
|
||||
body: ResolveConfirmRequest,
|
||||
employee_id: str = Depends(_get_current_employee),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""员工确认或拒绝坐席的结单请求。
|
||||
|
||||
坐席发起结单后,会话进入 pending_close 状态,
|
||||
员工通过此端点确认或拒绝。
|
||||
|
||||
- confirm: pending_close → resolved(坐席结单+员工确认)
|
||||
- reject: pending_close → serving(恢复服务)
|
||||
- 5分钟内不响应:系统自动关闭
|
||||
|
||||
Args:
|
||||
body: 请求体(action=confirm/reject, reason=拒绝原因)
|
||||
employee_id: 当前登录员工ID
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含更新后的会话信息
|
||||
"""
|
||||
closing_service = ClosingService(db)
|
||||
|
||||
if body.action == "confirm":
|
||||
conversation = await closing_service.employee_confirm_resolve(employee_id)
|
||||
message = "结单确认成功,会话已关闭。"
|
||||
elif body.action == "reject":
|
||||
conversation = await closing_service.employee_reject_resolve(
|
||||
employee_id, reason=body.reason
|
||||
)
|
||||
message = "已为您恢复服务,坐席将继续处理。"
|
||||
else:
|
||||
raise AppException(1008, f"无效的action: {body.action},应为 confirm 或 reject")
|
||||
|
||||
await db.commit()
|
||||
|
||||
response_data = ConversationResponse.model_validate(conversation).model_dump()
|
||||
response_data["message"] = message
|
||||
return success_response(data=response_data)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# POST /api/h5/conversations/current/reopen — 24h内重开已关闭会话
|
||||
# --------------------------------------------------------------------------
|
||||
@router.post("/h5/conversations/current/reopen")
|
||||
async def h5_reopen(
|
||||
body: ReopenRequest,
|
||||
employee_id: str = Depends(_get_current_employee),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""24小时内重开已关闭的会话。
|
||||
|
||||
创建新会话并关联原会话ID,用于上下文继承。
|
||||
新会话状态为 ai_handling,复用原会话的员工信息。
|
||||
|
||||
限制条件:
|
||||
- 原会话必须已关闭(status=resolved)
|
||||
- 距离关闭不超过24小时
|
||||
- 重开后新会话关联原会话的 reference_conversation_id
|
||||
|
||||
Args:
|
||||
body: 请求体(original_conversation_id)
|
||||
employee_id: 当前登录员工ID
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含新创建的会话信息
|
||||
"""
|
||||
closing_service = ClosingService(db)
|
||||
new_conversation = await closing_service.reopen_conversation(
|
||||
employee_id=employee_id,
|
||||
original_conversation_id=body.original_conversation_id,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
response_data = ConversationResponse.model_validate(new_conversation).model_dump()
|
||||
response_data["is_reopen"] = True
|
||||
response_data["message"] = "问题已重新接入,请描述您遇到的情况。"
|
||||
return success_response(data=response_data)
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# GET /api/h5/it-health — IT 健康信息
|
||||
# ==========================================================================
|
||||
# 说明:返回当前登录员工终端的 IT 健康信息,包括设备基本信息、
|
||||
# CPU/内存/磁盘使用率、安全检查状态、合规检查状态。
|
||||
# 数据来源:联软(设备信息) + 火绒(安全状态) + 资产服务(资产编号)
|
||||
# 降级策略:联软/火绒未配置时返回 Mock 数据
|
||||
# ==========================================================================
|
||||
|
||||
@router.get("/h5/it-health")
|
||||
async def h5_get_it_health(
|
||||
employee_id: str = Depends(_get_current_employee),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取当前员工终端的 IT 健康信息。
|
||||
|
||||
从联软、火绒、资产服务聚合数据,返回设备信息和安全状态。
|
||||
如果联软/火绒集成未配置,返回 Mock 降级数据。
|
||||
|
||||
Args:
|
||||
employee_id: 员工企微 UserID(通过认证依赖注入)
|
||||
db: 数据库会话(读取集成配置)
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含:
|
||||
- current_device: 当前设备信息(设备名/IP/MAC/OS/CPU/内存/磁盘/安全检查)
|
||||
- other_devices: 其他设备列表
|
||||
- data_source: "real"(真实数据)或 "mock"(降级数据)
|
||||
- generated_at: 生成时间
|
||||
"""
|
||||
from app.services.it_health_service import ITHealthService
|
||||
|
||||
service = ITHealthService(db)
|
||||
result = await service.get_it_health(employee_id)
|
||||
return success_response(data=result)
|
||||
|
||||
@@ -11,12 +11,19 @@
|
||||
# DELETE /itportal/meetingroom/booking/{booking_id} — 取消预定
|
||||
# GET /itportal/meetingroom/booking/{booking_id}/detail — 预定详情
|
||||
# GET /itportal/meetingroom/terminal/{terminal_sn}/binding — 终端绑定查询
|
||||
# POST /itportal/meetingroom/repair — 提交设备报修
|
||||
# GET /itportal/meetingroom/guides — 操作指南列表
|
||||
# GET /itportal/meetingroom/guides/{category} — 按类型获取指南
|
||||
# GET /itportal/meetingroom/terminal/{terminal_sn}/qrcode — 终端访问二维码
|
||||
# =============================================================================
|
||||
|
||||
import io
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import qrcode
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -30,8 +37,11 @@ from app.schemas.meetingroom import (
|
||||
BookingDetailResponse,
|
||||
BookingInfoResponse,
|
||||
BookingItem,
|
||||
GuideItem,
|
||||
MeetingroomItem,
|
||||
MeetingroomListResponse,
|
||||
RepairRequest,
|
||||
RepairResponse,
|
||||
RoomStatusResponse,
|
||||
TerminalBindingResponse,
|
||||
)
|
||||
@@ -429,3 +439,200 @@ async def get_terminal_binding(
|
||||
except Exception as e:
|
||||
logger.error(f"查询终端绑定异常: sn={terminal_sn}, error={e}", exc_info=True)
|
||||
raise AppException(1005, f"查询终端绑定失败: {str(e)}")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# POST /itportal/meetingroom/repair — 提交报修
|
||||
# =============================================================================
|
||||
@router.post("/repair", response_model=None)
|
||||
async def submit_repair(
|
||||
body: RepairRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""提交会议室设备报修。
|
||||
|
||||
无需认证(终端公共设备,支持匿名报修)。
|
||||
报修后自动创建IT工单会话 + 企微消息通知管理员 + WS通知坐席。
|
||||
|
||||
Args:
|
||||
body: 报修请求参数
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,data 含 repair_id 和 conversation_id
|
||||
"""
|
||||
try:
|
||||
from app.services.repair_service import RepairService
|
||||
|
||||
wecom_service = WecomService(settings.create_redis_client())
|
||||
service = RepairService(db, wecom_service)
|
||||
|
||||
result = await service.submit_repair(
|
||||
terminal_sn=body.terminal_sn,
|
||||
meetingroom_id=body.meetingroom_id,
|
||||
meetingroom_name=body.meetingroom_name,
|
||||
device_type=body.device_type,
|
||||
fault_description=body.fault_description,
|
||||
reporter_name=body.reporter_name,
|
||||
reporter_userid=body.reporter_userid,
|
||||
)
|
||||
|
||||
return success_response(data=RepairResponse(
|
||||
repair_id=result["repair_id"],
|
||||
conversation_id=result["conversation_id"],
|
||||
status=0,
|
||||
).model_dump())
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"提交报修异常: {e}", exc_info=True)
|
||||
raise AppException(3100, f"提交报修失败: {str(e)}")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# GET /itportal/meetingroom/guides — 获取操作指南列表
|
||||
# =============================================================================
|
||||
@router.get("/guides", response_model=None)
|
||||
async def get_guides(
|
||||
category: Optional[str] = Query(None, description="设备类型过滤"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取操作指南列表。
|
||||
|
||||
无需认证(终端公共设备,访客可查看)。
|
||||
|
||||
Args:
|
||||
category: 设备类型过滤(可选):projector/video_conf/aircon/phone/other
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,data 含 guides 列表
|
||||
"""
|
||||
try:
|
||||
guides = await MeetingroomService.get_guides(db, category)
|
||||
|
||||
guide_items = [
|
||||
GuideItem(
|
||||
id=g["id"],
|
||||
category=g["category"],
|
||||
title=g["title"],
|
||||
brief=g["brief"],
|
||||
detail_url=g["detail_url"],
|
||||
icon=g["icon"],
|
||||
).model_dump()
|
||||
for g in guides
|
||||
]
|
||||
|
||||
return success_response(data={"guides": guide_items})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取操作指南异常: {e}", exc_info=True)
|
||||
raise AppException(3101, f"获取操作指南失败: {str(e)}")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# GET /itportal/meetingroom/guides/{category} — 按设备类型获取指南
|
||||
# =============================================================================
|
||||
@router.get("/guides/{category}", response_model=None)
|
||||
async def get_guides_by_category(
|
||||
category: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""按设备类型获取操作指南。
|
||||
|
||||
无需认证。
|
||||
|
||||
Args:
|
||||
category: 设备类型
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,data 含 guides 列表
|
||||
"""
|
||||
try:
|
||||
guides = await MeetingroomService.get_guides(db, category)
|
||||
|
||||
guide_items = [
|
||||
GuideItem(
|
||||
id=g["id"],
|
||||
category=g["category"],
|
||||
title=g["title"],
|
||||
brief=g["brief"],
|
||||
detail_url=g["detail_url"],
|
||||
icon=g["icon"],
|
||||
).model_dump()
|
||||
for g in guides
|
||||
]
|
||||
|
||||
return success_response(data={"guides": guide_items})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"按类型获取操作指南异常: category={category}, error={e}", exc_info=True)
|
||||
raise AppException(3101, f"获取操作指南失败: {str(e)}")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# GET /itportal/meetingroom/terminal/{terminal_sn}/qrcode — 终端访问二维码
|
||||
# =============================================================================
|
||||
# 用于 NE2005 等不支持 H5 应用的老型号终端:
|
||||
# 管理员打印此二维码贴在终端上,用户用手机扫码访问终端页面
|
||||
# =============================================================================
|
||||
|
||||
# 终端页面基础 URL(从 settings 读取,默认使用生产域名)
|
||||
_TERMINAL_BASE_URL = getattr(settings, 'terminal_base_url', None) or 'https://itsupport.servyou.com.cn/itterminal/'
|
||||
|
||||
|
||||
@router.get("/terminal/{terminal_sn}/qrcode")
|
||||
async def get_terminal_qrcode(
|
||||
terminal_sn: str,
|
||||
size: int = Query(300, ge=100, le=800, description="二维码图片尺寸(像素)"),
|
||||
):
|
||||
"""生成终端访问二维码(PNG 图片)。
|
||||
|
||||
用于 NE2005 等不支持 H5 应用的老型号终端:
|
||||
- 管理员可调用此接口获取二维码图片,打印后贴在终端上
|
||||
- 用户用企业微信/微信扫码后在手机上打开终端页面
|
||||
|
||||
无需认证(二维码内容为公开的终端页面 URL)。
|
||||
|
||||
Args:
|
||||
terminal_sn: 终端序列号
|
||||
size: 二维码图片尺寸(像素),默认 300,范围 100-800
|
||||
|
||||
Returns:
|
||||
StreamingResponse: PNG 图片,Content-Type: image/png
|
||||
"""
|
||||
try:
|
||||
# 构建终端页面 URL
|
||||
url = f"{_TERMINAL_BASE_URL}{terminal_sn}/"
|
||||
|
||||
# 生成二维码
|
||||
qr = qrcode.QRCode(
|
||||
version=None, # 自动选择版本
|
||||
error_correction=qrcode.constants.ERROR_CORRECT_M, # 中等容错
|
||||
box_size=10,
|
||||
border=2,
|
||||
)
|
||||
qr.add_data(url)
|
||||
qr.make(fit=True)
|
||||
|
||||
img = qr.make_image(fill_color="black", back_color="white")
|
||||
|
||||
# 转为 PNG 字节流
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
buf.seek(0)
|
||||
|
||||
logger.info(f"生成终端二维码: sn={terminal_sn}, url={url}, size={size}")
|
||||
|
||||
return StreamingResponse(
|
||||
buf,
|
||||
media_type="image/png",
|
||||
headers={
|
||||
"Cache-Control": "public, max-age=3600", # 缓存 1 小时
|
||||
"X-Terminal-URL": url, # 调试用:响应头返回 URL
|
||||
},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"生成终端二维码异常: sn={terminal_sn}, error={e}", exc_info=True)
|
||||
raise AppException(3102, f"生成二维码失败: {str(e)}")
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 排队综合查询 API
|
||||
# =============================================================================
|
||||
# 说明:提供排队位置、平台统计、坐席看板等综合查询接口
|
||||
#
|
||||
# 路由:
|
||||
# GET /api/h5/queue/status — 员工端综合排队状态
|
||||
# GET /api/agent/queue/dashboard — 坐席端排队看板数据
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.conversation import Conversation
|
||||
from app.services.queue_service import get_queue_service
|
||||
from app.utils.response import AppException, success_response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["queue"])
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# H5 端:综合排队状态
|
||||
# =============================================================================
|
||||
@router.get("/h5/queue/status")
|
||||
async def get_queue_status(
|
||||
employee_id: str = Query(..., description="员工ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取综合排队状态(排队位置+段位+平台统计+答题状态+积分)。
|
||||
|
||||
供 H5 端 QueueWaiting.vue 组件初始化时调用。
|
||||
包含三段排序的排队位置、平台实时统计、答题插队进度、员工积分等级。
|
||||
|
||||
Args:
|
||||
employee_id: 员工企微 UserID
|
||||
|
||||
Returns:
|
||||
综合状态数据
|
||||
"""
|
||||
queue_service = get_queue_service()
|
||||
|
||||
# 查找员工当前活跃会话(排队中或服务中)
|
||||
stmt = select(Conversation).where(
|
||||
Conversation.employee_id == employee_id,
|
||||
Conversation.status.in_(["ai_handling", "queued", "serving", "pending_close"]),
|
||||
).order_by(Conversation.created_at.desc())
|
||||
|
||||
result = await db.execute(stmt)
|
||||
conversation = result.scalars().first()
|
||||
|
||||
if not conversation:
|
||||
# 无活跃会话 — 返回平台统计+默认积分
|
||||
platform_stats = await queue_service.get_platform_stats(db)
|
||||
points_info = await queue_service._get_employee_points(db, employee_id)
|
||||
return success_response(data={
|
||||
"conversation_status": None,
|
||||
"queue": {
|
||||
"position": 0,
|
||||
"segment": "none",
|
||||
"segment_label": "无活跃会话",
|
||||
"ahead_count": 0,
|
||||
"estimated_wait_sec": 0,
|
||||
"estimated_wait_text": "—",
|
||||
"queue_priority": 0,
|
||||
},
|
||||
"platform": platform_stats,
|
||||
"points": points_info,
|
||||
"quiz": {
|
||||
"answered_in_session": 0,
|
||||
"queue_priority": 0,
|
||||
"max_priority": 2,
|
||||
"remaining_for_next_jump": 0,
|
||||
"can_jump_more": False,
|
||||
},
|
||||
"info_locked": False,
|
||||
})
|
||||
|
||||
# 有活跃会话 — 返回综合状态
|
||||
status = await queue_service.get_comprehensive_status(db, conversation)
|
||||
return success_response(data=status)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 坐席端:排队看板
|
||||
# =============================================================================
|
||||
@router.get("/agent/queue/dashboard")
|
||||
async def get_agent_queue_dashboard(
|
||||
authorization: Optional[str] = Header(None, alias="Authorization"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取坐席端排队看板数据。
|
||||
|
||||
返回排队分段统计、平台统计、按三段排序的排队列表。
|
||||
供坐席端 ConversationList.vue 的"排队等候"区段展示。
|
||||
|
||||
Returns:
|
||||
看板数据(分段统计+排队列表)
|
||||
"""
|
||||
queue_service = get_queue_service()
|
||||
dashboard = await queue_service.get_agent_dashboard(db)
|
||||
return success_response(data=dashboard)
|
||||
@@ -0,0 +1,141 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 答题系统 API
|
||||
# =============================================================================
|
||||
# 说明:排队等待期间的答题+积分API
|
||||
#
|
||||
# 路由:
|
||||
# GET /api/h5/quiz/question — 获取下一道题(双模式自动选择)
|
||||
# POST /api/h5/quiz/answer — 提交答案(正误+积分+插队+下一题)
|
||||
# GET /api/h5/quiz/history — 答题历史记录和积分
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.conversation import Conversation
|
||||
from app.services.quiz_service import get_quiz_service
|
||||
from app.utils.response import AppException, success_response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["quiz"])
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 请求体定义
|
||||
# =============================================================================
|
||||
|
||||
class AnswerRequest(BaseModel):
|
||||
"""提交答案请求体。"""
|
||||
employee_id: str
|
||||
question_id: str
|
||||
selected_index: int
|
||||
conversation_id: Optional[str] = None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# GET /api/h5/quiz/question — 获取下一道题
|
||||
# =============================================================================
|
||||
@router.get("/h5/quiz/question")
|
||||
async def get_quiz_question(
|
||||
employee_id: str = Query(..., description="员工ID"),
|
||||
conversation_id: Optional[str] = Query(None, description="当前会话ID(排队时传入)"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取下一道题(双模式自动选择)。
|
||||
|
||||
模式选择:
|
||||
- 排队中且 info_locked=false → 诊断题(答案附加到会话上下文)
|
||||
- 排队中且 info_locked=true → IT知识题
|
||||
- 非排队 → IT知识题
|
||||
|
||||
Args:
|
||||
employee_id: 员工ID
|
||||
conversation_id: 当前会话ID(可选)
|
||||
|
||||
Returns:
|
||||
题目数据
|
||||
"""
|
||||
quiz_service = get_quiz_service()
|
||||
|
||||
# 查找当前会话
|
||||
conversation = None
|
||||
if conversation_id:
|
||||
result = await db.execute(
|
||||
select(Conversation).where(Conversation.id == conversation_id)
|
||||
)
|
||||
conversation = result.scalar_one_or_none()
|
||||
|
||||
question = await quiz_service.get_next_question(db, employee_id, conversation)
|
||||
return success_response(data=question)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# POST /api/h5/quiz/answer — 提交答案
|
||||
# =============================================================================
|
||||
@router.post("/h5/quiz/answer")
|
||||
async def submit_quiz_answer(
|
||||
body: AnswerRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""提交答案,返回正误+积分变化+插队效果+下一题。
|
||||
|
||||
处理流程:
|
||||
1. 判定正误
|
||||
2. 记录答题
|
||||
3. 更新积分(答对+10分,跨会话累积)
|
||||
4. 更新 queue_priority(每答3题前移1位,上限2)
|
||||
5. 如果是诊断题且答对,答案附加到会话上下文
|
||||
6. 返回下一道题
|
||||
|
||||
Args:
|
||||
body: 答案请求体
|
||||
|
||||
Returns:
|
||||
答题结果+下一题
|
||||
"""
|
||||
quiz_service = get_quiz_service()
|
||||
|
||||
# 查找当前会话
|
||||
conversation = None
|
||||
if body.conversation_id:
|
||||
result = await db.execute(
|
||||
select(Conversation).where(Conversation.id == body.conversation_id)
|
||||
)
|
||||
conversation = result.scalar_one_or_none()
|
||||
|
||||
result = await quiz_service.submit_answer(
|
||||
db, body.employee_id, body.question_id, body.selected_index, conversation
|
||||
)
|
||||
return success_response(data=result)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# GET /api/h5/quiz/history — 答题历史记录和积分
|
||||
# =============================================================================
|
||||
@router.get("/h5/quiz/history")
|
||||
async def get_quiz_history(
|
||||
employee_id: str = Query(..., description="员工ID"),
|
||||
page: int = Query(1, ge=1, description="页码"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取答题历史记录和积分信息。
|
||||
|
||||
Args:
|
||||
employee_id: 员工ID
|
||||
page: 页码
|
||||
page_size: 每页数量
|
||||
|
||||
Returns:
|
||||
答题历史+积分信息
|
||||
"""
|
||||
quiz_service = get_quiz_service()
|
||||
history = await quiz_service.get_quiz_history(db, employee_id, page, page_size)
|
||||
return success_response(data=history)
|
||||
@@ -0,0 +1,261 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 测验题目管理 API(管理员)
|
||||
# =============================================================================
|
||||
# 说明:管理后台的测验题目审批 API,统一 /api/admin/quiz 前缀。
|
||||
# 包含 4 个端点:
|
||||
# 1. POST /generate — 手动触发 Dify 生成题目
|
||||
# 2. GET /pending — 查看待审核题目列表(分页)
|
||||
# 3. POST /{id}/approve — 审批通过题目(is_active → True)
|
||||
# 4. DELETE /{id} — 删除质量差的题目
|
||||
#
|
||||
# 权限:所有端点需要管理员权限(Depends(require_admin))
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select, func
|
||||
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.quiz import QuizQuestion
|
||||
from app.services.quiz_generation_service import get_quiz_generation_service
|
||||
from app.utils.response import AppException, success_response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/admin/quiz", tags=["测验题目管理"])
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# 权限校验依赖(复用 admin_api.py 的模式)
|
||||
# ==========================================================================
|
||||
|
||||
async def require_admin(
|
||||
agent: Agent = Depends(get_current_agent),
|
||||
) -> Agent:
|
||||
"""管理员权限校验:仅 role='admin' 可访问。"""
|
||||
if agent.role != "admin":
|
||||
raise AppException(1004, "无管理权限")
|
||||
return agent
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# 请求体定义
|
||||
# ==========================================================================
|
||||
|
||||
class GenerateRequest(BaseModel):
|
||||
"""手动触发生成题目请求。"""
|
||||
category: str # network/vpn/email/system/printer/security/office
|
||||
question_type: str = "knowledge" # knowledge / diagnostic
|
||||
count: int = 5
|
||||
problem_category: Optional[str] = None # 仅 question_type=diagnostic 时使用
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# 1. POST /api/admin/quiz/generate — 手动触发 AI 生成题目
|
||||
# ==========================================================================
|
||||
|
||||
@router.post("/generate")
|
||||
async def generate_quiz_questions(
|
||||
body: GenerateRequest,
|
||||
admin: Agent = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""手动触发 AI 生成题目。
|
||||
|
||||
生成的题目 is_active=False,需通过 /approve 端点审批后激活。
|
||||
|
||||
Args:
|
||||
body: 生成请求(category, question_type, count, problem_category)
|
||||
admin: 管理员(权限校验)
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
生成结果摘要(成功/失败数量 + 题目列表)
|
||||
"""
|
||||
service = get_quiz_generation_service()
|
||||
|
||||
if body.question_type == "knowledge":
|
||||
result = await service.generate_knowledge_questions_batch(
|
||||
db=db,
|
||||
category=body.category,
|
||||
count=body.count,
|
||||
is_active=False, # 手动生成也需审批
|
||||
)
|
||||
elif body.question_type == "diagnostic":
|
||||
if not body.problem_category:
|
||||
raise AppException(1004, "diagnostic 类型必须提供 problem_category")
|
||||
result = await service.generate_diagnostic_questions_batch(
|
||||
db=db,
|
||||
problem_category=body.problem_category,
|
||||
count=body.count,
|
||||
is_active=False,
|
||||
)
|
||||
else:
|
||||
raise AppException(1004, f"不支持的题目类型: {body.question_type}")
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
f"管理员 {admin.name} 手动生成题目: "
|
||||
f"type={body.question_type}, category={body.category}, "
|
||||
f"成功={result['success_count']}"
|
||||
)
|
||||
|
||||
return success_response(data=result)
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# 2. GET /api/admin/quiz/pending — 查看待审核题目列表
|
||||
# ==========================================================================
|
||||
|
||||
@router.get("/pending")
|
||||
async def list_pending_questions(
|
||||
category: Optional[str] = Query(None, description="按类别筛选"),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
admin: Agent = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取待审核题目列表(is_active=False)。
|
||||
|
||||
Args:
|
||||
category: 可选,按类别筛选
|
||||
page: 页码(从 1 开始)
|
||||
page_size: 每页数量(1-100)
|
||||
admin: 管理员(权限校验)
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
分页列表 {total, page, page_size, items}
|
||||
"""
|
||||
# 构建查询条件
|
||||
conditions = [QuizQuestion.is_active == False] # noqa: E712
|
||||
if category:
|
||||
conditions.append(QuizQuestion.category == category)
|
||||
|
||||
# 总数
|
||||
total = await db.scalar(
|
||||
select(func.count(QuizQuestion.id)).where(*conditions)
|
||||
)
|
||||
total = total or 0
|
||||
|
||||
# 分页查询
|
||||
offset = (page - 1) * page_size
|
||||
stmt = (
|
||||
select(QuizQuestion)
|
||||
.where(*conditions)
|
||||
.order_by(QuizQuestion.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(page_size)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
questions = result.scalars().all()
|
||||
|
||||
items = [_question_to_dict(q) for q in questions]
|
||||
|
||||
return success_response(data={
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"items": items,
|
||||
})
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# 3. POST /api/admin/quiz/{question_id}/approve — 审批通过题目
|
||||
# ==========================================================================
|
||||
|
||||
@router.post("/{question_id}/approve")
|
||||
async def approve_question(
|
||||
question_id: str,
|
||||
admin: Agent = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""审批通过一道待审核题目(is_active: False → True)。
|
||||
|
||||
Args:
|
||||
question_id: 题目ID
|
||||
admin: 管理员(权限校验)
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
更新后的题目信息
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(QuizQuestion).where(QuizQuestion.id == question_id)
|
||||
)
|
||||
question = result.scalar_one_or_none()
|
||||
if not question:
|
||||
raise AppException(1004, "题目不存在")
|
||||
|
||||
if question.is_active:
|
||||
raise AppException(1004, "题目已激活,无需重复审批")
|
||||
|
||||
question.is_active = True
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"管理员 {admin.name} 审批通过题目: {question_id}")
|
||||
|
||||
return success_response(data=_question_to_dict(question))
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# 4. DELETE /api/admin/quiz/{question_id} — 删除题目
|
||||
# ==========================================================================
|
||||
|
||||
@router.delete("/{question_id}")
|
||||
async def delete_question(
|
||||
question_id: str,
|
||||
admin: Agent = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""删除一道题目(用于清理质量差的 AI 生成题)。
|
||||
|
||||
Args:
|
||||
question_id: 题目ID
|
||||
admin: 管理员(权限校验)
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
删除确认
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(QuizQuestion).where(QuizQuestion.id == question_id)
|
||||
)
|
||||
question = result.scalar_one_or_none()
|
||||
if not question:
|
||||
raise AppException(1004, "题目不存在")
|
||||
|
||||
await db.delete(question)
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"管理员 {admin.name} 删除题目: {question_id}")
|
||||
|
||||
return success_response(data={"deleted_id": question_id})
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# 辅助函数
|
||||
# ==========================================================================
|
||||
|
||||
def _question_to_dict(q: QuizQuestion) -> Dict[str, Any]:
|
||||
"""将 QuizQuestion 对象转为字典。"""
|
||||
return {
|
||||
"id": q.id,
|
||||
"type": q.type,
|
||||
"category": q.category,
|
||||
"problem_category": q.problem_category,
|
||||
"difficulty": q.difficulty,
|
||||
"question": q.question,
|
||||
"options": q.options,
|
||||
"correct_index": q.correct_index,
|
||||
"explanation": q.explanation,
|
||||
"is_active": q.is_active,
|
||||
"created_at": q.created_at.isoformat() if q.created_at else None,
|
||||
}
|
||||
@@ -398,3 +398,39 @@ api_router.include_router(meetingroom_router, tags=["会议室预定"])
|
||||
# DELETE /itportal/admin/terminal-bindings/{id} — 删除绑定
|
||||
from app.api.admin.terminal_binding import router as terminal_binding_router
|
||||
api_router.include_router(terminal_binding_router, tags=["终端绑定管理"])
|
||||
|
||||
# 知识库迭代 — 分诊交互 + 代答排除 API
|
||||
# H5 端分诊交互:POST /api/h5/triage/start, /step, /skip, /transfer, /complete
|
||||
# 坐席端分诊看板:GET /api/agent/triage/pending, /stats, /{id}, /history, /export,
|
||||
# POST /api/agent/triage/{id}/route, /{id}/exclude-options
|
||||
try:
|
||||
from app.api.triage import router as triage_router
|
||||
api_router.include_router(triage_router, tags=["分诊交互"])
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# 管理后台代答排除规则:CRUD + toggle + test + stats
|
||||
try:
|
||||
from app.api.exclusion_rules import router as exclusion_rules_router
|
||||
api_router.include_router(exclusion_rules_router, prefix="/admin/exclusion-rules", tags=["代答排除"])
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# 分层排队 + 答题系统 API
|
||||
# GET /api/h5/queue/status — 员工端综合排队状态(位置+段位+统计+答题+积分)
|
||||
# GET /api/agent/queue/dashboard — 坐席端排队看板(分段统计+排队列表)
|
||||
# GET /api/h5/quiz/question — 获取下一道题目(双模式:诊断题/知识题)
|
||||
# POST /api/h5/quiz/answer — 提交答案(判正误+积分+插队+下一题)
|
||||
# GET /api/h5/quiz/history — 答题历史(分页+积分信息)
|
||||
from app.api.queue import router as queue_router
|
||||
from app.api.quiz import router as quiz_router
|
||||
api_router.include_router(queue_router, tags=["分层排队"])
|
||||
api_router.include_router(quiz_router, tags=["答题系统"])
|
||||
|
||||
# 测验题目管理 API(管理员)
|
||||
# POST /api/admin/quiz/generate — 手动触发 Dify 生成题目
|
||||
# GET /api/admin/quiz/pending — 查看待审核题目列表(分页)
|
||||
# POST /api/admin/quiz/{id}/approve — 审批通过题目
|
||||
# DELETE /api/admin/quiz/{id} — 删除题目
|
||||
from app.api.quiz_admin import router as quiz_admin_router
|
||||
api_router.include_router(quiz_admin_router, tags=["测验题目管理"])
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 分诊交互 API
|
||||
# =============================================================================
|
||||
# 说明:分诊交互相关接口,包括:
|
||||
# H5 端(5个):
|
||||
# POST /api/h5/triage/start — 发起分诊
|
||||
# POST /api/h5/triage/step — 提交步骤选择
|
||||
# POST /api/h5/triage/skip — 跳过步骤
|
||||
# POST /api/h5/triage/transfer — 转人工
|
||||
# POST /api/h5/triage/complete — 分诊完成
|
||||
# 坐席端(7个):
|
||||
# GET /api/agent/triage/pending — 待分诊列表
|
||||
# GET /api/agent/triage/stats — 统计概要
|
||||
# GET /api/agent/triage/history — 历史列表
|
||||
# GET /api/agent/triage/export — 导出 xlsx
|
||||
# GET /api/agent/triage/{triage_id} — 分诊详情
|
||||
# POST /api/agent/triage/{triage_id}/route — 路由操作
|
||||
# POST /api/agent/triage/{triage_id}/exclude-options — 排除选项
|
||||
#
|
||||
# 注意:固定路径路由(/history, /export)必须在参数路由(/{triage_id})之前注册,
|
||||
# 否则 FastAPI 会将 "history"/"export" 误匹配为 triage_id。
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_current_user, UserInfo
|
||||
from app.schemas.triage import (
|
||||
TriageStartRequest,
|
||||
TriageStepRequest,
|
||||
TriageSkipRequest,
|
||||
TriageTransferRequest,
|
||||
TriageCompleteRequest,
|
||||
TriageRouteRequest,
|
||||
TriageExcludeOptionsRequest,
|
||||
)
|
||||
from app.services.triage_service import get_triage_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# 坐席端认证依赖(延迟导入避免循环依赖)
|
||||
def _get_current_agent():
|
||||
from app.api.agents import get_current_agent
|
||||
return get_current_agent
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# H5 端接口(5个)
|
||||
# =============================================================================
|
||||
|
||||
@router.post("/h5/triage/start")
|
||||
async def start_triage(
|
||||
body: TriageStartRequest,
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""发起分诊。
|
||||
|
||||
员工在 H5 端发送问题后,调用此接口发起 AI 分诊。
|
||||
后端创建分诊会话,调用 Dify 分诊应用分析问题并生成分步选择题。
|
||||
5秒超时自动转人工。
|
||||
|
||||
- **conversation_id**: 会话ID
|
||||
- **question**: 员工问题文本
|
||||
"""
|
||||
service = get_triage_service()
|
||||
result = await service.start_triage(
|
||||
db=db,
|
||||
conversation_id=body.conversation_id,
|
||||
question=body.question,
|
||||
user_id=current_user.employee_id,
|
||||
user_name=current_user.name,
|
||||
user_dept=current_user.department,
|
||||
)
|
||||
|
||||
if result.get("status") == "timeout":
|
||||
return {
|
||||
"code": 0,
|
||||
"message": result.get("message", "分诊超时,已自动转人工"),
|
||||
"data": result,
|
||||
}
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": result,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/h5/triage/step")
|
||||
async def submit_step(
|
||||
body: TriageStepRequest,
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""提交步骤选择。
|
||||
|
||||
员工选择某个选项后,提交到后端记录上下文并获取下一步骤。
|
||||
|
||||
- **triage_id**: 分诊会话ID
|
||||
- **step_index**: 当前步骤序号(0-based)
|
||||
- **selected_label**: 选择的选项标签
|
||||
"""
|
||||
service = get_triage_service()
|
||||
result = await service.submit_step(
|
||||
db=db,
|
||||
triage_id=body.triage_id,
|
||||
step_index=body.step_index,
|
||||
selected_label=body.selected_label,
|
||||
)
|
||||
|
||||
if "error" in result:
|
||||
return {"code": 404, "message": result["error"], "data": None}
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": result,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/h5/triage/skip")
|
||||
async def skip_step(
|
||||
body: TriageSkipRequest,
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""跳过步骤。
|
||||
|
||||
员工跳过当前步骤,直接进入下一步。
|
||||
|
||||
- **triage_id**: 分诊会话ID
|
||||
- **step_index**: 要跳过的步骤序号
|
||||
"""
|
||||
service = get_triage_service()
|
||||
result = await service.skip_step(
|
||||
db=db,
|
||||
triage_id=body.triage_id,
|
||||
step_index=body.step_index,
|
||||
)
|
||||
|
||||
if "error" in result:
|
||||
return {"code": 404, "message": result["error"], "data": None}
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": result,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/h5/triage/transfer")
|
||||
async def transfer_to_human(
|
||||
body: TriageTransferRequest,
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""转人工。
|
||||
|
||||
员工主动选择转人工,后端记录上下文并将会话状态改为转人工。
|
||||
|
||||
- **triage_id**: 分诊会话ID
|
||||
- **context**: 已收集的上下文列表
|
||||
"""
|
||||
service = get_triage_service()
|
||||
result = await service.transfer_to_human(
|
||||
db=db,
|
||||
triage_id=body.triage_id,
|
||||
context=body.context,
|
||||
)
|
||||
|
||||
if "error" in result:
|
||||
return {"code": 404, "message": result["error"], "data": None}
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "已转接人工坐席",
|
||||
"data": result,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/h5/triage/complete")
|
||||
async def complete_triage(
|
||||
body: TriageCompleteRequest,
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""分诊完成。
|
||||
|
||||
所有步骤完成后,调用 Dify 生成最终 AI 回复。
|
||||
|
||||
- **triage_id**: 分诊会话ID
|
||||
- **context**: 已收集的上下文列表
|
||||
"""
|
||||
service = get_triage_service()
|
||||
result = await service.complete_triage(
|
||||
db=db,
|
||||
triage_id=body.triage_id,
|
||||
context=body.context,
|
||||
)
|
||||
|
||||
if "error" in result:
|
||||
return {"code": 404, "message": result["error"], "data": None}
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": result,
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 坐席端接口(7个)
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/agent/triage/pending")
|
||||
async def list_pending(
|
||||
urgency: Optional[str] = Query(default=None, description="紧急度筛选:high/medium/low"),
|
||||
problem_type: Optional[str] = Query(default=None, description="问题类型筛选"),
|
||||
page: int = Query(default=1, ge=1, description="页码"),
|
||||
page_size: int = Query(default=20, ge=1, le=100, description="每页数量"),
|
||||
agent=Depends(_get_current_agent()),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取待分诊列表(按紧急度排序)。
|
||||
|
||||
返回状态为 pending/triaging 的分诊会话,按紧急度排序(high > medium > low)。
|
||||
|
||||
**需要坐席认证。**
|
||||
"""
|
||||
service = get_triage_service()
|
||||
result = await service.list_pending(
|
||||
db=db,
|
||||
urgency=urgency,
|
||||
problem_type=problem_type,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": result,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/agent/triage/stats")
|
||||
async def get_stats(
|
||||
agent=Depends(_get_current_agent()),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取分诊看板统计概要。
|
||||
|
||||
返回6项统计指标:待分诊数/今日已分诊/AI自答/转人工/自动审批/平均耗时。
|
||||
|
||||
**需要坐席认证。**
|
||||
"""
|
||||
service = get_triage_service()
|
||||
result = await service.get_stats(db=db)
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": result,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/agent/triage/history")
|
||||
async def get_history(
|
||||
date_from: Optional[str] = Query(default=None, description="开始日期(ISO格式)"),
|
||||
date_to: Optional[str] = Query(default=None, description="结束日期(ISO格式)"),
|
||||
route_action: Optional[str] = Query(default=None, description="路由动作筛选"),
|
||||
page: int = Query(default=1, ge=1, description="页码"),
|
||||
page_size: int = Query(default=20, ge=1, le=100, description="每页数量"),
|
||||
agent=Depends(_get_current_agent()),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取已分诊历史列表。
|
||||
|
||||
返回状态为 routed/skipped/timeout 的分诊会话。
|
||||
|
||||
**需要坐席认证。**
|
||||
"""
|
||||
service = get_triage_service()
|
||||
result = await service.get_history(
|
||||
db=db,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
route_action=route_action,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": result,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/agent/triage/export")
|
||||
async def export_sessions(
|
||||
date_from: Optional[str] = Query(default=None, description="开始日期(ISO格式)"),
|
||||
date_to: Optional[str] = Query(default=None, description="结束日期(ISO格式)"),
|
||||
agent=Depends(_get_current_agent()),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""导出分诊记录为 xlsx。
|
||||
|
||||
导出基础字段 + 分诊步骤详情。
|
||||
|
||||
**需要坐席认证。**
|
||||
"""
|
||||
service = get_triage_service()
|
||||
xlsx_data = await service.export_sessions(
|
||||
db=db,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
)
|
||||
|
||||
return Response(
|
||||
content=xlsx_data,
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={
|
||||
"Content-Disposition": "attachment; filename=triage_sessions.xlsx",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/agent/triage/{triage_id}")
|
||||
async def get_detail(
|
||||
triage_id: str,
|
||||
agent=Depends(_get_current_agent()),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取分诊详情。
|
||||
|
||||
返回分诊会话的完整数据,包括用户画像、问题描述、AI分析结果、已收集上下文等。
|
||||
|
||||
**需要坐席认证。**
|
||||
"""
|
||||
service = get_triage_service()
|
||||
result = await service.get_detail(db=db, triage_id=triage_id)
|
||||
|
||||
if not result:
|
||||
return {"code": 404, "message": "分诊会话不存在", "data": None}
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": result,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/agent/triage/{triage_id}/route")
|
||||
async def route_session(
|
||||
triage_id: str,
|
||||
body: TriageRouteRequest,
|
||||
agent=Depends(_get_current_agent()),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""坐席路由操作(覆盖 AI 建议)。
|
||||
|
||||
坐席可选择4种路由动作:ai_self(AI自答)/ human(转人工)/ auto_approval(自动审批)/ skip(跳过)。
|
||||
|
||||
**需要坐席认证。**
|
||||
"""
|
||||
service = get_triage_service()
|
||||
result = await service.route_session(
|
||||
db=db,
|
||||
triage_id=triage_id,
|
||||
route_action=body.route_action,
|
||||
route_note=body.route_note,
|
||||
operator_id=agent.user_id if hasattr(agent, "user_id") else str(agent.id),
|
||||
)
|
||||
|
||||
if not result:
|
||||
return {"code": 404, "message": "分诊会话不存在", "data": None}
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "路由操作成功",
|
||||
"data": result,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/agent/triage/{triage_id}/exclude-options")
|
||||
async def exclude_options(
|
||||
triage_id: str,
|
||||
body: TriageExcludeOptionsRequest,
|
||||
agent=Depends(_get_current_agent()),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""坐席排除/推荐分诊选项(WS 推送到 H5)。
|
||||
|
||||
坐席可排除某些选项或推荐某个选项,通过 WebSocket 实时推送到 H5 端。
|
||||
|
||||
**需要坐席认证。**
|
||||
"""
|
||||
service = get_triage_service()
|
||||
result = await service.exclude_options(
|
||||
db=db,
|
||||
triage_id=triage_id,
|
||||
excluded_labels=body.excluded_labels,
|
||||
recommended_label=body.recommended_label,
|
||||
)
|
||||
|
||||
if "error" in result:
|
||||
return {"code": 404, "message": result["error"], "data": None}
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": result,
|
||||
}
|
||||
@@ -30,22 +30,26 @@ router = APIRouter()
|
||||
@router.get("/wecom/jsapi-config")
|
||||
async def get_jsapi_config(
|
||||
url: str = Query(..., description="当前页面 URL(不含 # 及其后)"),
|
||||
with_agent_config: bool = Query(
|
||||
False, description="是否同时返回 agent_config 签名(thirdPartyOpenPage 等应用身份接口需要)"
|
||||
),
|
||||
):
|
||||
"""获取企微 JS-SDK 鉴权配置。
|
||||
|
||||
供前端 wx.config 和 wx.agentConfig 使用。
|
||||
|
||||
Args:
|
||||
url: 当前页面 URL(不含 # 及其后)
|
||||
with_agent_config: 是否同时返回 agent_config 签名
|
||||
- False(默认):仅返回 jsapi 签名(wx.config 用)
|
||||
- True:额外返回 agent_config 签名(wx.agentConfig 用,如 thirdPartyOpenPage)
|
||||
|
||||
Returns:
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"corp_id": "wwa8c87970b2011f41",
|
||||
"agent_id": "1000133",
|
||||
"timestamp": 1718500000,
|
||||
"nonce_str": "5K8264ILTKCH...",
|
||||
"signature": "f7c8e9..."
|
||||
}
|
||||
}
|
||||
without agent_config:
|
||||
{ corp_id, agent_id, timestamp, nonce_str, signature }
|
||||
with agent_config:
|
||||
{ corp_id, agent_id, timestamp, nonce_str, signature,
|
||||
agent_config: { timestamp, nonce_str, signature } }
|
||||
"""
|
||||
try:
|
||||
wecom_service = get_shared_wecom_service()
|
||||
@@ -57,7 +61,7 @@ async def get_jsapi_config(
|
||||
timestamp = int(time.time())
|
||||
nonce_str = secrets.token_hex(8) # 16 字符
|
||||
|
||||
# 3. 计算签名
|
||||
# 3. 计算 jsapi 签名(wx.config 用)
|
||||
signature = wecom_service.generate_jsapi_signature(
|
||||
ticket=ticket,
|
||||
nonce_str=nonce_str,
|
||||
@@ -65,19 +69,34 @@ async def get_jsapi_config(
|
||||
url=url,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"生成 JS-SDK 签名: url={url[:80]}... timestamp={timestamp}"
|
||||
)
|
||||
response_data = {
|
||||
"corp_id": settings.wecom_corp_id,
|
||||
"agent_id": str(settings.wecom_agent_id),
|
||||
"timestamp": timestamp,
|
||||
"nonce_str": nonce_str,
|
||||
"signature": signature,
|
||||
}
|
||||
|
||||
return success_response(
|
||||
{
|
||||
"corp_id": settings.wecom_corp_id,
|
||||
"agent_id": str(settings.wecom_agent_id),
|
||||
logger.info(f"生成 JS-SDK 签名: url={url[:80]}... timestamp={timestamp}")
|
||||
|
||||
# 4. 如果需要 agent_config 签名,额外计算
|
||||
if with_agent_config:
|
||||
agent_ticket = await wecom_service.get_agent_config_ticket()
|
||||
agent_nonce_str = secrets.token_hex(8)
|
||||
agent_signature = wecom_service.generate_jsapi_signature(
|
||||
ticket=agent_ticket,
|
||||
nonce_str=agent_nonce_str,
|
||||
timestamp=timestamp,
|
||||
url=url,
|
||||
)
|
||||
response_data["agent_config"] = {
|
||||
"timestamp": timestamp,
|
||||
"nonce_str": nonce_str,
|
||||
"signature": signature,
|
||||
"nonce_str": agent_nonce_str,
|
||||
"signature": agent_signature,
|
||||
}
|
||||
)
|
||||
logger.info(f"生成 agent_config 签名: url={url[:80]}...")
|
||||
|
||||
return success_response(response_data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"生成 JS-SDK 签名失败: {e}", exc_info=True)
|
||||
|
||||
Reference in New Issue
Block a user