Files
Simon 449c6d4875 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分块上传
2026-07-13 02:17:03 +08:00

1125 lines
43 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# =============================================================================
# IT智能服务台 — 审批流程 API
# =============================================================================
# 说明:提供审批模板管理和API提交功能
# - 模板详情获取
# - API提交审批申请
# - 审批状态回调处理
# =============================================================================
import asyncio
import json
import logging
import os
from typing import Optional
import httpx
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
import redis.asyncio as aioredis
from app.config import settings
from app.services.asset_service import AssetService
from app.services.wecom_service import WecomService
from app.utils.token_manager import TokenManager
from app.utils.response import success_response
logger = logging.getLogger(__name__)
router = APIRouter()
# IT资产升级申请模板ID(回调时用于识别审批类型,触发年限核查推送)
ASSET_UPGRADE_TEMPLATE_ID = "Bs7ucTGsPuFhxfk8pn8EydxrWxkVetB4JR8Pb6PHS"
# Redis客户端(依赖注入)
async def get_redis() -> aioredis.Redis:
"""获取Redis客户端依赖"""
from app.main import redis_client
return redis_client
# =============================================================================
# 审批模板配置(静态硬编码 — 18个审批流程,12种审批类型)
# =============================================================================
# 数据来源:docs/02-产品需求/approval_templates.json
# 所有模板 type 统一为 "jump"(直接URL跳转)
APPROVAL_TEMPLATES: dict[str, dict] = {
# --- 办公用品申请 ---
"office_supplies": {
"id": "office_supplies",
"name": "办公用品超额领用审批",
"type": "jump",
"keywords": ["办公用品", "超额", "领用"],
"url": "https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=3WN6zRucbjycdnR94gBvkSVuXRamX7pKW4PrmNFh&sp_id=&from=template_list",
"location": "企微审批",
},
# --- 设备申请 ---
"asset_receive": {
"id": "asset_receive",
"name": "资产领用登记",
"type": "jump",
"keywords": ["资产领用", "领用登记", "设备领用"],
"url": "https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=C4c8qt31AbSHwN9MuaFhYXt4Qwsx6ZLCftAFh6X1w&sp_id=&from=template_list",
"location": "企微审批",
},
"asset_borrow": {
"id": "asset_borrow",
"name": "资产借用申请",
"type": "jump",
"keywords": ["资产借用", "借用", "借用设备"],
"url": "https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=3TmACnFs8oqgYcasxVh4BfSMGNX7p9sb6ydBX77mK&sp_id=&from=template_list",
"location": "企微审批",
},
"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": "企微审批",
},
"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",
"location": "运维平台",
},
# --- 账号权限申请 ---
"wecom_external": {
"id": "wecom_external",
"name": "企微外联权限申请",
"type": "jump",
"keywords": ["外联", "企微外联", "外部联系人"],
"url": "https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=C4WrCbZd214XrDMZJiHDho7ZQHWX7gsabb7x2fF72&sp_id=&from=template_list",
"location": "企微审批",
},
"zero_trust_vpn": {
"id": "zero_trust_vpn",
"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",
"location": "运维平台",
},
# --- 软件服务申请 ---
"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": "企微审批",
},
# --- 资产处置申请 ---
"asset_repair": {
"id": "asset_repair",
"name": "IT资产外修申请",
"type": "jump",
"keywords": ["外修", "资产外修", "设备维修"],
"url": "https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=Bs7ucTLPo42dtj8Y1LzBoujijsa6geRWaRxZJjk4X&sp_id=&from=template_list",
"location": "企微审批",
},
"asset_scrap": {
"id": "asset_scrap",
"name": "IT资产报废申请",
"type": "jump",
"keywords": ["报废", "资产报废", "设备报废"],
"url": "https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=C4WroCDfWuHKyujQjatjm3AjNv67imXk5C6WNooFkb&sp_id=&from=template_list",
"location": "企微审批",
},
"asset_return": {
"id": "asset_return",
"name": "资产退还登记",
"type": "jump",
"keywords": ["退还", "资产退还", "退还设备"],
"url": "https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=C4c8qt33AZ52a7n9BBWDh6PmsDnpM5B6w8geqqqoHz&sp_id=&from=template_list",
"location": "企微审批",
},
# --- 会议室故障报修 ---
"meeting_room_repair": {
"id": "meeting_room_repair",
"name": "会议室故障报修",
"type": "jump",
"keywords": ["会议室", "故障报修", "报修"],
"url": "https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=C4ZXJMtjQJiPXo6N5vNMK26uPRT3KTi9VvkH2NScg&sp_id=&from=template_list",
"location": "企微审批",
},
# --- 企业应用管理 ---
"app_management": {
"id": "app_management",
"name": "企业应用管理",
"type": "jump",
"keywords": ["企业应用", "应用管理", "应用开通"],
"url": "https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=3WLJnRg2Se1fwizQtNvFtcYMgci1mhRJZhMw2FFKb&sp_id=&from=template_list",
"location": "企微审批",
},
# --- 资产变更确认 ---
"asset_change": {
"id": "asset_change",
"name": "资产变更确认",
"type": "jump",
"keywords": ["资产变更", "变更确认", "变更"],
"url": "https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=C4cA2owjRcXRRPQ46otZvUHoWNEKL5t25tHHfeePip&sp_id=&from=template_list",
"location": "企微审批",
},
# --- 终端设备网络准入 ---
"network_access": {
"id": "network_access",
"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",
"location": "运维平台",
},
# --- 活动与会议技术支持 ---
"event_support": {
"id": "event_support",
"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",
"location": "运维平台",
},
# --- 员工IT支持与故障报修 ---
"it_support_repair": {
"id": "it_support_repair",
"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",
"location": "运维平台",
},
# --- 公共邮箱账号申请 ---
"public_email": {
"id": "public_email",
"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",
"location": "运维平台",
},
}
# =============================================================================
# 审批意图识别 — 关键词预过滤 & 降级兜底
# =============================================================================
# 说明:覆盖 12 种审批类型、18 个审批流程的关键词,用于快速过滤非审批消息,
# 避免 Dify 被每条消息调用。当 Dify 不可用时降级为关键词匹配。
# 审批关键词预过滤列表(v2.0 收窄版 — 2026-07-13
# ------------------------------------------------------------
# 设计原则:只保留「强意图词」和「复合专有词」,移除高频泛化词。
#
# 改造前 ~40 词(含"设备""电脑""邮箱""权限""软件""报修"等),
# 几乎覆盖所有 IT 消息 → 预过滤命中率 ~60% → 大量无效 Dify 调用。
#
# 改造后策略:
# - 强意图动词:用户明确表达"申请/提交"意愿时才触发
# - 复合专有词:仅在审批场景出现,不会在普通 IT 咨询中出现
# - 移除的泛化词(设备/电脑/邮箱/权限/软件/报修/变更/会议室等)
# 改由 Dify 意图识别内部判断,不作为预过滤触发条件
#
# 预期效果:预过滤命中率从 ~60% 降至 ~15%,减少 75% 无效 Dify 调用
APPROVAL_PREFILTER_KEYWORDS: list[str] = [
# === 强意图动词/短语(用户明确表达申请意愿)===
"申请", "审批", "提交", "表单", "走流程",
"帮我申请", "我要申请", "需要申请", "想申请",
# === 复合专有词(仅在审批场景出现,日常 IT 咨询不会用)===
"资产领用", "资产借用", "资产升级", "资产变更", "资产处置",
"网络准入", "终端准入",
"办公用品", "超额领用",
"企业应用管理", "应用开通",
"公共邮箱", "共享邮箱", "公共账号",
"故障报修", "IT支持",
"自备电脑", "电脑补贴", "BYOD", "byod",
]
# 关键词到审批类型的映射(Dify 不可用时降级兜底用)
KEYWORD_TO_APPROVAL_TYPE: dict[str, str] = {
# 设备申请
"设备": "设备申请", "电脑": "设备申请", "笔记本": "设备申请",
"显示器": "设备申请", "资产领用": "设备申请", "资产借用": "设备申请", "资产升级": "设备申请",
# 账号权限申请
"VPN": "账号权限申请", "vpn": "账号权限申请", "账号": "账号权限申请",
"邮箱": "账号权限申请", "外联": "账号权限申请", "权限": "账号权限申请",
# 软件服务申请
"软件": "软件服务申请", "业务系统": "软件服务申请",
# 资产处置申请
"外修": "资产处置申请", "报废": "资产处置申请", "退还": "资产处置申请",
# 办公用品申请
"办公用品": "办公用品申请", "超额": "办公用品申请",
# 会议室故障报修
"会议室": "会议室故障报修", "故障报修": "会议室故障报修", "报修": "会议室故障报修",
# 企业应用管理
"企业应用": "企业应用管理", "应用管理": "企业应用管理", "应用开通": "企业应用管理",
# 资产变更确认
"资产变更": "资产变更确认", "变更确认": "资产变更确认", "变更": "资产变更确认",
# 终端设备网络准入
"网络准入": "终端设备网络准入", "终端准入": "终端设备网络准入", "准入申请": "终端设备网络准入",
# 活动与会议技术支持
"活动支持": "活动与会议技术支持", "会议支持": "活动与会议技术支持", "活动技术": "活动与会议技术支持",
# 员工IT支持与故障报修
"IT支持": "员工IT支持与故障报修",
# 公共邮箱账号申请
"公共邮箱": "公共邮箱账号申请", "公共账号": "公共邮箱账号申请", "共享邮箱": "公共邮箱账号申请",
}
# =============================================================================
# Schema 定义
# =============================================================================
class ApprovalTemplateResponse(BaseModel):
"""审批模板响应"""
id: str
name: str
type: str
keywords: list[str]
class ApprovalJumpRequest(BaseModel):
"""跳转审批请求"""
template_id: str
employee_id: Optional[str] = None
class ApprovalJumpResponse(BaseModel):
"""跳转审批响应"""
url: str
template_name: str
class ApprovalContentItem(BaseModel):
"""审批表单控件内容"""
control: str # 控件类型: Text, Textarea, Number, Money, Date, Selector, Contact, etc.
id: str # 控件ID
value: dict # 控件值
class ApprovalSubmitRequest(BaseModel):
"""API提交审批请求"""
template_id: str
employee_id: str # 申请人userid
contents: list[ApprovalContentItem] # 表单内容
use_template_approver: int = 1 # 1-使用模板预设流程
class ApprovalSubmitResponse(BaseModel):
"""API提交审批响应"""
sp_no: str # 审批单号
template_name: str
class ApprovalCallbackRequest(BaseModel):
"""审批回调请求(XML解析后的模型)"""
sp_no: str
sp_name: str
template_id: str
apply_time: int
applyer_userid: str
sp_status: int # 1-审批中 2-已通过 3-已驳回 4-已撤销 6-通过后撤销 7-已删除 10-已支付
status_change_event: int # 1-提单 2-同意 3-驳回 4-转审 5-催办 6-撤销 8-通过后撤销 10-添加备注
class ApprovalUrgeRequest(BaseModel):
"""审批推送请求 — 手动触发资产年限核查+推送"""
sp_no: str
class ApprovalDetectIntentRequest(BaseModel):
"""审批意图检测请求"""
text: str
employee_id: Optional[str] = None
class ApprovalDetectIntentResponse(BaseModel):
"""审批意图检测响应
Attributes:
is_approval_request: 是否为审批请求(原字段,语义不变)
confidence: 置信度(0.0~1.0)(原字段,语义不变)
approval_type: 审批类型(原字段,语义不变)
source: 结果来源 — dify(Dify识别) / keyword_prefilter(关键词预过滤未命中) / fallback(降级兜底)
intent_type: 意图大类(新增)— approval/it_consult/non_it_routing/chitchat
business_category: 非IT业务类别(新增)— 行政/人力资源/财务/法务/行政-物业,仅 non_it_routing 时有值
routing_confidence: 路由置信度(新增)— 0.0~1.0,≥0.7 触发名片推荐
"""
is_approval_request: bool
confidence: float
approval_type: Optional[str] = None
source: str # "dify" | "keyword_prefilter" | "fallback"
# 以下为 v3 统一意图识别新增字段(向后兼容:原审批逻辑只读取前4个字段)
intent_type: str = "chitchat"
business_category: Optional[str] = None
routing_confidence: float = 0.0
# =============================================================================
# 企微API调用辅助函数
# =============================================================================
async def get_approval_token(redis: aioredis.Redis) -> str:
"""获取企微access_token(使用IT支持应用Secret,IP已在白名单中)。"""
manager = TokenManager(redis)
try:
return await manager.get_token()
finally:
await manager.close()
async def get_template_detail(access_token: str, template_id: str) -> dict:
"""获取审批模板详情
对应企微API:
POST https://qyapi.weixin.qq.com/cgi-bin/oa/gettemplatedetail
返回模板内的控件构成及控件ID
"""
url = "https://qyapi.weixin.qq.com/cgi-bin/oa/gettemplatedetail"
params = {"access_token": access_token}
async with httpx.AsyncClient(timeout=httpx.Timeout(timeout=30.0, connect=10.0, read=30.0)) as client:
response = await client.post(url, params=params, json={"template_id": template_id})
result = response.json()
if result.get("errcode") != 0:
logger.error(f"获取模板详情失败: {result.get('errmsg')}")
raise Exception(f"获取模板详情失败: {result.get('errmsg')}")
return result
async def submit_approval_api(
access_token: str,
template_id: str,
creator_userid: str,
contents: list[dict],
use_template_approver: int = 1
) -> dict:
"""提交审批申请
对应企微API:
POST https://qyapi.weixin.qq.com/cgi-bin/oa/applyevent
Args:
access_token: 审批应用access_token
template_id: 模板ID
creator_userid: 申请人userid
contents: 表单控件内容列表
use_template_approver: 1-使用模板预设流程
Returns:
{"sp_no": "审批单号"}
"""
url = "https://qyapi.weixin.qq.com/cgi-bin/oa/applyevent"
params = {"access_token": access_token}
payload = {
"creator_userid": creator_userid,
"template_id": template_id,
"use_template_approver": use_template_approver,
"apply_data": {
"contents": contents
}
}
async with httpx.AsyncClient(timeout=httpx.Timeout(timeout=30.0, connect=10.0, read=30.0)) as client:
response = await client.post(url, params=params, json=payload)
result = response.json()
if result.get("errcode") != 0:
logger.error(f"提交审批失败: {result.get('errmsg')}")
raise Exception(f"提交审批失败: {result.get('errmsg')}")
return {"sp_no": result.get("sp_no")}
# =============================================================================
# 资产升级审批推送 — 辅助函数
# =============================================================================
async def get_approval_detail(access_token: str, sp_no: str) -> dict:
"""获取审批申请详情。
对应企微API:
POST https://qyapi.weixin.qq.com/cgi-bin/oa/getapprovaldetail
返回审批单的表单数据、审批流程节点、当前审批人等信息。
Args:
access_token: 审批应用 access_token
sp_no: 审批单号
Returns:
dict: 企微API返回的完整审批详情
Raises:
Exception: API调用失败或返回错误
"""
url = "https://qyapi.weixin.qq.com/cgi-bin/oa/getapprovaldetail"
params = {"access_token": access_token}
payload = {"sp_no": sp_no}
async with httpx.AsyncClient(timeout=httpx.Timeout(timeout=30.0, connect=10.0, read=30.0)) as client:
response = await client.post(url, params=params, json=payload)
result = response.json()
if result.get("errcode") != 0:
logger.error(f"获取审批详情失败: sp_no={sp_no}, errmsg={result.get('errmsg')}")
raise Exception(f"获取审批详情失败: {result.get('errmsg')}")
return result
async def get_approval_data(
access_token: str,
starttime: int,
endtime: int,
filters: Optional[list] = None,
cursor: int = 0,
size: int = 100,
) -> dict:
"""获取审批申请数据列表。
对应企微API:
POST https://qyapi.weixin.qq.com/cgi-bin/oa/getapprovaldata
按时间范围和过滤条件查询审批单列表,返回审批单号及基本信息。
支持 cursor 分页。
Args:
access_token: 审批应用 access_token
starttime: 查询开始时间(秒级 Unix 时间戳)
endtime: 查询结束时间(秒级 Unix 时间戳)
filters: 过滤条件列表,格式 [{"key": "template_id", "value": "xxx"}, ...]
cursor: 分页游标(从 0 开始)
size: 单页返回数量(最大 100)
Returns:
dict: 企微API返回的完整响应,包含 data(审批列表)、total、next_cursor
Raises:
Exception: API调用失败或返回错误
"""
url = "https://qyapi.weixin.qq.com/cgi-bin/oa/getapprovaldata"
params = {"access_token": access_token}
payload = {
"starttime": starttime,
"endtime": endtime,
"cursor": cursor,
"size": size,
}
if filters:
payload["filters"] = filters
async with httpx.AsyncClient(timeout=httpx.Timeout(timeout=30.0, connect=10.0, read=30.0)) as client:
response = await client.post(url, params=params, json=payload)
result = response.json()
if result.get("errcode") != 0:
logger.error(
f"getapprovaldata 调用失败: errcode={result.get('errcode')}, "
f"errmsg={result.get('errmsg')}"
)
raise Exception(f"获取审批数据列表失败: {result.get('errmsg')}")
return result
def _extract_asset_code(approval_detail: dict) -> Optional[str]:
"""从审批单表单数据中提取资产编号。
遍历 apply_data.contents,查找标题包含"资产编号"/"固定资产编号"等关键字的控件,
提取其值。支持 Text/Textareavalue.text)和 Selectorvalue.value)两种控件类型。
Args:
approval_detail: get_approval_detail 返回的完整审批详情
Returns:
str: 资产编号,提取失败返回 None
"""
try:
contents = (
approval_detail
.get("info", {})
.get("apply_data", {})
.get("contents", [])
)
# 资产编号可能的标题名
asset_code_titles = {"资产编号", "固定资产编号", "资产编码", "设备编号"}
for item in contents:
# 检查控件标题
title_list = item.get("title", [])
for title_obj in title_list:
title_text = title_obj.get("text", "")
if title_text in asset_code_titles:
# 提取控件值
value = item.get("value", {})
# Text / Textarea 类型:value.text
text = value.get("text", "")
if text:
return text.strip()
# Selector 类型:value.value
sel_value = value.get("value", "")
if sel_value:
return sel_value.strip()
except Exception as e:
logger.error(f"提取资产编号失败: {e}")
return None
def _extract_current_approver(approval_detail: dict) -> Optional[str]:
"""从审批单详情中提取当前审批人 userid。
遍历 sp_record,查找 sp_status=1(审批中)的节点,
返回其 details 中第一个 sp_status=1(待处理)的 approver.userid。
注意:企微 API 实际返回的字段名:
- sp_record[].sp_status(非 status
- sp_record[].approverattr(非 approverAttr
- sp_record[].details[].approver.userid(非 sp_record[].approver[].userid
Args:
approval_detail: get_approval_detail 返回的完整审批详情
Returns:
str: 当前审批人 userid,提取失败返回 None
"""
try:
sp_record = approval_detail.get("info", {}).get("sp_record", [])
for record in sp_record:
# 企微 API 字段名为 sp_status(非 status),1=审批中
if record.get("sp_status") != 1:
continue
# 审批人信息在 details[].approver 中(非 record.approver
details = record.get("details", [])
for detail in details:
# detail.sp_status=1 表示该审批人尚未处理
if detail.get("sp_status") == 1:
approver = detail.get("approver", {})
if isinstance(approver, dict):
userid = approver.get("userid", "")
if userid:
return userid
elif isinstance(approver, list) and len(approver) > 0:
userid = approver[0].get("userid", "")
if userid:
return userid
except Exception as e:
logger.error(f"提取当前审批人失败: {e}")
return None
def _build_urge_description(check_result: dict, applyer_userid: str) -> str:
"""构建推送卡片的描述文本。
根据资产核查结果拼接多行描述,包含资产编号、设备名称、启用日期、
已使用年限、核查结论、申请人等信息。
Args:
check_result: AssetService.check_device_age 返回的核查结果
applyer_userid: 申请人 userid
Returns:
str: 多行描述文本
"""
lines = []
# 资产编号
lines.append(f"资产编号:{check_result.get('asset_code', '未知')}")
# 设备名称
if check_result.get("asset_name"):
lines.append(f"设备名称:{check_result['asset_name']}")
# 启用日期
if check_result.get("start_date"):
lines.append(f"启用日期:{check_result['start_date']}")
# 已使用年限
if check_result.get("years_display"):
lines.append(f"已使用:{check_result['years_display']}")
# 核查结论
threshold = settings.asset_replacement_threshold_years
if check_result.get("found") and check_result.get("meets_threshold") is not None:
if check_result["meets_threshold"]:
lines.append(f"核查结论:✅ 已满{threshold}年,符合更换条件")
else:
lines.append(f"核查结论:❌ 未满{threshold}年,不符合更换条件")
elif not check_result.get("found"):
lines.append("核查结论:⚠️ 未找到资产记录,请人工核查")
else:
lines.append("核查结论:⚠️ 无法计算年限,请人工核查")
# 申请人
lines.append(f"申请人:{applyer_userid}")
# 提示
lines.append("请点击查看审批详情")
return "\n".join(lines)
async def _do_asset_urge(sp_no: str, redis: aioredis.Redis) -> dict:
"""执行资产年限核查 + 向审批人推送卡片消息的完整逻辑。
流程:
1. 获取审批应用 access_token
2. 调用 oa/getapprovaldetail 获取审批单详情
3. 从表单数据提取资产编号
4. 从审批流程提取当前审批人 userid
5. 用 AssetService 核查设备使用年限
6. 向当前审批人推送企微文本卡片消息
所有步骤均有 try/except 保护,失败时记日志但不抛出异常。
Args:
sp_no: 审批单号
redis: Redis 客户端(用于获取 access_token
Returns:
dict: 执行结果,包含 success/message 以及核查详情
"""
try:
# 1. 获取审批应用 access_token
token = await get_approval_token(redis)
# 2. 获取审批单详情
detail = await get_approval_detail(token, sp_no)
# 3. 提取资产编号
asset_code = _extract_asset_code(detail)
if not asset_code:
logger.warning(f"审批单 {sp_no} 未能提取资产编号")
return {"success": False, "message": "未能从审批表单提取资产编号", "sp_no": sp_no}
# 4. 提取当前审批人
approver = _extract_current_approver(detail)
if not approver:
logger.warning(f"审批单 {sp_no} 未能提取当前审批人")
return {"success": False, "message": "未能提取当前审批人 userid", "sp_no": sp_no}
# 5. 提取申请人 userid
applyer_userid = (
detail.get("info", {}).get("applyer", {}).get("userid", "")
)
# 6. 核查资产年限
asset_service = AssetService()
threshold = settings.asset_replacement_threshold_years
check_result = asset_service.check_device_age(asset_code, threshold)
# 7. 构建并推送卡片消息
description = _build_urge_description(check_result, applyer_userid)
card_url = (
f"https://app.work.weixin.qq.com/wework_admin/approval_v3"
f"#/?template_id={ASSET_UPGRADE_TEMPLATE_ID}&sp_id={sp_no}"
)
# 使用 IT 支持应用的 access_token 发送消息(非审批应用 token)
wecom_service = WecomService(redis_client=redis)
await wecom_service.send_card_message(
user_id=approver,
title="IT资产升级审批 — 设备年限核查",
description=description,
url=card_url,
btntxt="去审批",
)
logger.info(f"资产年限核查推送成功: sp_no={sp_no}, approver={approver}")
return {
"success": True,
"message": "推送成功",
"sp_no": sp_no,
"approver": approver,
"asset_code": asset_code,
"check_result": check_result,
}
except Exception as e:
logger.error(f"资产年限核查推送失败: sp_no={sp_no}, error={e}")
return {"success": False, "message": f"推送失败: {e}", "sp_no": sp_no}
# =============================================================================
# API 端点
# =============================================================================
@router.get("/approval/templates", response_model=list[ApprovalTemplateResponse])
async def get_approval_templates():
"""获取所有审批模板列表"""
return list(APPROVAL_TEMPLATES.values())
@router.get("/approval/templates/{template_id}", response_model=ApprovalTemplateResponse)
async def get_approval_template(template_id: str):
"""获取指定审批模板详情"""
if template_id not in APPROVAL_TEMPLATES:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="模板不存在")
return APPROVAL_TEMPLATES[template_id]
@router.get("/approval/templates/{template_id}/detail")
async def get_template_full_detail(
template_id: str,
redis: aioredis.Redis = Depends(get_redis)
):
"""获取审批模板完整详情(控件结构)"""
if template_id not in APPROVAL_TEMPLATES:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="模板不存在")
try:
token = await get_approval_token(redis)
detail = await get_template_detail(token, template_id)
return detail
except Exception as e:
from fastapi import HTTPException
raise HTTPException(status_code=500, detail=str(e))
@router.post("/approval/jump")
async def create_approval_jump(request: ApprovalJumpRequest):
"""生成跳转审批链接"""
template = APPROVAL_TEMPLATES.get(request.template_id)
if not template:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="模板不存在")
if template["type"] != "jump":
from fastapi import HTTPException
raise HTTPException(status_code=400, detail="该模板不支持跳转方式")
# 生成跳转URL(企微审批链接格式)
jump_url = f"https://qyapi.weixin.qq.com/cgi-bin/oa/applyevent?access_token=TOKEN&template_id={request.template_id}"
return success_response(data=ApprovalJumpResponse(
url=jump_url,
template_name=template["name"],
))
@router.post("/approval/submit", response_model=ApprovalSubmitResponse)
async def submit_approval(
request: ApprovalSubmitRequest,
redis: aioredis.Redis = Depends(get_redis)
):
"""API提交审批申请"""
template = APPROVAL_TEMPLATES.get(request.template_id)
if not template:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="模板不存在")
if template["type"] != "api":
from fastapi import HTTPException
raise HTTPException(status_code=400, detail="该模板不支持API提交")
try:
# 1. 获取审批token
token = await get_approval_token(redis)
# 2. 转换contents格式
contents = [item.model_dump() for item in request.contents]
# 3. 提交审批
result = await submit_approval_api(
access_token=token,
template_id=request.template_id,
creator_userid=request.employee_id,
contents=contents,
use_template_approver=request.use_template_approver
)
return ApprovalSubmitResponse(
sp_no=result["sp_no"],
template_name=template["name"],
)
except Exception as e:
from fastapi import HTTPException
raise HTTPException(status_code=500, detail=str(e))
@router.post("/approval/callback")
async def approval_callback(
sp_no: str = Query(...),
sp_name: str = Query(...),
template_id: str = Query(...),
apply_time: int = Query(...),
applyer_userid: str = Query(...),
sp_status: int = Query(...),
status_change_event: int = Query(...),
redis: aioredis.Redis = Depends(get_redis),
):
"""审批状态变化回调处理
对应企微审批回调事件: sys_approval_change
状态变化类型 (status_change_event):
1 - 提单
2 - 同意
3 - 驳回
4 - 转审
5 - 催办
6 - 撤销
8 - 通过后撤销
10 - 添加备注
审批单状态 (sp_status):
1 - 审批中
2 - 已通过
3 - 已驳回
4 - 已撤销
6 - 通过后撤销
7 - 已删除
10 - 已支付
"""
logger.info(f"审批回调: sp_no={sp_no}, status={sp_status}, event={status_change_event}")
# TODO: 根据业务需求处理审批状态变化
# 例如:
# - 审批通过后,更新IT服务台待办状态
# - 审批驳回后,通知申请人
# - 审批撤销后,关闭相关工单
event_map = {
1: "submitted",
2: "approved",
3: "rejected",
4: "transferred",
5: "reminded",
6: "revoked",
8: "revoked_after_approved",
10: "commented"
}
event_type = event_map.get(status_change_event, f"unknown_{status_change_event}")
logger.info(f"审批事件类型: {event_type}")
# IT资产升级申请提单时,自动触发年限核查+推送(异步执行,不阻塞回调响应)
if status_change_event == 1 and template_id == ASSET_UPGRADE_TEMPLATE_ID:
logger.info(f"检测到IT资产升级申请提单: sp_no={sp_no}")
asyncio.create_task(_do_asset_urge(sp_no, redis))
return {"errcode": 0, "errmsg": "ok"}
@router.post("/approval/urge")
async def urge_approval(
request: ApprovalUrgeRequest,
redis: aioredis.Redis = Depends(get_redis),
):
"""手动触发审批推送 — 资产年限核查 + 向当前审批人推送卡片消息。
用于回调未触发或需重新推送的场景。内部调用 _do_asset_urge 执行完整逻辑。
Args:
request: 包含 sp_no(审批单号)
redis: Redis 客户端
Returns:
success_response: 包含推送结果和核查详情
"""
result = await _do_asset_urge(request.sp_no, redis)
return success_response(data=result)
@router.get("/approval/keywords")
async def get_approval_keywords():
"""获取所有审批关键词(用于前端关键词检测)"""
keywords = []
for template in APPROVAL_TEMPLATES.values():
for kw in template["keywords"]:
keywords.append({
"keyword": kw,
"template_id": template["id"],
"template_name": template["name"],
"type": template["type"],
})
return success_response(data=keywords)
# =============================================================================
# 审批意图识别(Dify + 关键词预过滤 + 降级兜底)
# =============================================================================
def _keyword_prefilter(text: str) -> bool:
"""关键词预过滤:检查文本是否包含审批相关关键词(v2.0 收窄版)。
v2.0 变更(2026-07-13):
- 不再合并 APPROVAL_TEMPLATES 的 keywords(包含"借用""升级""外联"等泛化词)
- 仅使用 APPROVAL_PREFILTER_KEYWORDS(强意图词 + 复合专有词)
- 模板 keywords 仍保留在 KEYWORD_TO_APPROVAL_TYPE 中,仅用于 Dify 不可用时的降级兜底
Args:
text: 用户消息文本
Returns:
bool: 是否包含审批关键词
"""
if not text:
return False
lower_text = text.lower()
# 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]]:
"""关键词兜底:Dify 不可用时通过关键词匹配判断审批意图。
遍历 KEYWORD_TO_APPROVAL_TYPE 映射,命中第一个关键词即返回对应审批类型。
置信度取 0.6(略低于阈值,但预过滤已通过说明有审批关键词)。
Args:
text: 用户消息文本
Returns:
tuple: (is_approval_request, confidence, approval_type)
"""
lower_text = (text or "").lower()
approval_type: Optional[str] = None
for kw, atype in KEYWORD_TO_APPROVAL_TYPE.items():
if kw.lower() in lower_text:
approval_type = atype
break
# 预过滤已通过(说明有审批关键词),兜底返回 is_approval_request=True
return True, 0.6, approval_type
async def _call_dify_approval_intent(text: str, employee_id: str = "") -> dict:
"""调用 Dify 审批意图识别应用(Dify 原生 API)。
直接调用 Dify 原生 /v1/chat-messages 接口,绕过 Dify2OpenAI 代理。
Dify2OpenAI 代理会将 JSON 响应序列化为 "[object Object]" 字符串,
导致后端无法解析。使用原生 API 可获得正确的 JSON 响应。
Dify 应用的 System Prompt 已在 Dify 后台配置好,后端只需把用户消息传过去。
返回 JSON: {"is_approval_request": bool, "confidence": float, "approval_type": str|null}
Args:
text: 用户消息文本
employee_id: 员工 ID(可选,传给 Dify 的 user 字段)
Returns:
dict: {"is_approval_request": bool, "confidence": float, "approval_type": str|None}
Raises:
Exception: Dify 调用失败或响应解析失败
"""
base_url = settings.approval_dify_base_url
api_key = settings.approval_dify_api_key
timeout = settings.approval_dify_timeout
if not base_url or not api_key:
raise ValueError("Dify 审批意图识别应用未配置(APPROVAL_DIFY_BASE_URL / APPROVAL_DIFY_API_KEY")
# 构建请求 URLbase_url + /v1/chat-messagesDify 原生 API
url = f"{base_url.rstrip('/')}/v1/chat-messages"
body = {
"inputs": {}, # Dify 应用的输入变量(无自定义变量时为空)
"query": text, # 用户消息文本
"response_mode": "blocking", # 阻塞模式,等待完整响应
"user": employee_id or "approval_detection", # 用户标识
}
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
async with httpx.AsyncClient(timeout=httpx.Timeout(timeout)) as client:
response = await client.post(url, json=body, headers=headers)
response.raise_for_status()
data = response.json()
# 解析 Dify 原生响应:answer 字段包含 AI 返回的文本(JSON 字符串)
answer = data.get("answer", "")
parsed = json.loads(answer)
return {
"is_approval_request": bool(parsed.get("is_approval_request", False)),
"confidence": float(parsed.get("confidence", 0.0)),
"approval_type": parsed.get("approval_type"),
# v3 统一意图识别新增字段(向后兼容:旧 Prompt 无这些字段时取默认值)
"intent_type": str(parsed.get("intent_type", "chitchat")),
"business_category": parsed.get("business_category"),
"routing_confidence": float(parsed.get("routing_confidence", 0.0)),
}
@router.post("/approval/detect-intent")
async def detect_approval_intent(request: ApprovalDetectIntentRequest):
"""审批意图检测端点。
流程:
1. 关键词预过滤 — 未命中直接返回 false(避免每条消息都调 Dify)
2. 命中关键词 → 调用 Dify 审批意图识别应用
3. Dify 调用失败 → 降级为关键词匹配(兜底)
Args:
request: 包含 text(用户消息)和可选的 employee_id
Returns:
ApprovalDetectIntentResponse: 检测结果
"""
text = request.text or ""
# 1. 关键词预过滤
if not _keyword_prefilter(text):
return success_response(data=ApprovalDetectIntentResponse(
is_approval_request=False,
confidence=0.0,
approval_type=None,
source="keyword_prefilter",
intent_type="chitchat",
business_category=None,
routing_confidence=0.0,
))
# 2. 调用 Dify 意图识别
try:
result = await _call_dify_approval_intent(text, request.employee_id or "")
# 检查置信度阈值
threshold = settings.approval_confidence_threshold
is_approval = result["is_approval_request"] and result["confidence"] >= threshold
logger.info(
f"审批意图检测(Dify): is_approval={is_approval}, "
f"confidence={result['confidence']}, type={result.get('approval_type')}, "
f"intent_type={result.get('intent_type')}, "
f"business_category={result.get('business_category')}, "
f"routing_confidence={result.get('routing_confidence')}"
)
return success_response(data=ApprovalDetectIntentResponse(
is_approval_request=is_approval,
confidence=result["confidence"],
approval_type=result.get("approval_type"),
source="dify",
intent_type=result.get("intent_type", "chitchat"),
business_category=result.get("business_category"),
routing_confidence=result.get("routing_confidence", 0.0),
))
except Exception as e:
logger.warning(f"Dify 审批意图识别失败,降级为关键词匹配: {e}")
# 3. 降级为关键词匹配
is_approval, confidence, approval_type = _fallback_detect(text)
logger.info(
f"审批意图检测(兜底): is_approval={is_approval}, "
f"confidence={confidence}, type={approval_type}"
)
return success_response(data=ApprovalDetectIntentResponse(
is_approval_request=is_approval,
confidence=confidence,
approval_type=approval_type,
source="fallback",
intent_type="approval" if is_approval else "chitchat",
business_category=None,
routing_confidence=0.0,
))