Files
wecom_it_smart_desk/src/backend/app/api/approval.py
T

1328 lines
50 KiB
Python
Raw Normal View History

# =============================================================================
# IT智能服务台 — 审批流程 API
# =============================================================================
# 说明:提供审批模板管理和API提交功能
# - 模板详情获取
# - API提交审批申请
# - 审批状态回调处理
# =============================================================================
import asyncio
import json
import logging
import os
from datetime import datetime, timezone
from typing import Any, Dict, List, 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"
# ---------------------------------------------------------------------------
# 坐席待办回写相关常量(企微审批回调 → 服务台待办最终一致)
# ---------------------------------------------------------------------------
# 坐席待办列表缓存 Key 前缀(与 TodoAggregatorService._cache_key 保持一致)
# 实际格式:todo:cache:{agent_userid}:{todo_type_or_all}
TODO_CACHE_KEY_PREFIX = "todo:cache:"
# 待办列表缓存兜底 TTL(秒)—— 与 TodoAggregatorService.CACHE_TTL 对齐
TODO_CACHE_FALLBACK_TTL = 45
# 审批状态回写快照 Key(供排障/审计,以及缓存过期后的状态追溯)
TODO_APPROVAL_STATUS_KEY = "todo:approval:status:{sp_no}"
# 状态快照保留时长(秒):7 天
TODO_APPROVAL_STATUS_TTL = 7 * 24 * 3600
# status_change_event → 事件语义(企微 sys_approval_change
APPROVAL_EVENT_MAP: Dict[int, str] = {
1: "submitted", # 提单
2: "approved", # 同意
3: "rejected", # 驳回
4: "transferred", # 转审
5: "reminded", # 催办
6: "revoked", # 撤销
8: "revoked_after_approved", # 通过后撤销
10: "commented", # 添加备注
}
# status_change_event → 本地待办状态(pending/processing/resolved
# 说明:
# - 2 同意 / 3 驳回 / 6 撤销 / 8 通过后撤销 → 审批单已终结,移出坐席待办
# - 4 转审 → 对「当前这位审批人」而言同样已终结(单子转给了别人)
# - 1 提单 / 5 催办 / 10 备注 → 审批单仍在流转,待办保持 pending
APPROVAL_EVENT_TODO_STATUS: Dict[int, str] = {
1: "pending",
2: "resolved",
3: "resolved",
4: "resolved",
5: "pending",
6: "resolved",
8: "resolved",
10: "pending",
}
# sp_status → 本地待办状态(回调未带 status_change_event 时的兜底映射)
# sp_status: 1-审批中 2-已通过 3-已驳回 4-已撤销 6-通过后撤销 7-已删除 10-已支付
APPROVAL_SP_STATUS_TODO_STATUS: Dict[int, str] = {
1: "pending",
2: "resolved",
3: "resolved",
4: "resolved",
6: "resolved",
7: "resolved",
10: "resolved",
}
# Redis客户端(依赖注入)
async def get_redis() -> aioredis.Redis:
"""获取Redis客户端依赖
使用 settings.create_redis_client() 自建连接,而非从 app.main 导入 redis_client
(后者是 lifespan 函数内的局部变量,不可跨模块导入)。
"""
return settings.create_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": "企微审批",
"category": "办公用品申请",
"icon": "gift-o",
"desc": "超配额申领",
},
# --- 设备申请 ---
# IT资产领用 改用ITSM工单系统(企微审批模板 C4c8qt31... 已失效)
"asset_receive": {
"id": "asset_receive",
"name": "IT资产领用申请",
"type": "jump",
"keywords": ["资产领用", "领用登记", "设备领用"],
"url": "https://itsm.servyou.com.cn/itsm-miniapp-mobile/",
"location": "运维平台",
"category": "设备申请",
"icon": "orders-o",
"desc": "申请新设备",
},
# IT资产借用 改用ITSM工单系统(企微审批模板 3TmACnFs... 已失效)
"asset_borrow": {
"id": "asset_borrow",
"name": "IT资产借用申请",
"type": "jump",
"keywords": ["资产借用", "借用", "借用设备"],
"url": "https://itsm.servyou.com.cn/itsm-miniapp-mobile/",
"location": "运维平台",
"category": "设备申请",
"icon": "orders-o",
"desc": "临时借用设备",
},
# 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": "运维平台",
"category": "设备申请",
"icon": "orders-o",
"desc": "设备升级换新",
},
"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": "运维平台",
"category": "设备申请",
"icon": "warn-o",
"desc": "设备维修",
},
# --- 账号权限申请 ---
"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": "企微审批",
"category": "账号权限申请",
"icon": "lock",
"desc": "外部联系人权限",
},
"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": "运维平台",
"category": "账号权限申请",
"icon": "lock",
"desc": "零信任VPN",
},
# --- 软件服务申请 --- 改用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": "运维平台",
"category": "软件服务申请",
"icon": "apps-o",
"desc": "正版软件授权",
},
# --- 资产处置申请 ---
"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": "企微审批",
"category": "资产处置申请",
"icon": "warn-o",
"desc": "设备送修",
},
"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": "企微审批",
"category": "资产处置申请",
"icon": "delete-o",
"desc": "设备报废",
},
"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": "企微审批",
"category": "资产处置申请",
"icon": "back-top",
"desc": "退还设备",
},
# --- 会议室故障报修 ---
"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": "企微审批",
"category": "会议室故障报修",
"icon": "warn-o",
"desc": "会议室设备故障",
},
# --- 企业应用管理 ---
"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": "企微审批",
"category": "企业应用管理",
"icon": "apps-o",
"desc": "企业应用开通管理",
},
# --- 资产变更确认 ---
"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": "企微审批",
"category": "资产变更确认",
"icon": "exchange",
"desc": "资产信息变更",
},
# --- 终端设备网络准入 ---
"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": "运维平台",
"category": "终端设备网络准入",
"icon": "lock",
"desc": "终端网络准入",
},
# --- 活动与会议技术支持 ---
"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": "运维平台",
"category": "活动与会议技术支持",
"icon": "calendar-o",
"desc": "活动会议技术保障",
},
# --- 员工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": "运维平台",
"category": "员工IT支持与故障报修",
"icon": "warn-o",
"desc": "IT支持与故障报修",
},
# --- 公共邮箱账号申请 ---
"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": "运维平台",
"category": "公共邮箱账号申请",
"icon": "lock",
"desc": "公共邮箱账号",
},
}
# =============================================================================
# 审批意图识别 — 关键词预过滤 & 降级兜底
# =============================================================================
# 说明:覆盖 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
# =============================================================================
# 企微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}
# =============================================================================
# 审批回调 → 坐席待办状态回写(降级跳转方案的「最终一致」闭环)
# =============================================================================
# 背景:
# 企微官方不提供「服务端代审批人执行同意/拒绝/转交」的接口,坐席端的审批动作
# 只能降级为跳转企微原系统由本人操作。因此服务台侧的待办状态无法在动作发生的
# 那一刻同步更新,只能依赖企微 sys_approval_change 回调回写,达成最终一致。
#
# 关联键(approval_code / sp_no → 本地待办):
# 坐席待办的 id 由 ApprovalTodoService._map_to_todo_item 生成为 "approval:{sp_no}"
# description.sp_no 亦为同一值。因此企微回调携带的 sp_no 就是本地待办的反查键,
# 无需额外建立映射表。
#
# 存储现状(重要):
# 坐席待办当前**不落库**——todo_items 表(TodoItem 模型)虽已定义但全链路未接线,
# 列表由 TodoAggregatorService 实时聚合企微审批 + ITSM,并缓存在 Redis
# todo:cache:{agent_userid}:{type}TTL 45s)。
# 因此本回写作用于两处:
# 1) 就地改写命中的待办列表缓存条目(覆盖缓存未过期的 45s 窗口);
# 2) 写一份状态快照 todo:approval:status:{sp_no}TTL 7 天)供排障/审计。
# 缓存过期后由聚合层重新拉取企微权威数据,天然一致。
# =============================================================================
def _map_approval_todo_status(status_change_event: int, sp_status: int) -> str:
"""将企微审批回调映射为本地待办状态。
优先使用 status_change_event(语义更精确,可区分「转审」),
未命中时回退到 sp_status 映射,再兜底为 pending。
Args:
status_change_event: 企微状态变化类型(1提单/2同意/3驳回/4转审/
5催办/6撤销/8通过后撤销/10备注)
sp_status: 企微审批单状态(1审批中/2已通过/3已驳回/4已撤销/
6通过后撤销/7已删除/10已支付)
Returns:
str: 本地待办状态(pending/processing/resolved
"""
todo_status = APPROVAL_EVENT_TODO_STATUS.get(status_change_event)
if todo_status:
return todo_status
return APPROVAL_SP_STATUS_TODO_STATUS.get(sp_status, "pending")
def _parse_agent_userid_from_cache_key(cache_key: str) -> str:
"""从待办缓存 Key 中解析坐席 userid。
Key 格式:todo:cache:{agent_userid}:{todo_type_or_all}
userid 理论上不含冒号,但仍按「去掉前缀与末段」的方式解析以增强容错。
Args:
cache_key: Redis 缓存 Key(已 decode 为 str
Returns:
str: 坐席 userid,解析失败返回空字符串
"""
if not cache_key.startswith(TODO_CACHE_KEY_PREFIX):
return ""
remainder = cache_key[len(TODO_CACHE_KEY_PREFIX):]
if ":" not in remainder:
return ""
# 末段是 todo_typeall/approval/ticket),其余部分是 userid
return remainder.rsplit(":", 1)[0]
async def _patch_todo_cache(
redis: aioredis.Redis,
sp_no: str,
todo_status: str,
sp_status: int,
) -> List[str]:
"""就地改写待办列表缓存中命中的审批条目,并返回受影响的坐席 userid 列表。
做什么:扫描 todo:cache:*,找到 items 中 id == "approval:{sp_no}" 的条目,
更新其 status 与 description.sp_status,然后按剩余 TTL 写回。
为什么:待办不落库,缓存就是坐席端当前看到的「本地待办」;不改写的话,
坐席在缓存过期前仍会看到已终结的审批单。
单个 Key 处理失败不影响其他 Key。
Args:
redis: Redis 异步客户端
sp_no: 企微审批单号(本地待办反查键)
todo_status: 回写后的本地待办状态
sp_status: 企微审批单状态(同步写入 description.sp_status
Returns:
List[str]: 命中该审批单的坐席 userid 列表(去重,顺序稳定)
"""
item_id = f"approval:{sp_no}"
affected_agents: List[str] = []
seen_agents: set = set()
try:
keys = await redis.keys(f"{TODO_CACHE_KEY_PREFIX}*")
except Exception as e:
logger.warning(f"扫描待办缓存失败: sp_no={sp_no}, error={e}")
return affected_agents
for raw_key in keys or []:
key = raw_key.decode("utf-8") if isinstance(raw_key, bytes) else str(raw_key)
try:
raw_value = await redis.get(key)
if not raw_value:
continue
if isinstance(raw_value, bytes):
raw_value = raw_value.decode("utf-8")
payload: Dict[str, Any] = json.loads(raw_value)
items = payload.get("items")
if not isinstance(items, list):
continue
matched = False
for item in items:
if not isinstance(item, dict) or item.get("id") != item_id:
continue
item["status"] = todo_status
description = item.get("description")
if isinstance(description, dict):
description["sp_status"] = sp_status
matched = True
if not matched:
continue
# 保留剩余 TTL 写回(拿不到有效 TTL 时用兜底值,避免写成永不过期)
ttl = await redis.ttl(key)
if not isinstance(ttl, int) or ttl <= 0:
ttl = TODO_CACHE_FALLBACK_TTL
await redis.setex(key, ttl, json.dumps(payload, ensure_ascii=False))
agent_userid = _parse_agent_userid_from_cache_key(key)
if agent_userid and agent_userid not in seen_agents:
seen_agents.add(agent_userid)
affected_agents.append(agent_userid)
except Exception as e:
logger.warning(f"回写待办缓存失败: key={key}, sp_no={sp_no}, error={e}")
continue
return affected_agents
async def _save_approval_status_snapshot(
redis: aioredis.Redis,
sp_no: str,
snapshot: Dict[str, Any],
) -> None:
"""持久化一份审批状态回写快照(TTL 7 天)。
用途:待办列表缓存只有 45s,快照可用于排障、审计以及回调乱序时的追溯。
Args:
redis: Redis 异步客户端
sp_no: 审批单号
snapshot: 快照内容
"""
try:
await redis.setex(
TODO_APPROVAL_STATUS_KEY.format(sp_no=sp_no),
TODO_APPROVAL_STATUS_TTL,
json.dumps(snapshot, ensure_ascii=False),
)
except Exception as e:
logger.warning(f"写入审批状态快照失败: sp_no={sp_no}, error={e}")
async def _push_todo_status_to_agents(
agent_userids: List[str], payload: Dict[str, Any]
) -> None:
"""向相关坐席推送待办状态变更事件(复用现有 WS 推送通道)。
推送失败不抛异常(坐席可能不在线),由前端下次拉取兜底。
Args:
agent_userids: 目标坐席 userid 列表
payload: 事件数据(对应前端 msg.data)
"""
if not agent_userids:
return
# 延迟导入,避免 api 层与 services 层在模块加载期形成循环依赖
from app.services.ws_manager import manager as ws_manager
message = {"type": "todo_status_changed", "data": payload}
for agent_userid in agent_userids:
try:
await ws_manager.send_to_agent(agent_userid, message)
except Exception as e:
logger.warning(
f"推送待办状态变更失败: agent={agent_userid}, "
f"sp_no={payload.get('sp_no')}, error={e}"
)
async def writeback_approval_todo_status(
sp_no: str,
sp_status: int,
status_change_event: int,
redis: aioredis.Redis,
template_id: str = "",
) -> Dict[str, Any]:
"""企微审批回调 → 坐席待办状态回写 + WS 推送(最终一致)。
流程:
1. 映射 status_change_event/sp_status → 本地待办状态
2. 就地改写命中的待办列表缓存条目,得到受影响坐席
3. 写入状态快照(TTL 7 天)
4. 向受影响坐席推送 todo_status_changed 事件
全流程 try/except 保护:回写失败只记日志,绝不影响回调 ACK
(企微回调失败会重试,且服务台侧有 45s 缓存过期兜底)。
Args:
sp_no: 审批单号(本地待办反查键,本地 id = "approval:{sp_no}"
sp_status: 企微审批单状态
status_change_event: 企微状态变化类型
redis: Redis 异步客户端
template_id: 审批模板 ID(可选,仅用于日志与快照)
Returns:
Dict[str, Any]: 回写结果 {success, sp_no, status, agents}
"""
if not sp_no:
return {"success": False, "sp_no": sp_no, "message": "sp_no 为空,跳过回写"}
event_type = APPROVAL_EVENT_MAP.get(
status_change_event, f"unknown_{status_change_event}"
)
todo_status = _map_approval_todo_status(status_change_event, sp_status)
updated_at = datetime.now(timezone.utc).isoformat()
try:
# 1. 回写待办列表缓存,拿到受影响坐席
affected_agents = await _patch_todo_cache(redis, sp_no, todo_status, sp_status)
# 2. 持久化状态快照
snapshot: Dict[str, Any] = {
"sp_no": sp_no,
"template_id": template_id,
"sp_status": sp_status,
"status_change_event": status_change_event,
"event_type": event_type,
"todo_status": todo_status,
"affected_agents": affected_agents,
"updated_at": updated_at,
}
await _save_approval_status_snapshot(redis, sp_no, snapshot)
# 3. 推送待办状态变更(仅在有命中坐席时推送)
await _push_todo_status_to_agents(
affected_agents,
{
"item_id": f"approval:{sp_no}",
"todo_type": "approval",
"sp_no": sp_no,
"sp_status": sp_status,
"status": todo_status,
"event_type": event_type,
"updated_at": updated_at,
},
)
logger.info(
f"审批待办状态回写完成: sp_no={sp_no}, event={event_type}, "
f"todo_status={todo_status}, agents={affected_agents}"
)
return {
"success": True,
"sp_no": sp_no,
"status": todo_status,
"event_type": event_type,
"agents": affected_agents,
}
except Exception as e:
logger.error(f"审批待办状态回写失败: sp_no={sp_no}, error={e}", exc_info=True)
return {"success": False, "sp_no": sp_no, "message": f"回写失败: {e}"}
# =============================================================================
# API 端点
# =============================================================================
@router.get("/approval/templates", response_model=list[ApprovalTemplateResponse])
async def get_approval_templates():
"""获取所有审批模板列表"""
return list(APPROVAL_TEMPLATES.values())
@router.get("/approval/all-categories-card")
async def get_all_categories_card():
"""v4.0 P0-3: 获取全量审批卡片数据(快捷申请按钮用)
返回标准化 card_data,前端纯渲染(无需前端再做匹配)。
与 ApprovalCardModal 的 CardData 接口结构一致。
"""
from app.services.approval_matcher import get_approval_matcher
matcher = get_approval_matcher()
categories = matcher.get_all_categories()
# 扁平化所有分类的 options
all_options = []
for cat in categories:
all_options.extend(cat.get("options", []))
return {
"card_type": "multiple",
"title": "审批申请",
"description": "请选择审批类型",
"options": all_options,
}
@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}")
event_type = APPROVAL_EVENT_MAP.get(status_change_event, f"unknown_{status_change_event}")
logger.info(f"审批事件类型: {event_type}")
# 回写坐席端待办状态 + WS 推送(异步执行,不阻塞回调响应)
# 说明:审批动作只能由审批人在企微原系统完成,服务台通过本回调达成最终一致。
asyncio.create_task(
writeback_approval_todo_status(
sp_no=sp_no,
sp_status=sp_status,
status_change_event=status_change_event,
redis=redis,
template_id=template_id,
)
)
# 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)
# v4.0 批次4GET /approval/keywords 端点已删除
# (前端 getApprovalKeywords 调用已随 v3.0 ApprovalCardModal 重构移除,
# 仅剩 .bak 备份文件引用;关键词匹配现由 ApprovalMatcher 后端统一处理)