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

1001 lines
37 KiB
Python
Raw Normal View History

2026-06-15 09:32:41 +08:00
# =============================================================================
# IT智能服务台 — 审批流程 API
# =============================================================================
# 说明:提供审批模板管理和API提交功能
# - 模板详情获取
# - API提交审批申请
# - 审批状态回调处理
2026-06-15 09:32:41 +08:00
# =============================================================================
import asyncio
import logging
import os
2026-06-15 09:32:41 +08:00
from typing import Optional
import httpx
2026-06-15 09:32:41 +08:00
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
import redis.asyncio as aioredis
2026-06-15 09:32:41 +08:00
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__)
2026-06-15 09:32:41 +08:00
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
2026-06-15 09:32:41 +08:00
2026-06-15 14:14:58 +08:00
# =============================================================================
# 审批模板配置(静态硬编码 — 18个审批流程,12种审批类型)
2026-06-15 14:14:58 +08:00
# =============================================================================
# 数据来源:docs/02-产品需求/approval_templates.json
# 所有模板 type 统一为 "jump"(直接URL跳转)
2026-06-15 14:14:58 +08:00
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": "公共邮箱账号",
},
}
2026-06-15 14:14:58 +08:00
# =============================================================================
# 审批意图识别 — 关键词预过滤 & 降级兜底
# =============================================================================
# 说明:覆盖 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支持与故障报修",
# 公共邮箱账号申请
"公共邮箱": "公共邮箱账号申请", "公共账号": "公共邮箱账号申请", "共享邮箱": "公共邮箱账号申请",
}
2026-06-15 09:32:41 +08:00
# =============================================================================
# Schema 定义
# =============================================================================
class ApprovalTemplateResponse(BaseModel):
"""审批模板响应"""
id: str
name: str
type: str
2026-06-15 09:32:41 +08:00
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 # 控件值
2026-06-15 09:32:41 +08:00
class ApprovalSubmitRequest(BaseModel):
"""API提交审批请求"""
template_id: str
employee_id: str # 申请人userid
contents: list[ApprovalContentItem] # 表单内容
use_template_approver: int = 1 # 1-使用模板预设流程
2026-06-15 09:32:41 +08:00
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}
2026-06-15 09:32:41 +08:00
# =============================================================================
# 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,
}
2026-06-15 09:32:41 +08:00
@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")
2026-06-15 09:32:41 +08:00
async def create_approval_jump(request: ApprovalJumpRequest):
"""生成跳转审批链接"""
2026-06-15 09:32:41 +08:00
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(
2026-06-15 09:32:41 +08:00
url=jump_url,
template_name=template["name"],
))
2026-06-15 09:32:41 +08:00
@router.post("/approval/submit", response_model=ApprovalSubmitResponse)
async def submit_approval(
request: ApprovalSubmitRequest,
redis: aioredis.Redis = Depends(get_redis)
):
"""API提交审批申请"""
2026-06-15 09:32:41 +08:00
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)
2026-06-15 09:32:41 +08:00
# 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"}
2026-06-15 09:32:41 +08:00
@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)
2026-06-15 09:32:41 +08:00
@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)