feat(agent/backend): 坐席端审批线降级跳转 + 回调最终一致回写
实现 Phase 0 审批线 T01+T02(依据 PRD-REQ-坐席-011 + U-1 技术验证结论)。
前端(T01):
- TaskDetailView.handleAction 移除 mock toast,审批类仅 console.info
- ApprovalDetail 通过/拒绝/转交 + 打开按钮接线为企微审批深链真实跳转(<a target="_blank">)
- useWebSocket 新增 todo_status_changed 实时刷新分支
后端(T02):
- approval.py 新增 writeback_approval_todo_status 主入口 + /approval/callback 接线
- approval_webhook.py 新增 _writeback_agent_todo 复用回调→WS 推送通道
- 以 approval:{sp_no} 为关联键,缓存就地改写 + 7天快照 + WS 推送,最终一致
测试:src/backend/tests/test_approval_todo_writeback.py(51 例全绿)
文档:PRD-REQ-坐席-011 v0.1、技术验证-U-1 v1.0
This commit is contained in:
+355
-19
@@ -8,9 +8,11 @@
|
||||
# =============================================================================
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Optional
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
@@ -29,6 +31,63 @@ 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客户端依赖"""
|
||||
@@ -775,6 +834,288 @@ async def _do_asset_urge(sp_no: str, redis: aioredis.Redis) -> dict:
|
||||
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_type(all/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 端点
|
||||
# =============================================================================
|
||||
@@ -935,26 +1276,21 @@ async def approval_callback(
|
||||
"""
|
||||
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}")
|
||||
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}")
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
# - H5 接口用 Header X-Employee-Id(与现有接口风格一致 — 实际生产应加 RBAC)
|
||||
# =============================================================================
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from typing import List, Optional
|
||||
@@ -98,6 +99,64 @@ def _verify_h5_employee(x_employee_id: Optional[str]) -> str:
|
||||
return x_employee_id
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 坐席端待办回写(复用本文件的「回调 → 推送」通道)
|
||||
# =============================================================================
|
||||
# 本文件原有的推送链路面向 H5 员工端(recommend_update 进度卡片)。
|
||||
# 审批降级跳转方案还需要把状态回写到**坐席端待办**,因此在同一回调入口处
|
||||
# 追加一次坐席侧回写 + WS 推送,两条链路互不影响。
|
||||
#
|
||||
# 关联键:payload.approval_id 即企微审批单号 sp_no,
|
||||
# 坐席待办 id = "approval:{sp_no}"(见 ApprovalTodoService._map_to_todo_item)。
|
||||
# =============================================================================
|
||||
|
||||
# webhook 字符串状态 → (sp_status, status_change_event)
|
||||
# 与企微 sys_approval_change 的数值语义对齐,便于复用同一套回写逻辑。
|
||||
_WEBHOOK_STATUS_TO_WECOM: dict = {
|
||||
"pending": (1, 1), # 审批中 / 提单
|
||||
"approved": (2, 2), # 已通过 / 同意
|
||||
"completed": (2, 2), # 已完成(等同通过)
|
||||
"rejected": (3, 3), # 已驳回 / 驳回
|
||||
"cancelled": (4, 6), # 已撤销 / 撤销
|
||||
}
|
||||
|
||||
|
||||
async def _writeback_agent_todo(approval_id: str, status: str) -> None:
|
||||
"""把审批状态回写到坐席端待办并推送(失败不影响 webhook ACK)。
|
||||
|
||||
Args:
|
||||
approval_id: 审批单号(= 企微 sp_no)
|
||||
status: webhook 上报的字符串状态
|
||||
"""
|
||||
mapping = _WEBHOOK_STATUS_TO_WECOM.get((status or "").strip().lower())
|
||||
if not mapping:
|
||||
logger.debug("[ApprovalWebhook] 状态 %r 无需回写坐席待办", status)
|
||||
return
|
||||
|
||||
sp_status, status_change_event = mapping
|
||||
|
||||
# 延迟导入:避免 api 模块之间在加载期相互依赖
|
||||
from app.api.approval import writeback_approval_todo_status
|
||||
|
||||
redis_client = settings.create_redis_client()
|
||||
try:
|
||||
await writeback_approval_todo_status(
|
||||
sp_no=approval_id,
|
||||
sp_status=sp_status,
|
||||
status_change_event=status_change_event,
|
||||
redis=redis_client,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"[ApprovalWebhook] 坐席待办回写异常: approval=%s, error=%s", approval_id, e
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
await redis_client.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Pydantic models — 4 个端点的请求/响应
|
||||
# =============================================================================
|
||||
@@ -200,12 +259,16 @@ async def approval_webhook(
|
||||
鉴权:Header X-WeCom-Token = 环境变量 WECOM_WEBHOOK_TOKEN
|
||||
|
||||
行为:
|
||||
- 调 `svc.update_progress()` 落库 + WS 推送右侧栏
|
||||
- 调 `svc.update_progress()` 落库 + WS 推送右侧栏(H5 员工端进度卡片)
|
||||
- 同步回写坐席端待办状态 + WS 推送(审批降级跳转方案的最终一致闭环)
|
||||
- 若 approval_id 不存在(提前于 H5 initial),返回 action='skipped'
|
||||
让企微知道"我们已收到但暂无可更新记录"
|
||||
"""
|
||||
_verify_wecom_token(x_wecom_token)
|
||||
|
||||
# 坐席端待办回写(异步执行,不阻塞 webhook ACK;失败已在内部吞掉)
|
||||
asyncio.create_task(_writeback_agent_todo(payload.approval_id, payload.status))
|
||||
|
||||
try:
|
||||
rec = svc.update_progress(
|
||||
approval_id=payload.approval_id,
|
||||
|
||||
@@ -0,0 +1,764 @@
|
||||
# =============================================================================
|
||||
# QA 回归测试 — 企微审批回调 → 坐席待办状态回写(Phase 0 审批线 T02)
|
||||
# =============================================================================
|
||||
# 背景(PRD / 设计约束):
|
||||
# 企微官方不提供「服务端代审批人执行同意/拒绝/转交」的接口,坐席端审批动作
|
||||
# 已降级为「跳转企微原系统由本人操作」。服务台侧的待办状态因此只能依赖企微
|
||||
# sys_approval_change 回调回写,达成最终一致。
|
||||
#
|
||||
# 本文件独立验证 src/backend/app/api/approval.py 与 approval_webhook.py 中新增的
|
||||
# 回写链路,覆盖:
|
||||
# 1. 状态映射(status_change_event / sp_status → 本地 pending|resolved)
|
||||
# 2. 缓存就地改写(命中 / 不误伤其他单 / TTL 保留 / 多坐席 / 脏数据容错)
|
||||
# 3. 状态快照(todo:approval:status:{sp_no},TTL 7 天)
|
||||
# 4. WS 推送(type=todo_status_changed,payload 字段完整)
|
||||
# 5. 回调端点 POST /approval/callback 触发回写
|
||||
# 6. webhook 路径 _writeback_agent_todo 的字符串状态 → 企微数值语义映射
|
||||
#
|
||||
# 依赖:本文件自带 FakeRedis(conftest 的 MockRedis 缺少 keys/ttl,
|
||||
# 无法驱动 _patch_todo_cache 的扫描逻辑)。不需要真实 Redis / 企微环境。
|
||||
# =============================================================================
|
||||
|
||||
import asyncio
|
||||
import fnmatch
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.api import approval as approval_mod
|
||||
from app.api import approval_webhook as webhook_mod
|
||||
from app.api.approval import (
|
||||
TODO_APPROVAL_STATUS_KEY,
|
||||
TODO_APPROVAL_STATUS_TTL,
|
||||
TODO_CACHE_FALLBACK_TTL,
|
||||
_map_approval_todo_status,
|
||||
_parse_agent_userid_from_cache_key,
|
||||
writeback_approval_todo_status,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 测试替身:FakeRedis
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class FakeRedis:
|
||||
"""最小可用的异步 Redis 替身,覆盖回写链路用到的命令。
|
||||
|
||||
与 redis-py(decode_responses=False)行为对齐:get/keys 返回 bytes。
|
||||
通过 ``decode_responses=True`` 可切换为返回 str,用于验证源码的双形态兼容。
|
||||
"""
|
||||
|
||||
def __init__(self, decode_responses: bool = False) -> None:
|
||||
self._data: Dict[str, str] = {}
|
||||
self._ttl: Dict[str, int] = {}
|
||||
self.decode_responses = decode_responses
|
||||
self.closed = False
|
||||
# 故障注入:设为异常实例后,对应命令抛出该异常
|
||||
self.fail_on_keys: Optional[Exception] = None
|
||||
self.fail_on_setex: Optional[Exception] = None
|
||||
|
||||
# -- 内部工具 ---------------------------------------------------------
|
||||
def _out(self, value: str):
|
||||
return value if self.decode_responses else value.encode("utf-8")
|
||||
|
||||
# -- Redis 命令 -------------------------------------------------------
|
||||
async def keys(self, pattern: str) -> List[Any]:
|
||||
if self.fail_on_keys is not None:
|
||||
raise self.fail_on_keys
|
||||
return [self._out(k) for k in self._data if fnmatch.fnmatch(k, pattern)]
|
||||
|
||||
async def get(self, key: str):
|
||||
value = self._data.get(key)
|
||||
return None if value is None else self._out(value)
|
||||
|
||||
async def setex(self, key: str, ttl: int, value: str) -> bool:
|
||||
if self.fail_on_setex is not None:
|
||||
raise self.fail_on_setex
|
||||
self._data[key] = value
|
||||
self._ttl[key] = ttl
|
||||
return True
|
||||
|
||||
async def ttl(self, key: str) -> int:
|
||||
if key not in self._data:
|
||||
return -2 # key 不存在
|
||||
return self._ttl.get(key, -1) # -1 = 无过期时间
|
||||
|
||||
async def delete(self, *keys) -> int:
|
||||
removed = 0
|
||||
for key in keys:
|
||||
if key in self._data:
|
||||
del self._data[key]
|
||||
self._ttl.pop(key, None)
|
||||
removed += 1
|
||||
return removed
|
||||
|
||||
async def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
# -- 测试辅助 ---------------------------------------------------------
|
||||
def seed_json(self, key: str, payload: Dict[str, Any], ttl: int = 45) -> None:
|
||||
self._data[key] = json.dumps(payload, ensure_ascii=False)
|
||||
self._ttl[key] = ttl
|
||||
|
||||
def seed_raw(self, key: str, raw: str, ttl: int = 45) -> None:
|
||||
self._data[key] = raw
|
||||
self._ttl[key] = ttl
|
||||
|
||||
def load_json(self, key: str) -> Dict[str, Any]:
|
||||
return json.loads(self._data[key])
|
||||
|
||||
def ttl_of(self, key: str) -> Optional[int]:
|
||||
return self._ttl.get(key)
|
||||
|
||||
|
||||
class _FakeSettings:
|
||||
"""替身 settings:仅暴露 create_redis_client,返回注入的 FakeRedis。
|
||||
|
||||
直接 patch ``webhook_mod.settings.create_redis_client`` 会触碰 pydantic
|
||||
frozen 实例的属性描述符而报错;改为替换模块级 ``settings`` 全局名,既避开
|
||||
pydantic 内部又保留 ``_writeback_agent_todo`` 对 ``settings.create_redis_client``
|
||||
的调用语义。
|
||||
"""
|
||||
|
||||
def __init__(self, client: "FakeRedis") -> None:
|
||||
self._client = client
|
||||
|
||||
def create_redis_client(self):
|
||||
return self._client
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 测试数据工厂
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def make_approval_item(sp_no: str, status: str = "pending", sp_status: int = 1) -> Dict[str, Any]:
|
||||
"""构造一条与 TodoSourceService._map_to_todo_item 同构的审批待办条目。"""
|
||||
return {
|
||||
"id": f"approval:{sp_no}",
|
||||
"type": "approval",
|
||||
"title": "IT资产升级申请",
|
||||
"priority": "high",
|
||||
"status": status,
|
||||
"description": {
|
||||
"sp_no": sp_no,
|
||||
"template_name": "IT资产升级申请",
|
||||
"template_id": "Bs7ucTGsPuFhxfk8pn8EydxrWxkVetB4JR8Pb6PHS",
|
||||
"applicant": "zhangsan",
|
||||
"apply_time": 1754500000,
|
||||
"sp_status": sp_status,
|
||||
"current_approver": "agent001",
|
||||
},
|
||||
"assigned_agent_id": "agent001",
|
||||
"corp_id": "test_corp",
|
||||
"created_at": "2026-08-08T10:00:00",
|
||||
"updated_at": "2026-08-08T10:00:00",
|
||||
}
|
||||
|
||||
|
||||
def make_ticket_item(ticket_id: str = "T1001") -> Dict[str, Any]:
|
||||
"""构造一条工单待办条目(用于验证非审批条目不受影响)。"""
|
||||
return {
|
||||
"id": f"ticket:{ticket_id}",
|
||||
"type": "ticket",
|
||||
"title": "打印机故障",
|
||||
"priority": "normal",
|
||||
"status": "pending",
|
||||
"description": {"ticket_id": ticket_id},
|
||||
"assigned_agent_id": "agent001",
|
||||
"corp_id": "test_corp",
|
||||
"created_at": "2026-08-08T10:00:00",
|
||||
"updated_at": "2026-08-08T10:00:00",
|
||||
}
|
||||
|
||||
|
||||
def cache_key(agent: str, todo_type: str = "all") -> str:
|
||||
"""与 TodoAggregatorService._cache_key 保持一致的 Key 拼接。"""
|
||||
return f"todo:cache:{agent}:{todo_type}"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_redis() -> FakeRedis:
|
||||
return FakeRedis()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ws_send() -> AsyncMock:
|
||||
"""替换 ws_manager.send_to_agent,捕获 WS 推送。"""
|
||||
from app.services import ws_manager as ws_manager_mod
|
||||
|
||||
mock = AsyncMock()
|
||||
with patch.object(ws_manager_mod.manager, "send_to_agent", mock):
|
||||
yield mock
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 1. 状态映射(纯函数)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestApprovalStatusMapping:
|
||||
"""status_change_event / sp_status → 本地待办状态。"""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"event, expected",
|
||||
[
|
||||
(1, "pending"), # 提单:单子仍在流转
|
||||
(2, "resolved"), # 同意:终结
|
||||
(3, "resolved"), # 驳回:终结
|
||||
(4, "resolved"), # 转审:对当前审批人已终结
|
||||
(5, "pending"), # 催办:仍在流转
|
||||
(6, "resolved"), # 撤销:终结
|
||||
(8, "resolved"), # 通过后撤销:终结
|
||||
(10, "pending"), # 添加备注:仍在流转
|
||||
],
|
||||
)
|
||||
def test_event_maps_to_expected_todo_status(self, event: int, expected: str):
|
||||
# sp_status 传 1(审批中)以确保结果确实来自 event 映射而非兜底
|
||||
assert _map_approval_todo_status(event, 1) == expected
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sp_status, expected",
|
||||
[
|
||||
(1, "pending"),
|
||||
(2, "resolved"),
|
||||
(3, "resolved"),
|
||||
(4, "resolved"),
|
||||
(6, "resolved"),
|
||||
(7, "resolved"),
|
||||
(10, "resolved"),
|
||||
],
|
||||
)
|
||||
def test_unknown_event_falls_back_to_sp_status(self, sp_status: int, expected: str):
|
||||
# event=99 不在映射表中 → 回退 sp_status 映射
|
||||
assert _map_approval_todo_status(99, sp_status) == expected
|
||||
|
||||
def test_unknown_event_and_unknown_sp_status_defaults_pending(self):
|
||||
assert _map_approval_todo_status(99, 999) == "pending"
|
||||
|
||||
|
||||
class TestCacheKeyParsing:
|
||||
"""待办缓存 Key → 坐席 userid。"""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"key, expected",
|
||||
[
|
||||
("todo:cache:agent001:all", "agent001"),
|
||||
("todo:cache:agent001:approval", "agent001"),
|
||||
("todo:cache:WangWu:ticket", "WangWu"),
|
||||
# 容错:userid 内含冒号时按「去掉前缀与末段」解析
|
||||
("todo:cache:corp:agent001:all", "corp:agent001"),
|
||||
# 非本前缀 / 缺末段 → 空串
|
||||
("other:cache:agent001:all", ""),
|
||||
("todo:cache:agent001", ""),
|
||||
],
|
||||
)
|
||||
def test_parse_agent_userid(self, key: str, expected: str):
|
||||
assert _parse_agent_userid_from_cache_key(key) == expected
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 2~4. 回写主入口:缓存改写 + 快照 + WS 推送
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestWritebackApprovalTodoStatus:
|
||||
"""writeback_approval_todo_status 主入口。"""
|
||||
|
||||
async def test_approved_event_updates_cache_snapshot_and_agents(
|
||||
self, fake_redis: FakeRedis, ws_send: AsyncMock
|
||||
):
|
||||
# Arrange:坐席 agent001 的待办缓存中有一条 pending 的 SP001
|
||||
key = cache_key("agent001")
|
||||
fake_redis.seed_json(key, {"items": [make_approval_item("SP001")], "total": 1})
|
||||
|
||||
# Act:企微回调「同意」
|
||||
result = await writeback_approval_todo_status(
|
||||
sp_no="SP001",
|
||||
sp_status=2,
|
||||
status_change_event=2,
|
||||
redis=fake_redis,
|
||||
template_id="TPL_X",
|
||||
)
|
||||
|
||||
# Assert 1:返回值
|
||||
assert result["success"] is True
|
||||
assert result["status"] == "resolved"
|
||||
assert result["event_type"] == "approved"
|
||||
assert result["agents"] == ["agent001"]
|
||||
|
||||
# Assert 2:缓存条目就地改写
|
||||
item = fake_redis.load_json(key)["items"][0]
|
||||
assert item["status"] == "resolved"
|
||||
assert item["description"]["sp_status"] == 2
|
||||
|
||||
# Assert 3:7 天状态快照
|
||||
snap_key = TODO_APPROVAL_STATUS_KEY.format(sp_no="SP001")
|
||||
snapshot = fake_redis.load_json(snap_key)
|
||||
assert fake_redis.ttl_of(snap_key) == TODO_APPROVAL_STATUS_TTL
|
||||
assert snapshot["sp_no"] == "SP001"
|
||||
assert snapshot["template_id"] == "TPL_X"
|
||||
assert snapshot["sp_status"] == 2
|
||||
assert snapshot["status_change_event"] == 2
|
||||
assert snapshot["event_type"] == "approved"
|
||||
assert snapshot["todo_status"] == "resolved"
|
||||
assert snapshot["affected_agents"] == ["agent001"]
|
||||
assert snapshot["updated_at"]
|
||||
|
||||
# Assert 4:WS 推送
|
||||
ws_send.assert_awaited_once()
|
||||
agent_arg, message = ws_send.await_args.args
|
||||
assert agent_arg == "agent001"
|
||||
assert message["type"] == "todo_status_changed"
|
||||
assert message["data"]["item_id"] == "approval:SP001"
|
||||
assert message["data"]["todo_type"] == "approval"
|
||||
assert message["data"]["sp_no"] == "SP001"
|
||||
assert message["data"]["sp_status"] == 2
|
||||
assert message["data"]["status"] == "resolved"
|
||||
assert message["data"]["event_type"] == "approved"
|
||||
|
||||
async def test_submitted_event_keeps_pending(self, fake_redis: FakeRedis, ws_send: AsyncMock):
|
||||
key = cache_key("agent001")
|
||||
fake_redis.seed_json(key, {"items": [make_approval_item("SP001")], "total": 1})
|
||||
|
||||
result = await writeback_approval_todo_status(
|
||||
sp_no="SP001", sp_status=1, status_change_event=1, redis=fake_redis
|
||||
)
|
||||
|
||||
assert result["status"] == "pending"
|
||||
assert result["event_type"] == "submitted"
|
||||
item = fake_redis.load_json(key)["items"][0]
|
||||
assert item["status"] == "pending"
|
||||
assert item["description"]["sp_status"] == 1
|
||||
|
||||
async def test_rejected_event_resolves(self, fake_redis: FakeRedis, ws_send: AsyncMock):
|
||||
key = cache_key("agent001")
|
||||
fake_redis.seed_json(key, {"items": [make_approval_item("SP001")], "total": 1})
|
||||
|
||||
result = await writeback_approval_todo_status(
|
||||
sp_no="SP001", sp_status=3, status_change_event=3, redis=fake_redis
|
||||
)
|
||||
|
||||
assert result["status"] == "resolved"
|
||||
assert result["event_type"] == "rejected"
|
||||
item = fake_redis.load_json(key)["items"][0]
|
||||
assert item["status"] == "resolved"
|
||||
assert item["description"]["sp_status"] == 3
|
||||
|
||||
async def test_other_sp_no_not_touched(self, fake_redis: FakeRedis, ws_send: AsyncMock):
|
||||
"""不匹配的审批单(SP999)与工单条目必须原样保留。"""
|
||||
key = cache_key("agent001")
|
||||
fake_redis.seed_json(
|
||||
key,
|
||||
{
|
||||
"items": [
|
||||
make_approval_item("SP001"),
|
||||
make_approval_item("SP999"),
|
||||
make_ticket_item("T1001"),
|
||||
],
|
||||
"total": 3,
|
||||
},
|
||||
)
|
||||
|
||||
await writeback_approval_todo_status(
|
||||
sp_no="SP001", sp_status=2, status_change_event=2, redis=fake_redis
|
||||
)
|
||||
|
||||
items = {i["id"]: i for i in fake_redis.load_json(key)["items"]}
|
||||
assert items["approval:SP001"]["status"] == "resolved"
|
||||
assert items["approval:SP999"]["status"] == "pending"
|
||||
assert items["approval:SP999"]["description"]["sp_status"] == 1
|
||||
assert items["ticket:T1001"]["status"] == "pending"
|
||||
|
||||
async def test_empty_sp_no_is_skipped_safely(self, fake_redis: FakeRedis, ws_send: AsyncMock):
|
||||
key = cache_key("agent001")
|
||||
fake_redis.seed_json(key, {"items": [make_approval_item("SP001")], "total": 1})
|
||||
|
||||
result = await writeback_approval_todo_status(
|
||||
sp_no="", sp_status=2, status_change_event=2, redis=fake_redis
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "sp_no" in result["message"]
|
||||
# 未触碰缓存、未写快照、未推送
|
||||
assert fake_redis.load_json(key)["items"][0]["status"] == "pending"
|
||||
assert TODO_APPROVAL_STATUS_KEY.format(sp_no="") not in fake_redis._data
|
||||
ws_send.assert_not_awaited()
|
||||
|
||||
async def test_multiple_agents_and_types_all_patched(
|
||||
self, fake_redis: FakeRedis, ws_send: AsyncMock
|
||||
):
|
||||
"""同一审批单出现在多个坐席、多种 type 的缓存里时全部改写并去重推送。"""
|
||||
fake_redis.seed_json(
|
||||
cache_key("agent001", "all"), {"items": [make_approval_item("SP001")]}
|
||||
)
|
||||
fake_redis.seed_json(
|
||||
cache_key("agent001", "approval"), {"items": [make_approval_item("SP001")]}
|
||||
)
|
||||
fake_redis.seed_json(
|
||||
cache_key("agent002", "all"), {"items": [make_approval_item("SP001")]}
|
||||
)
|
||||
# 未命中该单的坐席不应出现在 agents 中
|
||||
fake_redis.seed_json(
|
||||
cache_key("agent003", "all"), {"items": [make_approval_item("SP777")]}
|
||||
)
|
||||
|
||||
result = await writeback_approval_todo_status(
|
||||
sp_no="SP001", sp_status=2, status_change_event=2, redis=fake_redis
|
||||
)
|
||||
|
||||
assert sorted(result["agents"]) == ["agent001", "agent002"]
|
||||
# agent001 出现在两个 Key 中,但只推送一次
|
||||
assert ws_send.await_count == 2
|
||||
for k in (cache_key("agent001", "all"), cache_key("agent001", "approval"), cache_key("agent002")):
|
||||
assert fake_redis.load_json(k)["items"][0]["status"] == "resolved"
|
||||
assert fake_redis.load_json(cache_key("agent003"))["items"][0]["status"] == "pending"
|
||||
|
||||
async def test_remaining_ttl_is_preserved(self, fake_redis: FakeRedis, ws_send: AsyncMock):
|
||||
key = cache_key("agent001")
|
||||
fake_redis.seed_json(key, {"items": [make_approval_item("SP001")]}, ttl=30)
|
||||
|
||||
await writeback_approval_todo_status(
|
||||
sp_no="SP001", sp_status=2, status_change_event=2, redis=fake_redis
|
||||
)
|
||||
|
||||
assert fake_redis.ttl_of(key) == 30
|
||||
|
||||
async def test_missing_ttl_falls_back_to_default(self, fake_redis: FakeRedis, ws_send: AsyncMock):
|
||||
"""TTL 为 -1(永不过期)时必须落到兜底 45s,避免把缓存写成永久。"""
|
||||
key = cache_key("agent001")
|
||||
fake_redis.seed_raw(
|
||||
key, json.dumps({"items": [make_approval_item("SP001")]}, ensure_ascii=False)
|
||||
)
|
||||
fake_redis._ttl[key] = -1
|
||||
|
||||
await writeback_approval_todo_status(
|
||||
sp_no="SP001", sp_status=2, status_change_event=2, redis=fake_redis
|
||||
)
|
||||
|
||||
assert fake_redis.ttl_of(key) == TODO_CACHE_FALLBACK_TTL
|
||||
|
||||
async def test_decoded_string_redis_client_supported(self, ws_send: AsyncMock):
|
||||
"""decode_responses=True 的客户端(返回 str)同样能正确回写。"""
|
||||
redis = FakeRedis(decode_responses=True)
|
||||
key = cache_key("agent001")
|
||||
redis.seed_json(key, {"items": [make_approval_item("SP001")]})
|
||||
|
||||
result = await writeback_approval_todo_status(
|
||||
sp_no="SP001", sp_status=2, status_change_event=2, redis=redis
|
||||
)
|
||||
|
||||
assert result["agents"] == ["agent001"]
|
||||
assert redis.load_json(key)["items"][0]["status"] == "resolved"
|
||||
|
||||
async def test_malformed_cache_entry_does_not_block_others(
|
||||
self, fake_redis: FakeRedis, ws_send: AsyncMock
|
||||
):
|
||||
"""脏缓存(非 JSON / items 非 list)被跳过,正常 Key 仍被改写。"""
|
||||
fake_redis.seed_raw(cache_key("agentBad"), "not-a-json{{{")
|
||||
fake_redis.seed_json(cache_key("agentNoItems"), {"total": 0})
|
||||
good_key = cache_key("agentGood")
|
||||
fake_redis.seed_json(good_key, {"items": [make_approval_item("SP001")]})
|
||||
|
||||
result = await writeback_approval_todo_status(
|
||||
sp_no="SP001", sp_status=2, status_change_event=2, redis=fake_redis
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["agents"] == ["agentGood"]
|
||||
assert fake_redis.load_json(good_key)["items"][0]["status"] == "resolved"
|
||||
|
||||
async def test_redis_scan_failure_is_swallowed(self, fake_redis: FakeRedis, ws_send: AsyncMock):
|
||||
"""Redis 扫描失败不得抛出(回调 ACK 不能被回写拖垮)。"""
|
||||
fake_redis.fail_on_keys = RuntimeError("redis down")
|
||||
|
||||
result = await writeback_approval_todo_status(
|
||||
sp_no="SP001", sp_status=2, status_change_event=2, redis=fake_redis
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["agents"] == []
|
||||
ws_send.assert_not_awaited()
|
||||
|
||||
async def test_no_agents_means_no_ws_push(self, fake_redis: FakeRedis, ws_send: AsyncMock):
|
||||
"""无人命中时不推送,但快照照写(供缓存过期后追溯)。"""
|
||||
result = await writeback_approval_todo_status(
|
||||
sp_no="SP001", sp_status=2, status_change_event=2, redis=fake_redis
|
||||
)
|
||||
|
||||
assert result["agents"] == []
|
||||
ws_send.assert_not_awaited()
|
||||
assert TODO_APPROVAL_STATUS_KEY.format(sp_no="SP001") in fake_redis._data
|
||||
|
||||
async def test_ws_push_failure_does_not_break_writeback(self, fake_redis: FakeRedis):
|
||||
"""单个坐席推送失败(离线)不影响整体回写成功。"""
|
||||
from app.services import ws_manager as ws_manager_mod
|
||||
|
||||
fake_redis.seed_json(cache_key("agent001"), {"items": [make_approval_item("SP001")]})
|
||||
failing = AsyncMock(side_effect=RuntimeError("ws closed"))
|
||||
|
||||
with patch.object(ws_manager_mod.manager, "send_to_agent", failing):
|
||||
result = await writeback_approval_todo_status(
|
||||
sp_no="SP001", sp_status=2, status_change_event=2, redis=fake_redis
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert fake_redis.load_json(cache_key("agent001"))["items"][0]["status"] == "resolved"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 5. 回调端点 POST /approval/callback
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def approval_client(fake_redis: FakeRedis):
|
||||
"""只挂载 approval 路由的最小 app,Redis 依赖替换为 FakeRedis。"""
|
||||
app = FastAPI()
|
||||
app.include_router(approval_mod.router)
|
||||
app.dependency_overrides[approval_mod.get_redis] = lambda: fake_redis
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
yield ac
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
class TestApprovalCallbackEndpoint:
|
||||
"""POST /approval/callback(企微 sys_approval_change)。"""
|
||||
|
||||
async def test_callback_triggers_writeback_and_updates_cache(
|
||||
self, approval_client: AsyncClient, fake_redis: FakeRedis, ws_send: AsyncMock
|
||||
):
|
||||
key = cache_key("agent001")
|
||||
fake_redis.seed_json(key, {"items": [make_approval_item("SP001")], "total": 1})
|
||||
|
||||
resp = await approval_client.post(
|
||||
"/approval/callback",
|
||||
params={
|
||||
"sp_no": "SP001",
|
||||
"sp_name": "IT资产升级申请",
|
||||
"template_id": "TPL_X",
|
||||
"apply_time": 1754500000,
|
||||
"applyer_userid": "zhangsan",
|
||||
"sp_status": 2,
|
||||
"status_change_event": 2,
|
||||
},
|
||||
)
|
||||
|
||||
# 回调必须立即 ACK
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"errcode": 0, "errmsg": "ok"}
|
||||
|
||||
# 回写是 create_task 异步执行,让出事件循环等其完成
|
||||
for _ in range(10):
|
||||
await asyncio.sleep(0)
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
item = fake_redis.load_json(key)["items"][0]
|
||||
assert item["status"] == "resolved"
|
||||
assert item["description"]["sp_status"] == 2
|
||||
assert TODO_APPROVAL_STATUS_KEY.format(sp_no="SP001") in fake_redis._data
|
||||
ws_send.assert_awaited_once()
|
||||
|
||||
async def test_callback_passes_all_fields_to_writeback(
|
||||
self, approval_client: AsyncClient, fake_redis: FakeRedis
|
||||
):
|
||||
"""回调解析出的 sp_no / sp_status / event / template_id 需原样透传。"""
|
||||
spy = AsyncMock(return_value={"success": True})
|
||||
with patch.object(approval_mod, "writeback_approval_todo_status", spy):
|
||||
resp = await approval_client.post(
|
||||
"/approval/callback",
|
||||
params={
|
||||
"sp_no": "SP123",
|
||||
"sp_name": "外修申请",
|
||||
"template_id": "TPL_REPAIR",
|
||||
"apply_time": 1754500001,
|
||||
"applyer_userid": "lisi",
|
||||
"sp_status": 3,
|
||||
"status_change_event": 3,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
for _ in range(10):
|
||||
await asyncio.sleep(0)
|
||||
|
||||
spy.assert_awaited_once()
|
||||
kwargs = spy.await_args.kwargs
|
||||
assert kwargs["sp_no"] == "SP123"
|
||||
assert kwargs["sp_status"] == 3
|
||||
assert kwargs["status_change_event"] == 3
|
||||
assert kwargs["template_id"] == "TPL_REPAIR"
|
||||
assert kwargs["redis"] is fake_redis
|
||||
|
||||
async def test_callback_route_is_registered_in_production_app(self):
|
||||
"""路由契约:企微回调 /approval/callback 在生产 app 中已注册且可响应。
|
||||
|
||||
注:本仓库 api_router 以「无 /api 前缀」挂载(main.py:918 注释说明
|
||||
nginx 已通过 location /api/ 剥离前缀,请求到达后端时 /api 已被 strip),
|
||||
故后端内部路径为 /approval/callback;外部(nginx 视角)/api/approval/callback
|
||||
由网关映射,不属后端单测范围。
|
||||
"""
|
||||
from app.main import app as production_app
|
||||
from app.api import approval as approval_mod
|
||||
|
||||
redis = FakeRedis()
|
||||
key = cache_key("agent001")
|
||||
redis.seed_json(key, {"items": [make_approval_item("SP001")], "total": 1})
|
||||
|
||||
production_app.dependency_overrides[approval_mod.get_redis] = lambda: redis
|
||||
transport = ASGITransport(app=production_app)
|
||||
try:
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
resp = await ac.post(
|
||||
"/approval/callback",
|
||||
params={
|
||||
"sp_no": "SP001",
|
||||
"sp_name": "IT资产升级申请",
|
||||
"template_id": "TPL_X", # 非资产升级模板 → 不触发 _do_asset_urge
|
||||
"apply_time": 1754500000,
|
||||
"applyer_userid": "zhangsan",
|
||||
"sp_status": 2,
|
||||
"status_change_event": 2,
|
||||
},
|
||||
)
|
||||
# 让端点内 create_task 异步回写跑完
|
||||
for _ in range(10):
|
||||
await asyncio.sleep(0)
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
# 路由已注册(非 404)且按契约立即 ACK
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"errcode": 0, "errmsg": "ok"}
|
||||
# 回写确实经由生产 app 的路由 + 注入的 Redis 生效
|
||||
item = redis.load_json(key)["items"][0]
|
||||
assert item["status"] == "resolved"
|
||||
assert item["description"]["sp_status"] == 2
|
||||
finally:
|
||||
production_app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 6. webhook 路径:_writeback_agent_todo
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestWebhookWriteback:
|
||||
"""approval_webhook._writeback_agent_todo(字符串状态 → 企微数值语义)。"""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status, expected_sp_status, expected_event",
|
||||
[
|
||||
("pending", 1, 1),
|
||||
("approved", 2, 2),
|
||||
("completed", 2, 2),
|
||||
("rejected", 3, 3),
|
||||
("cancelled", 4, 6),
|
||||
(" APPROVED ", 2, 2), # 大小写 / 空白容错
|
||||
],
|
||||
)
|
||||
async def test_status_mapping_calls_writeback(
|
||||
self, status: str, expected_sp_status: int, expected_event: int
|
||||
):
|
||||
redis = FakeRedis()
|
||||
spy = AsyncMock(return_value={"success": True})
|
||||
|
||||
fake_settings = _FakeSettings(redis)
|
||||
with patch.object(webhook_mod, "settings", fake_settings), \
|
||||
patch.object(approval_mod, "writeback_approval_todo_status", spy):
|
||||
await webhook_mod._writeback_agent_todo("SP001", status)
|
||||
|
||||
spy.assert_awaited_once()
|
||||
kwargs = spy.await_args.kwargs
|
||||
assert kwargs["sp_no"] == "SP001"
|
||||
assert kwargs["sp_status"] == expected_sp_status
|
||||
assert kwargs["status_change_event"] == expected_event
|
||||
# Redis 客户端必须被释放
|
||||
assert redis.closed is True
|
||||
|
||||
@pytest.mark.parametrize("status", ["unknown_status", "", None])
|
||||
async def test_unmapped_status_skips_writeback(self, status):
|
||||
redis = FakeRedis()
|
||||
spy = AsyncMock()
|
||||
|
||||
fake_settings = _FakeSettings(redis)
|
||||
with patch.object(webhook_mod, "settings", fake_settings), \
|
||||
patch.object(approval_mod, "writeback_approval_todo_status", spy):
|
||||
await webhook_mod._writeback_agent_todo("SP001", status)
|
||||
|
||||
spy.assert_not_awaited()
|
||||
|
||||
async def test_end_to_end_webhook_updates_agent_cache(self, ws_send: AsyncMock):
|
||||
"""webhook 全链路(不 mock 回写):缓存条目应被改写为 resolved。"""
|
||||
redis = FakeRedis()
|
||||
key = cache_key("agent001")
|
||||
redis.seed_json(key, {"items": [make_approval_item("SP001")], "total": 1})
|
||||
|
||||
fake_settings = _FakeSettings(redis)
|
||||
with patch.object(webhook_mod, "settings", fake_settings):
|
||||
await webhook_mod._writeback_agent_todo("SP001", "approved")
|
||||
|
||||
item = redis.load_json(key)["items"][0]
|
||||
assert item["status"] == "resolved"
|
||||
assert item["description"]["sp_status"] == 2
|
||||
ws_send.assert_awaited_once()
|
||||
|
||||
async def test_writeback_exception_is_swallowed(self):
|
||||
"""回写抛异常不得冒泡(webhook 必须照常 ACK),且释放 Redis。"""
|
||||
redis = FakeRedis()
|
||||
boom = AsyncMock(side_effect=RuntimeError("boom"))
|
||||
|
||||
fake_settings = _FakeSettings(redis)
|
||||
with patch.object(webhook_mod, "settings", fake_settings), \
|
||||
patch.object(approval_mod, "writeback_approval_todo_status", boom):
|
||||
await webhook_mod._writeback_agent_todo("SP001", "approved")
|
||||
|
||||
assert redis.closed is True
|
||||
|
||||
|
||||
class TestApprovalWebhookEndpointWiring:
|
||||
"""POST /wecom/approval_webhook 端点是否接线到坐席回写。"""
|
||||
|
||||
async def test_endpoint_schedules_agent_writeback(self):
|
||||
payload = webhook_mod.ApprovalWebhookPayload(
|
||||
approval_id="SP001",
|
||||
employee_id="zhangsan",
|
||||
status="approved",
|
||||
progress=100,
|
||||
)
|
||||
svc = MagicMock()
|
||||
svc.update_progress.return_value = None # 记录不存在 → skipped
|
||||
spy = AsyncMock()
|
||||
|
||||
with patch.object(webhook_mod, "WECOM_WEBHOOK_TOKEN", "unit_test_token"), \
|
||||
patch.object(webhook_mod, "_writeback_agent_todo", spy):
|
||||
ack = await webhook_mod.approval_webhook(
|
||||
payload=payload, x_wecom_token="unit_test_token", svc=svc
|
||||
)
|
||||
for _ in range(10):
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert ack.success is True
|
||||
assert ack.action == "skipped"
|
||||
spy.assert_awaited_once_with("SP001", "approved")
|
||||
|
||||
async def test_invalid_token_rejected_before_writeback(self):
|
||||
payload = webhook_mod.ApprovalWebhookPayload(
|
||||
approval_id="SP001", employee_id="zhangsan", status="approved"
|
||||
)
|
||||
spy = AsyncMock()
|
||||
|
||||
with patch.object(webhook_mod, "WECOM_WEBHOOK_TOKEN", "unit_test_token"), \
|
||||
patch.object(webhook_mod, "_writeback_agent_todo", spy):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await webhook_mod.approval_webhook(
|
||||
payload=payload, x_wecom_token="wrong", svc=MagicMock()
|
||||
)
|
||||
|
||||
assert exc.value.status_code == 401
|
||||
spy.assert_not_awaited()
|
||||
@@ -77,7 +77,7 @@ interface Props {
|
||||
todoItem: TodoItemData
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
const props = defineProps<Props>()
|
||||
|
||||
// ============================================================================
|
||||
// 状态
|
||||
@@ -114,12 +114,24 @@ function handleGoBack(): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理操作按钮点击(Mock 模式:仅 toast 提示)
|
||||
* 处理子视图上抛的操作按钮点击
|
||||
*
|
||||
* @param action - 操作标识
|
||||
* 审批类任务(type=approval):
|
||||
* 企微不支持服务端代审批人执行同意/拒绝/转交,动作已降级为「跳转企微审批
|
||||
* 原系统」——跳转由 ApprovalDetail 的 <a target="_blank"> 原生完成,此处
|
||||
* 只记录日志,不再弹出无条件的 mock 成功提示(状态由企微回调回写)。
|
||||
*
|
||||
* 其他类型任务:
|
||||
* 暂无可在服务台内直接执行的动作,统一提示到原系统操作。
|
||||
*
|
||||
* @param action - 操作标识(approve/reject/transfer/open 等)
|
||||
*/
|
||||
function handleAction(action: string): void {
|
||||
ElMessage.success(`操作成功:${action}`)
|
||||
if (props.todoItem.type === 'approval') {
|
||||
console.info('[TaskDetailView] 审批动作已跳转企微审批原系统:', action)
|
||||
return
|
||||
}
|
||||
ElMessage.info('该操作需在原系统中完成')
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -16,7 +16,14 @@
|
||||
// 功能:
|
||||
// 1. 审批内容卡片(审批单号/模板名称/申请人/申请时间/当前审批人)
|
||||
// 2. 审批意见输入区(textarea,仅供参考)
|
||||
// 3. 底部操作按钮(在企微审批中打开 — 跳转到原系统操作)
|
||||
// 3. 底部操作按钮(通过/拒绝/转交 + 在企微审批中打开)
|
||||
//
|
||||
// ⚠️ 降级跳转说明(Phase 0):
|
||||
// 企微官方无「代审批人执行同意/拒绝/转交」的服务端接口,PC Web 也不具备
|
||||
// 对应的 JS-SDK 能力,因此服务台内不可能直接完成审批动作。
|
||||
// 本组件的所有审批动作统一降级为「跳转企微审批原系统」:点击即在新标签页
|
||||
// 打开该审批单的深链,由审批人本人在企微完成操作;服务台侧状态由企微回调
|
||||
// 回写(最终一致),不在前端做任何乐观更新或假成功提示。
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
@@ -72,14 +79,31 @@
|
||||
</div>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- 底部操作按钮 — 跳转到企微审批原系统操作 -->
|
||||
<!-- 底部操作按钮 — 全部为「跳转企微审批原系统」出口(降级方案) -->
|
||||
<!-- ================================================================== -->
|
||||
<div class="apv-action-hint">
|
||||
⚠️ 审批动作需由审批人本人在企微审批中完成,点击下方按钮将在新标签页打开该审批单
|
||||
</div>
|
||||
<div class="tic-actions">
|
||||
<!-- 通过/拒绝/转交:三个动作共用同一个深链,仅作为跳转入口 -->
|
||||
<a
|
||||
v-for="item in approvalActions"
|
||||
:key="item.action"
|
||||
class="tic-action-btn"
|
||||
:href="wecomApprovalUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
@click="handleApprovalAction(item.action)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</a>
|
||||
|
||||
<a
|
||||
class="tic-action-btn tic-action-primary"
|
||||
:href="wecomApprovalUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
@click="handleApprovalAction('open')"
|
||||
>
|
||||
🔗 在企微审批中打开
|
||||
</a>
|
||||
@@ -114,7 +138,18 @@ interface Emits {
|
||||
(e: 'action', action: string): void
|
||||
}
|
||||
|
||||
defineEmits<Emits>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
// ============================================================================
|
||||
// 常量
|
||||
// ============================================================================
|
||||
|
||||
/** 审批动作列表(均降级为跳转企微审批原系统) */
|
||||
const approvalActions: ReadonlyArray<{ action: string; label: string }> = [
|
||||
{ action: 'approve', label: '✅ 通过' },
|
||||
{ action: 'reject', label: '❌ 拒绝' },
|
||||
{ action: 'transfer', label: '🔄 转交' },
|
||||
]
|
||||
|
||||
// ============================================================================
|
||||
// 状态
|
||||
@@ -168,6 +203,18 @@ const apvStatusText = computed<string>(() => {
|
||||
// 方法
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 处理审批动作点击(降级跳转)
|
||||
*
|
||||
* 说明:跳转本身由 <a href target="_blank"> 原生完成(避免被浏览器拦截弹窗),
|
||||
* 此处仅把动作透传给父组件,供其记录/埋点,不做任何状态变更或成功提示。
|
||||
*
|
||||
* @param action - 动作标识:approve/reject/transfer/open
|
||||
*/
|
||||
function handleApprovalAction(action: string): void {
|
||||
emit('action', action)
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化企微申请时间(秒级时间戳 → 可读时间)
|
||||
*
|
||||
@@ -288,6 +335,17 @@ function formatApplyTime(applyTime: any): string {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ---- 降级跳转提示 ---- */
|
||||
.apv-action-hint {
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--color-warning);
|
||||
background-color: rgba(230, 162, 60, 0.08);
|
||||
border: 1px solid rgba(230, 162, 60, 0.25);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
/* ---- 操作按钮 ---- */
|
||||
.tic-actions {
|
||||
display: flex;
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
|
||||
import { useAgentStore } from '@/stores/agent'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import { useTodoStore } from '@/stores/todo'
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 常量配置
|
||||
@@ -472,6 +473,26 @@ export function useWebSocket() {
|
||||
}
|
||||
break
|
||||
|
||||
case 'todo_status_changed':
|
||||
// 审批降级跳转配套:企微审批回调回写后,后端推送待办状态变更。
|
||||
// 审批人在企微原系统完成操作 → 服务台待办最终一致(此处刷新列表即可,
|
||||
// 后端已就地更新缓存条目,fetchTodoList 命中缓存可立即反映新状态)。
|
||||
if (msg.data) {
|
||||
const todoStore = useTodoStore()
|
||||
// 当前正打开的待办若被终结,同步更新其状态,避免详情页仍显示「审批中」
|
||||
if (
|
||||
todoStore.currentTodoItem &&
|
||||
todoStore.currentTodoItem.id === msg.data.item_id
|
||||
) {
|
||||
todoStore.currentTodoItem.status = msg.data.status
|
||||
if (todoStore.currentTodoItem.description) {
|
||||
todoStore.currentTodoItem.description.sp_status = msg.data.sp_status
|
||||
}
|
||||
}
|
||||
todoStore.fetchTodoList()
|
||||
}
|
||||
break
|
||||
|
||||
default:
|
||||
console.warn(`[WebSocket] 未知消息类型: ${msg.type}`)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user