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:
Simon
2026-08-09 00:46:31 +08:00
parent 2fd2e7df02
commit 9292f41763
8 changed files with 1782 additions and 27 deletions
+355 -19
View File
@@ -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_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 端点
# =============================================================================
@@ -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}")
+64 -1
View File
@@ -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_changedpayload 字段完整)
# 5. 回调端点 POST /approval/callback 触发回写
# 6. webhook 路径 _writeback_agent_todo 的字符串状态 → 企微数值语义映射
#
# 依赖:本文件自带 FakeRedisconftest 的 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-pydecode_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 37 天状态快照
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 4WS 推送
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 路由的最小 appRedis 依赖替换为 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}`)
}