# ============================================================================= # 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": "企微审批", }, # --- 设备申请 --- # IT资产领用 改用ITSM工单系统(企微审批模板 C4c8qt31... 已失效) "asset_receive": { "id": "asset_receive", "name": "IT资产领用申请", "type": "jump", "keywords": ["资产领用", "领用登记", "设备领用"], "url": "https://itsm.servyou.com.cn/itsm-miniapp-mobile/", "location": "运维平台", }, # IT资产借用 改用ITSM工单系统(企微审批模板 3TmACnFs... 已失效) "asset_borrow": { "id": "asset_borrow", "name": "IT资产借用申请", "type": "jump", "keywords": ["资产借用", "借用", "借用设备"], "url": "https://itsm.servyou.com.cn/itsm-miniapp-mobile/", "location": "运维平台", }, # IT资产升级 改用ITSM工单系统(企微审批模板 Bs7ucTGs... 已失效) "asset_upgrade": { "id": "asset_upgrade", "name": "IT资产升级申请", "type": "jump", "keywords": ["资产升级", "设备升级", "升级"], "url": "https://itsupport.servyou.com.cn/h5/itsm-bridge.html?name=IT%E8%AE%BE%E5%A4%87%E5%8D%87%E7%BA%A7%E4%B8%8E%E7%A1%AC%E4%BB%B6%E7%BB%B4%E4%BF%AE", "location": "运维平台", }, "it_device_repair": { "id": "it_device_repair", "name": "IT设备升级与硬件维修", "type": "jump", "keywords": ["设备升级", "硬件维修", "设备维修"], "url": "https://itsupport.servyou.com.cn/h5/itsm-bridge.html?name=IT%E8%AE%BE%E5%A4%87%E5%8D%87%E7%BA%A7%E4%B8%8E%E7%A1%AC%E4%BB%B6%E7%BB%B4%E4%BF%AE", "location": "运维平台", }, # --- 账号权限申请 --- "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://itsupport.servyou.com.cn/h5/itsm-bridge.html?name=%E5%91%98%E5%B7%A5%E9%9B%B6%E4%BF%A1%E4%BB%BB%EF%BC%88%E5%8E%9FVPN%EF%BC%89%E8%B4%A6%E5%8F%B7%E7%94%B3%E8%AF%B7", "location": "运维平台", }, # --- 软件服务申请 --- 改用ITSM工单系统(企微审批模板 3TmACf8D... 已失效) "software_service": { "id": "software_service", "name": "商业软件服务申请", "type": "jump", "keywords": ["软件", "商业软件", "软件服务"], "url": "https://itsupport.servyou.com.cn/h5/itsm-bridge.html?name=%E5%95%86%E4%B8%9A%E8%BD%AF%E4%BB%B6%E6%9C%8D%E5%8A%A1%E7%94%B3%E8%AF%B7", "location": "运维平台", }, # --- 资产处置申请 --- "asset_repair": { "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://itsupport.servyou.com.cn/h5/itsm-bridge.html?name=%E7%BB%88%E7%AB%AF%E8%AE%BE%E5%A4%87%E7%BD%91%E7%BB%9C%E5%87%86%E5%85%A5%E7%94%B3%E8%AF%B7", "location": "运维平台", }, # --- 活动与会议技术支持 --- "event_support": { "id": "event_support", "name": "活动与会议技术支持", "type": "jump", "keywords": ["活动支持", "会议支持", "技术支持", "活动技术"], "url": "https://itsupport.servyou.com.cn/h5/itsm-bridge.html?name=%E6%B4%BB%E5%8A%A8%E4%B8%8E%E4%BC%9A%E8%AE%AE%E6%8A%80%E6%9C%AF%E6%94%AF%E6%8C%81", "location": "运维平台", }, # --- 员工IT支持与故障报修 --- "it_support_repair": { "id": "it_support_repair", "name": "员工IT支持与故障报修", "type": "jump", "keywords": ["故障报修", "IT支持", "技术支持", "报修"], "url": "https://itsupport.servyou.com.cn/h5/itsm-bridge.html?name=%E5%91%98%E5%B7%A5IT%E6%94%AF%E6%8C%81%E4%B8%8E%E6%95%85%E9%9A%9C%E6%8A%A5%E4%BF%AE", "location": "运维平台", }, # --- 公共邮箱账号申请 --- "public_email": { "id": "public_email", "name": "公共邮箱账号申请", "type": "jump", "keywords": ["公共邮箱", "公共账号", "共享邮箱"], "url": "https://itsupport.servyou.com.cn/h5/itsm-bridge.html?name=%E5%85%AC%E5%85%B1%E9%82%AE%E7%AE%B1%E8%B4%A6%E5%8F%B7%E7%94%B3%E8%AF%B7", "location": "运维平台", }, } # ============================================================================= # 审批意图识别 — 关键词预过滤 & 降级兜底 # ============================================================================= # 说明:覆盖 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/Textarea(value.text)和 Selector(value.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)") # 构建请求 URL:base_url + /v1/chat-messages(Dify 原生 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, ))