Compare commits

..

3 Commits

Author SHA1 Message Date
Simon af87f1deb0 fix(backend): approval.py + byod.py 改用 settings.create_redis_client()
PR #3 (commit 9292f41) 引入的回归:get_redis() 用 `from app.main import redis_client`,
但 redis_client 是 lifespan 函数内的局部变量,永远不可跨模块导入。

冒烟测试:ImportError: cannot import name 'redis_client' from 'app.main'
          → 整个 approval 模块加载失败,所有审批路由 500

修复:改用 settings.create_redis_client() 自建连接(与 approval_webhook.py:_writeback_agent_todo 同款)。

byod.py 同样问题,预防性一并修复(避免 byod 模块首次被访问时再炸)。

实测:
- POST /approval/callback → HTTP 200 {errcode:0}
- GET /byod/eligible-positions → HTTP 200 (845B)
2026-08-09 22:26:02 +08:00
simon 9294cf12c1 Merge pull request '坐席端审批线降级跳转 + 回调最终一致回写 (Phase 0 T01+T02)' (#3) from feat/agent-approval-degrade-jump into main
Reviewed-on: #3
2026-08-09 08:28:36 +08:00
Simon 9292f41763 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
2026-08-09 00:46:31 +08:00
9 changed files with 1794 additions and 33 deletions
@@ -0,0 +1,205 @@
# PRD — 坐席端统一工作队列重构
> **REQ编号**: REQ-坐席-011
> **版本**: v0.1(方案草案 / 沟通确认阶段)
> **状态**: 🟡 待评审 — 含未决项,不得据此排期开发
> **优先级**: P1(其中 Phase 0 为 P0
> **日期**: 2026-08-08
> **作者**: 宋献
> **关联**: REQ-坐席-004(任务详情视图切换)、REQ-坐席-009(会话状态Tab筛选)、任务说明书 #132
---
## 1. 背景
### 1.1 提案
取消坐席工作台「待办事项」独立面板,将审批单与工单纳入左栏,与咨询会话统一按优先级和分类排列。
### 1.2 提案原始论据与核查结论
| # | 原始论据 | 核查结论 | 依据 |
|---|---|---|---|
| ① | 咨询/审批/工单本质都是待处理的单个事件 | ✅ **成立** | 与 Zendesk / ServiceNow Agent Workspace 的单一工作队列范式一致 |
| ② | 均关联对应员工(左栏) | ❌ **当前不成立** | `TodoItemData``src/frontend-agent/src/api/todo.ts:17`)顶层无 employee 字段 |
| ③ | 均需辅助处理功能(右栏) | ⚠️ **当前未实现,且不应简单共用** | `AiAssistantPanel.vue:100` 仅依赖 `conversationId`,不感知 `workspaceView` |
**对 ② 的补充**:前端待办头像为伪造实现 —— `todoAvatarText()``TodoPanel.vue:224-233`)从 `title``" - "` 切分后取**部门名最后一个字**,颜色取 `title` 的 hash。后端 `applicant` 仅有 userid,埋在 `description` JSON 内未暴露到顶层(`src/backend/app/services/todo_source_service.py:485`)。
**对 ③ 的补充**:会话辅助信息(知识推荐 / 话术 / 排查步骤)服务于「对话」;审批辅助信息(申请人历史 / 同类通过率 / 合规校验)服务于「决策」。二者不同源、不同构。统一三栏布局 ≠ 统一右栏内容,右栏必须按工作项类型分派渲染。
### 1.3 立项主理由(重新论证)
原提案表述为「布局更合理、处理更高效」,论证力度不足。本 PRD 采用以下主理由:
> **待办面板置于右栏属于信息架构语义错位。** 右栏的定义是「当前会话的上下文辅助面板」,而待办是**不隶属于任何会话的全局工作队列**。将全局队列置入上下文面板,破坏了右栏的语义一致性,并导致坐席的工作入口分裂为两处。
### 1.4 与 #132 的关系(须在评审中说明)
| 项 | 内容 |
|---|---|
| #132 做了什么 | 2026-08-0208-03,待办面板由**左栏底部**迁至**右栏底部** |
| #132 的决策依据 | 任务说明书仅记载「与 v1.1 原型保持一致」,验收项含「左栏会话列表不被压缩」。**无信息架构层面论证** |
| 本次是否为返工 | **否**#132 是面板位移;本次是数据模型与列表融合,属架构升级 |
| 需规避 | 若仅将面板移回左栏(见 §5 方案 B),将原地重演 #132 的左栏空间竞争问题,构成第三次搬迁 |
---
## 2. 已确认决策
以下三项经沟通确认,作为本方案的设计约束。
| # | 决策项 | 结论 | 推导出的约束 |
|---|---|---|---|
| **D-1** | 左栏列表主键模型 | **以「事」为主键** | 每个咨询/审批/工单各占一条;员工信息作为条目属性展示,不作聚合维度 |
| **D-2** | 实施顺序 | **先闭环、后合并** | Phase 0(操作闭环)为 Phase 2(布局合并)的硬前置,顺序不可调换 |
| **D-3** | 待办归属范围 | **本人指派 + 组内未分配** | 引入「认领」动作与并发控制 —— **当前系统完全没有此能力,属新增需求** |
### 2.1 D-3 的成本提示
D-3 不是筛选条件的调整,而是新增一条状态机路径。当前 `todo-items` 接口按 `assigned_agent_id` 过滤,不存在无主池概念。落地需新增:
- 后端:未分配待办的查询口径(组边界定义见 §6 未决项 U-3)
- 后端:`claim`(认领)动作 + 幂等与乐观锁(防并发抢单)
- 前端:认领按钮、认领中态、认领失败(已被他人认领)的提示
- 数据:认领操作的审计留痕
---
## 3. 阻塞项(P0
### 3.1 任务详情操作按钮全部为 Mock
```js
// src/frontend-agent/src/components/chat/TaskDetailView.vue:117
function handleAction(action: string): void {
ElMessage.success(`操作成功:${action}`) // 仅 toast,不调用任何接口
}
```
REQ-坐席-004 §2.4 定义的全部操作 —— 工单(接单 / 开始处理 / 结单 / 转派)、审批(通过 / 拒绝 / 转交)—— **均不生效**
**风险定级:P0,阻塞布局合并。**
理由:当前待办面板位于右栏底部 260px 区域,坐席误操作的暴露面有限。一旦将待办提升至左栏主队列首屏,等同于把不可用功能放置于最高可见度位置。坐席点击「审批通过」后收到绿色成功提示,而企业微信侧该单仍处于挂起状态 —— 属于会造成真实业务后果的错误反馈。
---
## 4. 其余工程前置条件
| 编号 | 前置项 | 现状 | 不处理的后果 |
|---|---|---|---|
| **B-1** | 待办缺员工身份字段 | 顶层无 employee_id/name/department`applicant` 仅 userid 且未暴露 | 混排后同列表内会话条目为真人头像+姓名+部门,待办条目为伪头像+部门残字,身份密度断裂,无法按人扫视 |
| **B-2** | 待办无实时推送 | 会话走 WS(`useWebSocket.ts` 12 类事件);待办为 60s 轮询(`TodoPanel.vue:143`),后端**无任何 todo WS 事件** | 紧急审批最长滞后 60s 才浮升;同列表内会话实时跳动而待办静止,坐席对排序失去信任 |
| **B-3** | 优先级不可比 | 会话为 `urgency_score` 1–5 叠加 6 档加权(置顶 10000 / 代办 5000 / 招手 2000 / 需介入 1500 / 情绪 1000 / VIP 800);待办仅 `urgent/high/normal` 三档,且**同档内无二级排序**`todo_aggregator_service.py:134` 无时间兜底) | 混排结果不可解释,坐席无法预期条目位置 |
| **B-4** | SLA 语义冲突 | 会话等待成本为「用户实时干等」(秒级);审批为「当日处理完毕」(小时级) | 纯优先级排序会使 urgent 审批将 `serving` 状态会话挤出首屏。**漏回一条实时会话的代价显著高于晚 30 分钟处理一单审批**,纯优先级模型会系统性放大该错误 |
**B-4 的设计要求**:统一排序权重**不得**仅取优先级,必须引入「实时性/等待可感知度」维度。建议排序键为 `f(优先级, SLA剩余时间, 对端是否在线等待)`,其中「对端在线等待」应具备最高权重档位。具体系数见 §6 未决项 U-2。
---
## 5. 方案选型
| 方案 | 做法 | 优势 | 劣势 | 采纳 |
|---|---|---|---|---|
| **A** 完全融合 | 单列表跨类型混排 | 真正的单一队列 | 四项前置全欠;实时会话被挤压;排序不可解释 | ❌ 不作为首个形态 |
| **B** 移回左栏保持分区 | 左栏上会话、下待办,可折叠 | 改动最小(≈0.5d),立即消除右栏语义错位 | 仍为两个列表;重演 #132 左栏空间竞争,构成第三次搬迁 | ❌ 不单独实施 |
| **C** 统一容器 + Tab 分层 | 左栏 Tab 增加类型层「会话 / 待办 / 全部」,「全部」下混排 | 兼顾专注模式与全局视图;可灰度、可回退 | Tab 层级加深一层 | ✅ **首个落地形态** |
| **D** WorkItem 统一模型 | 后端抽象 WorkItem,会话/工单/审批为其子类型,共享优先级、SLA、关联人、状态机 | 架构最干净;未来接入设备告警、巡检任务零边际成本 | 后端工作量高一个数量级 | ✅ **目标态** |
**采纳路径:以 D 为目标态,C 为首个落地形态,Phase 0 为硬前置。**
---
## 6. 实施路线
> 工时为粗估,用于排序参考,不作为承诺。
### Phase 0 — 操作闭环(P0,阻塞后续全部阶段)
> **U-1 已验证结论(2026-08-08)**:企微审批**不可服务端闭环**(官方文档证实无代审批接口,PC Web 无 JS-SDK 能力),审批动作必须降级为「跳转企微原系统 + 回调状态回写」;ITSM 工单因操作类 OpenAPI 尚未落地,**暂不可判定服务端闭环**,需先降级跳转,待 T05 外部依赖到位后升级。详见 `docs/02-技术文档/技术架构/技术验证-U-1-审批与工单操作闭环可行性-v1.0.md`。
| 任务 | 说明 | 降级层级 |
|---|---|---|
| 审批操作 → 降级跳转 + 回写 | 通过 / 拒绝 / 转交 → 点击「在企微审批中打开」跳转原系统,由 `approval_webhook.py` 接收 `sys_approval_change` 回调 → 补全 `approval.py:902` 状态回写 → WS 推送 | Level 0 立即可做;Level 1 需补回调回写 |
| 工单操作 → 降级跳转(暂) | 接单 / 开始处理 / 结单 / 转派 → 点击「在 ITSM 中打开」跳转原系统;ITSM 写接口到位前不承诺服务端闭环(受 T05 外部阻塞)。**⚠️ 注意:该跳转依赖工单可见,而当前 ITSM 读列表亦未实现(见 U-1.2),故本行在读链路打通前无实际可操作对象** | Level 0(暂,受 U-1.2 前置) |
| 失败态处理 | 移除无条件 `ElMessage.success`,改为「跳转成功提示 + 状态回写后刷新」按三态分派;仍禁止以 toast 作为验收依据 | — |
**验收标准**:操作后外部系统(ITSM / 企微审批)**状态真实变更**,且前端反馈与外部状态**最终一致**(通过回调 / webhook 回写达成)。禁止以 toast 成功作为验收依据。审批 / 工单在降级跳转模式下,以「跳转成功 + 原系统状态通过回调回写并刷新」作为验收闭环,不要求前端内嵌直接操作。
### Phase 1 — 数据层归一(后端)
| 任务 | 对应前置项 |
|---|---|
| `TodoItem` 顶层暴露 `employee_id` / `employee_name` / `department`(由 applicant userid 反查企微通讯录) | B-1 |
| 新增 `sla_due_at` 字段 | B-4 |
| 定义 WorkItem 统一排序权重模型 | B-3 / B-4 |
| 新增 todo WS 事件:`todo_created` / `todo_updated` / `todo_claimed` / `todo_resolved` | B-2 |
| 未分配待办查询口径 + `claim` 动作(幂等 + 乐观锁) | D-3 |
### Phase 2 — 左栏统一容器(前端,方案 C)
| 任务 |
|---|
| 左栏 Tab 分层:类型层(会话 / 待办 / 全部)+ 状态层(沿用 REQ-009 的四态) |
| 定义统一 `ListItem` 类型(含 `kind` 判别式),条目组件支持会话态与任务态两种渲染 |
| 移除 `AiAssistantPanel.vue` 中的 `<TodoPanel />` 挂载 |
| **右栏按 `workspaceView` 分派渲染**(修复现存缺陷:任务详情下右栏仍显示上一会话的排查建议) |
| 修复 `selectConversation` 不重置 `workspaceView` 的问题 |
| 视图切换时保留输入框草稿与滚动位置(当前 `v-if/v-else` 互斥卸载会丢失,`ReplyBox.vue:307``inputText` 为组件局部 ref |
### Phase 3 — 混排与观察
| 任务 |
|---|
| 「全部」Tab 下按归一化权重混排 |
| 灰度发布 + 2 周数据观察(观测指标见下) |
| 依据数据决定是否推进 D(后端 WorkItem 模型) |
**观测指标**:会话首响时长(是否因混排而劣化)、待办平均处理时长、坐席 Tab 切换频次、认领冲突率。
---
## 7. 未决项
| 编号 | 未决项 | 影响 | 需谁决策 |
|---|---|---|---|
| **U-1** | 审批操作能否在坐席 PC Web 端完成 | **已验证(2026-08-08**:企微审批**不可服务端闭环**——官方文档证实无代审批接口,PC Web 亦无 JS-SDK 原生表单能力。结论:审批动作须降级为「跳转企微原系统 + `sys_approval_change` 回调状态回写」。该结论**不阻塞** Phase 0 启动(降级跳转可立即落地)。验证依据见 `技术验证-U-1-审批与工单操作闭环可行性-v1.0.md` | 已闭环(技术) |
| **U-1.1** | ITSM 写接口到位时间 | 工单「接单 / 开始处理 / 结单 / 转派」能否服务端闭环,取决于 ITSM 平台方提供的操作类 OpenAPI 文档、`app_id`/`secret` 写权限、测试账号(对应 T05)。当前项目内 ITSM 仅实现只读查询(`itsm_service.py` 全文件无写操作),无写接口实证。该项是**工单服务端闭环的外部阻塞项**,未到位前工单同样降级跳转 | 平台方 / 外部协调 |
| **U-1.2** 🔴 | **ITSM 工单当前在坐席端完全不可见(读链路即断)** | **代码实证(2026-08-08 主理人复核补录)**`ITSMService.get_todo_list()``src/backend/app/services/itsm_service.py:113-130`**无条件 `return []`**,仅打印告警「ITSM 代办列表 API 尚未实现」。`TodoAggregatorService``todo_aggregator_service.py:110-127`)并发聚合审批与 ITSM 两源,ITSM 分支恒为空 → **待办列表中工单数量恒为 0,现有待办全部是企微审批单**。且因无列表即无 `process_instance_id`,已实现的 `get_todo_detail` / `workitem/detail`(只读)**实际也无从调用**。<br>**影响**:此项**比 U-1.1(写接口缺失)更前置**——写接口的前提是先能读到工单。在读链路打通前,「工单纳入统一队列」在数据层无内容可纳入,Phase 2 的工单部分实为空跑 | 平台方 / 外部协调(与 U-1.1 合并索取) |
| **U-2** | 统一排序权重的具体系数 | 决定混排结果是否可解释、是否会挤压实时会话 | 产品 + 坐席试用反馈 |
| **U-3** | 「组内未分配」的组边界定义 | 全体 IT 支持组?还是按技能/区域路由后的子集?直接影响列表长度 | 产品 |
| **U-4** | 认领并发冲突的交互 | 两名坐席同时认领同一单时的提示与落败方引导 | 产品 |
| **U-5** | 列表长度上限与虚拟滚动 | 合并 + 组内未分配后列表显著变长,260px 宽左栏的承载能力 | 前端 |
| **U-6** | 待办条目的未读/变更标识 | 会话有 `is_todo` 等标志,待办无未读概念,混排后视觉规则需统一 | 产品 |
---
## 8. 风险登记
| 编号 | 风险 | 等级 | 缓解措施 |
|---|---|---|---|
| R-1 | 实时会话被审批挤出首屏,首响时长劣化 | 🔴 高 | 排序模型引入「对端在线等待」最高权重档;Phase 3 灰度观测首响指标 |
| R-2 | 认领并发抢单导致重复处理 | 🟡 中 | 后端乐观锁 + 幂等;前端落败态明确提示 |
| R-3 | 轮询向 WS 迁移期间的双通道数据不一致 | 🟡 中 | 迁移期保留轮询作为兜底,以 WS 事件为主、轮询做对账 |
| R-4 | 第三次布局搬迁引发团队对决策稳定性的质疑 | 🟢 低 | 评审时明确说明本次为架构升级而非位移返工(见 §1.4) |
| R-5 | 工单闭环因 ITSM 写接口缺失受阻,导致 Phase 0 工单部分停滞 | 🟡 中 | U-1 已验证:审批走降级跳转**不阻塞** Phase 0;工单闭环的外部阻塞已独立为 U-1.1 / T05,审批与工单降级跳转可先行落地,不拖累整体路线 |
---
## 9. 明确不在本次范围
- 会话侧数据模型改造(`urgency_score` 与加权标志保持不变)
- H5 员工端任何改动
- 设备异常类型(REQ-004 §2.3 曾定义,现已从 `TodoPanel` 移除,本次不恢复)
- 坐席在线统计数据源(当前取自 `src/mock/data.ts`,属独立技术债)
---
## 10. 变更记录
| 日期 | 版本 | 变更内容 | 变更人 |
|---|---|---|---|
| 2026-08-08 | v0.1 | 创建方案草案;固化 D-1/D-2/D-3 三项决策;完成原始论据核查与现状事实核实 | 宋献 |
| 2026-08-08 | v0.1 | 据 U-1 技术验证结论(架构师高见远)修订 §6 Phase 0 与 §7 U-1:审批明确为降级跳转 + 回写(U-1 已验证);新增 U-1.1 ITSM 写接口外部阻塞项;同步下调 R-5 风险表述 | 宋献 |
| 2026-08-08 | v0.1 | 主理人复核补录 **U-1.2**:代码实证 `ITSMService.get_todo_list()` 无条件返回空列表,ITSM 工单在坐席端读链路即断、当前待办全为企微审批单。该项前置于 U-1.1,一并标注于 §6 Phase 0 工单行 | 齐活林(交付总监) |
@@ -0,0 +1,296 @@
# 技术验证 U-1:坐席 PC Web 端审批与工单操作闭环可行性
> **文档版本**v1.0
> **验证人**:高见远(架构师)
> **验证日期**2026-08-08
> **关联 PRD**`PRD-REQ-坐席-011-统一工作队列重构-v0.1.md`U-1,§6 Phase 0,§7
> **任务性质**:技术可行性验证(**未修改任何业务源码**)
> **代码核查范围**:仅 `src/` 下活跃代码(`frontend-agent/`、`backend/`),忽略根目录废弃的 `frontend/`、`backend_bak/`。
---
## 0. 结论先行(可行性判定矩阵)
**核心结论**:坐席在 PC Web 端**无法**通过服务端接口直接完成审批单的「通过 / 拒绝 / 转交」动作;工单的「接单 / 开始处理 / 结单 / 转派」动作因 ITSM 操作类 OpenAPI 尚未落地,**暂不可判定可行**,需向平台方索取文档与权限。两者均**不能**在 Phase 0 内实现"服务端真实闭环",必须分级降级。
| 对象 | 动作 | 可行性判定 | 依据类型 | 关键约束 |
|------|------|-----------|---------|---------|
| 企微审批 | 通过(同意) | **需降级跳转** | 官方文档 | 企微无"代审批人执行同意"的服务端接口 |
| 企微审批 | 拒绝 | **需降级跳转** | 官方文档 | 同上 |
| 企微审批 | 转交 | **需降级跳转** | 官方文档 | 同上 |
| ITSM 工单 | 接单 | **待外部确认** | 代码实证 + 待确认 | 操作类 OpenAPI 未实现、未文档化 |
| ITSM 工单 | 开始处理 | **待外部确认** | 代码实证 + 待确认 | 同上 |
| ITSM 工单 | 结单 | **待外部确认** | 代码实证 + 待确认 | 同上 |
| ITSM 工单 | 转派 | **待外部确认** | 代码实证 + 待确认 | 同上 |
**判定值枚举说明**
- `可服务端闭环`:服务端 API 可代替坐席真实生效,无需跳转原系统。
- `需降级跳转`:服务端无代操作能力,必须跳转原系统(企微客户端 / ITSM Web)由坐席本人操作。
- `待外部确认`:能力是否存在取决于外部平台方提供的接口/权限,项目内暂无实证。
- `不可行`:经核查确认任何路径均无法达成。
**对 Phase 0 的整体影响(一句话)**Phase 0 的"审批操作接真实接口"**无法满足**"操作后外部系统状态真实变更"的验收标准,必须改为"降级跳转 +(可选)webhook 回写";工单部分**阻塞于外部依赖**,须等 ITSM 文档/权限到位方可进入闭环开发,否则同样降级为跳转。
---
## 1. V-1 企微审批:服务端代审批能力核查
### 1.1 企微审批的两套接口体系(背景,官方文档)
企业微信的审批能力在代码与文档中存在**两套独立体系**,需分别核查:
1. **「审批应用」体系**(企业微信「审批」应用自带的审批流)
- 回调事件:`sys_approval_change`
- 服务端接口:`gettemplatedetail``applyevent``getapprovaldetail``getapprovaldata`、批量获取审批编号。
2. **「审批流程引擎」体系**(自建应用内嵌审批,走 JS-SDK)
- 回调事件:`open_approval_change`
- 前端能力:`wx.invoke('thirdPartyOpenPage', {oaType:'10001'|'10002'})`
-`wx.agentConfig`(应用身份) + 企微客户端环境。
**核查结论**:无论哪套体系,**服务端均无"代替审批人执行同意/拒绝/转交"的接口**。(依据类型:官方文档)
### 1.2 官方服务端接口清单(代码实证 + 官方文档)
项目内 `src/backend/app/api/approval.py` 已实现/封装的企微审批服务端调用,经逐行核对**全部为"提交/查询/回调",无一为"代审批动作"**
| 函数 | 行号 | 对应企微 API | 性质 |
|------|------|-------------|------|
| `get_approval_token` | `approval.py:392` | 获取 access_token | 鉴权 |
| `get_template_detail` | `approval.py:401` | `oa/gettemplatedetail` | 查询模板 |
| `submit_approval_api` | `approval.py:423` | `oa/applyevent` | **提交申请**(非审批) |
| `get_approval_detail` | `approval.py:472` | `oa/getapprovaldetail` | 查询详情 |
| `get_approval_data` | `approval.py:505` | `oa/getapprovaldata` | 查询列表 |
| `/approval/jump` | `approval.py:840` | — | 生成跳转链接 |
| `/approval/submit` | `approval.py:861` | `oa/applyevent` | 提交申请 |
| `/approval/callback` | `approval.py:902` | `sys_approval_change` | 状态变化**回调**(当前仅 log,状态未回写业务) |
**官方文档佐证**:企微「审批应用」服务端 API 文档(https://developer.work.weixin.qq.com/document/path/91854)列出的全部接口即上述 5 类(模板详情、提交申请、状态变化回调、批量获取编号、获取详情),**不含任何"审批/驳回/转交"动作接口**。(依据类型:官方文档)
### 1.3 为什么 PC Web 端也无法用 JS-SDK 兜底
PRD U-1 提到现有交互依赖 `wx.invoke('thirdPartyOpenPage', {oaType:'10001'})` 原生表单。核对该能力约束:
- `wx.config` / `wx.agentConfig``wx.invoke` **仅在企业微信客户端内嵌的 H5 中生效**,普通 PC 浏览器调用无效(官方 JS-SDK 文档:https://developer.work.weixin.qq.com/document/path/94345;社区多源佐证)。
- `wxwork://launch?launch_code=xxx` URL Scheme **仅支持 Windows / Mac 唤起客户端打开"个人聊天窗口"**,不支持跳转审批详情页(官方 Scheme 文档:https://developer.work.weixin.qq.com/document/path/94345)。
- 坐席工作台是独立的 **PC Web 应用**(非企微内嵌 H5),因此上述原生表单能力**不可用**。(依据类型:官方文档 + 推断,推断部分为"坐席工作台非企微内嵌"——该事实以 PRD 上下文与项目前端独立部署形态为据)
### 1.4 降级跳转方案(推荐)
既然服务端无代审批接口,审批操作闭环采用**"跳转 + 状态回写"降级**
1. 前端审批详情页提供"在企微审批中打开"链接,跳转至企微审批管理后台/客户端由审批人本人在原系统操作。
- 代码实证:`src/frontend-agent/src/components/chat/task/ApprovalDetail.vue:78-86` 已使用该跳转模式(`https://app.work.weixin.qq.com/wework_admin/approval_v3#/?sp_id=...`)。
2. 操作后状态由**原系统回调**回写服务台:
- 代码实证:`src/backend/app/api/approval_webhook.py` 已具备接收企微审批状态变化并向前端 WebSocket 推送的能力(`sys_approval_change` → 状态变化 → WS 推送)。
- **缺口(待确认/待补全)**`approval.py:902``/approval/callback` 当前仅 `logger`,未将 `status_change_event`(同意=2/驳回=3/转审=4)回写业务状态;需在 Phase 0 补一段"回调 → 更新本地待办状态 → WS 通知"。
> ⚠️ 严格说,降级跳转方案**不满足** PRD Phase 0 验收标准"操作后外部系统状态真实变更且前端反馈一致"中的"前端直接闭环"——因为动作发生在原系统。但它是 U-1 不可行前提下的**唯一可行路径**,且状态可通过 webhook 回写实现"最终一致"。(依据类型:推断)
---
## 2. V-2 ITSM 工单:操作类 OpenAPI 核查
### 2.1 项目内 ITSM 调用现状(代码实证)
`src/backend/app/services/itsm_service.py` 全文件 261 行,**仅实现只读查询,无任何写操作**:
| 方法 | 行号 | 性质 | 说明 |
|------|------|------|------|
| `get_todo_list` | `itsm_service.py:113` | 读(占位) | 返回空列表 + 日志告警,API 待实现 |
| `get_todo_detail` | `itsm_service.py:132` | 读 | 调 `workitem/detail` |
| `_do_post` | `itsm_service.py:167` | 通用 POST | 带 `ITSMSigner` 签名发送,可复用于写 |
| `_get_workitem_detail` | `itsm_service.py:196` | 读 | `POST /openapi/v1/process/workitem/detail` |
| `_map_to_todo_item` | `itsm_service.py:217` | 映射 | 详情 → 统一 `TodoItemData` |
**关键事实**:已知的唯一 ITSM 端点 `POST /openapi/v1/process/workitem/detail` 是**只读详情接口**(代码实证 `itsm_service.py:27,196-215`)。接单 / 开始处理 / 结单 / 转派等**写操作端点路径、请求体 schema、成功/错误码在项目内完全不存在**。(依据类型:代码实证)
### 2.2 签名机制可复用(利好)
`src/backend/app/utils/itsm_signer.py``ITSMSigner.compute_signature(app_id, timestamp, app_secret, biz_data)` 是**纯静态工具**SHA1(`appSecret`+`appId`+`timestamp`+`bizData` 升序拼接 → `quote_plus` → SHA1 → 大写 hex)。该签名**不区分读写**,一旦获得写操作端点与请求体,可直接复用 `ITSMService._do_post``itsm_service.py:167`)发起写请求,无需新增鉴权逻辑。(依据类型:代码实证 + 推断,推断部分为"写操作可走同一签名/同一 `_do_post`"——基于签名与端点解耦的现状合理推断,但需 ITSM 平台方确认写接口是否复用同一套签名)
### 2.3 操作类 API 缺失的外部依赖(待确认清单)
工单动作是否可服务端闭环,**取决于 ITSM 平台方提供的接口与权限**,项目内无实证。需向平台方索取:
| 待确认项 | 说明 | 当前项目状态 |
|---------|------|-------------|
| ITSM 操作类 OpenAPI 文档 | 接单/开始处理/结单/转派 的端点、方法、请求体、响应码 | PRD-审批-001 Q1「ITSM API 完整接口规范」⏳待抓包 |
| `ITSM_APP_ID` / `ITSM_APP_SECRET` | 写操作所需的应用凭证 | PRD-审批-001 Q2.1 ⏳待申请;`docker-compose.yml` 未配置 |
| 操作类权限开通 | 当前 app_id 是否具备写权限 | 未知,需平台方确认 |
| 测试账号 / 测试工单 | 用于闭环联调 | 未提供 |
| 写操作成功/冲突语义 | 例如重复接单是否幂等、并发转派冲突码 | 未知 |
> 注:PRD-审批-001 已明确 Q5「代办状态更新交互」✅已确认:仅展示 + 跳转,不在服务台内直接操作。这与本验证"工单动作待外部确认"不冲突——Q5 是**产品决策**(先不内嵌操作),本验证是**技术可行性**(若要做内嵌,接口是否存在)。
### 2.4 结论
工单 4 动作**全部"待外部确认"**。在当前无任何操作类接口实证的前提下,**不能承诺服务端闭环**;若 ITSM 平台方提供写接口且权限到位,则因签名可复用,开发成本较低(主要工作量在补全 `ITSMService` 写方法 + 前端动作按钮接真实接口)。(依据类型:代码实证 + 待确认)
---
## 3. V-3 权限与身份模型
### 3.1 现状:操作以"应用身份"发起
| 系统 | 当前调用身份 | 代码实证 |
|------|------------|---------|
| 企微审批(读/提交) | 应用 access_tokenIT 支持应用 Secret | `approval.py:392` `get_approval_token` |
| ITSM(读) | app_id + SHA1 签名(应用级) | `itsm_service.py:106-107,180` |
服务端调用均使用**应用身份**,不携带坐席个人身份令牌。(依据类型:代码实证)
### 3.2 工单:坐席个人身份如何传递(待确认)
`ITSMService._get_workitem_detail` 在请求体中传入 `executor`(坐席 userid):
```python
# itsm_service.py:196-215
body = {
"process_instance_id": process_instance_id,
"executor": executor, # = self.agent_useriditsm_service.py:103
}
```
即服务台**主动声明**执行人为当前坐席 userid。(依据类型:代码实证)
但 ITSM 是否据此将" executor"认作**真实操作人并写入审计日志**,取决于 ITSM 侧实现——当前仅详情查询用到该字段,写操作未实现,**无法验证**。(依据类型:待确认)
### 3.3 审批:个人身份不可绕过(不可行)
企微审批的"同意/拒绝/转交"依法规与产品逻辑必须由**审批人本人在客户端**操作,服务端无代审批接口(见 §1.2)。因此 PC Web 代审批在**身份与合规层面不可行**,只能由审批人本人跳转原系统操作。(依据类型:官方文档 + 推断)
### 3.4 审计追溯结论
- **降级跳转方案下**:动作发生在原系统(企微/ITSM),审计由对方负责。服务台仅能记录"跳转动作"事件,**无法闭环确认结果**,需依赖 webhook / 回调回写状态(见 §1.4、§4.2)。
- **若合规要求"个人身份可追溯"**ITSM 侧需确认是否支持 impersonation 或坐席级令牌;企微审批侧 PC Web 代审批不可行,此路不通。(依据类型:推断 + 待确认)
---
## 4. V-4 结论与方案
### 4.1 可行性判定汇总(同 §0 矩阵,附依据)
- 审批 3 动作:`需降级跳转`(官方文档实证:无服务端代审批接口 + PC Web 无 JS-SDK 能力)。
- 工单 4 动作:`待外部确认`(代码实证:仅只读;待平台方提供写接口/权限/账号)。
### 4.2 分级降级方案
```mermaid
flowchart TD
A[坐席在 PC Web 服务台点击操作] --> B{动作类型}
B -->|审批:通过/拒绝/转交| C[降级:跳转企微审批原系统]
B -->|工单:接单/处理/结单/转派| D{ITSM 写接口是否到位?}
D -->|否| E[降级:跳转 ITSM Web 处理]
D -->|是| F[服务端调用 ITSM OpenAPI 真实闭环]
C --> G[企微 sys_approval_change 回调]
G --> H[/approval/callback 回写状态 + WS 推送/]
E --> I[ITSM 状态变化]
I --> J[待确认:ITSM 是否提供状态回调/Webhook]
F --> K[前端按接口返回分派 成功/失败/冲突 三态]
```
**降级层级**
1. **Level 0(立即可执行,不阻塞)**:审批全量降级跳转;工单在 ITSM 写接口未到位前同样降级跳转。前端按钮接真实"跳转链接"而非 mock toast。
2. **Level 1(需补开发)**:审批跳转后通过 `approval_webhook.py` 已具备的回调 → WS 推送实现**状态最终一致**(需补全 `approval.py:902` 回写逻辑)。
3. **Level 2(依赖外部)**:ITSM 写接口到位后,升级为服务端真实闭环,移除跳转降级。
### 4.3 对 PRD Phase 0 的影响与修订建议
PRD §6 Phase 0 原表:
| 原任务 | 原说明 | 修订后(基于本验证) |
|--------|--------|---------------------|
| 工单操作接真实接口 | 接单/开始处理/结单/转派 → ITSM API | **降级为跳转 ITSM**,并标注"服务端闭环待 ITSM 接口到位后升级"(受外部依赖阻塞) |
| 审批操作接真实接口 | 通过/拒绝/转交 → 企微审批(受 U-1 约束,方案未定) | **明确为"降级跳转 + webhook 回写"**,U-1 判定为"不可服务端闭环" |
| 失败态处理 | 移除无条件 `ElMessage.success` | 维持;跳转方案下改为"跳转成功提示 + 状态回写后刷新",仍禁止以 toast 作为验收依据 |
**对 Phase 0 阻塞关系的影响**
- 审批部分:**不阻塞** Phase 0 启动——降级跳转方案可立即落地,原系统回调回写可并行开发。
- 工单部分:**受外部依赖阻塞**——若坚持"服务端真实闭环"则必须等 ITSM 文档/权限;若接受降级跳转则与审批同步落地。
- 建议 PRD 将 U-1 结论由"待验证"改为"**已验证:审批不可服务端闭环,须降级跳转**",并新增 U-1.1「ITSM 写接口到位时间」作为工单闭环的外部阻塞项。
### 4.4 有序任务分解(供 Engineer 实施)
> 以下任务**仅含配置/前端/回调补完**,**不含任何工单写操作实现**(因接口待确认)。工单闭环任务在外部依赖到位后单独追加。
| 任务 ID | 任务名 | 涉及文件(相对路径) | 依赖 | 优先级 |
|---------|--------|---------------------|------|--------|
| T01 | 审批/工单详情页动作改为真实跳转链接(移除 mock) | `src/frontend-agent/src/components/chat/task/ApprovalDetail.vue``TicketDetail.vue``src/frontend-agent/src/components/chat/TaskDetailView.vue``handleAction` 不再仅 toast | — | P0 |
| T02 | 补全企微审批回调状态回写(sys_approval_change → 本地状态 → WS | `src/backend/app/api/approval.py``/approval/callback` 现状 `approval.py:902`)、`src/backend/app/api/approval_webhook.py` | T01 | P0 |
| T03 | ITSM 工单详情页跳转链接接入(wecom ITSM Web 深链) | `src/frontend-agent/src/components/chat/task/TicketDetail.vue``src/backend/app/services/itsm_service.py`(补充 `itsm_web_url` 构造) | T01 | P1 |
| T04 | 失败/冲突三态反馈(移除无条件 success) | `TaskDetailView.vue`、各 Detail 子组件、`src/backend/app/api/todo_items.py``PUT /{id}/status` 现状 `todo_items.py:158` display_only 保持不变,仅前端反馈改造) | T01 | P1 |
| T05 | 外部依赖跟进:向 ITSM 平台方索取写接口文档 + 申请 app_id/secret + 测试账号(阻塞工单闭环) | `docs/01-产品文档/06-审批与待办/PRD-REQ-审批-001-ITSM工单跳转-v1.0.md`(更新 Q1/Q2.1)、`docker-compose.yml`(补 ITSM_APP_ID/SECRET | — | P0(外部) |
**依赖顺序图**
```mermaid
graph TD
T01[T01 跳转链接改造] --> T02[T02 审批回调回写]
T01 --> T03[T03 ITSM 跳转接入]
T01 --> T04[T04 三态反馈]
T05[T05 ITSM 外部依赖] -.阻塞.-> T03
```
> 注:T05 为**外部协调任务**(非代码),其完成是工单服务端闭环(未来追加的 T06+)的前置,但不阻塞审批降级与跳转类任务。
---
## 5. 证据清单与方法说明
| 依据类型 | 含义 | 本文使用处 |
|---------|------|-----------|
| 官方文档 | 企业微信/ITSM 官方接口文档,附链接 | §1.2、§1.3、§1.4 |
| 代码实证 | 项目 `src/` 内源码,附 `文件:行号` | §1.2、§2.1、§3.1、§3.2 |
| 推断 | 基于上述事实的合理推论,**非证实事实** | §1.4、§2.2、§3.4、§4.2 |
| 待确认 | 需外部平台方/产品提供信息方可定论 | §2.3、§3.2、§3.4、T05 |
**核查边界声明**
- 未运行任何代码、未修改任何业务源码,仅静态阅读与官方文档交叉验证。
- 企微官方文档链接为验证时引用的权威来源;若文档版本更新导致接口增减,需重新核对。
- ITSM 操作类接口结论为"待外部确认",不表示"不可行";结论仅在"项目内当前无任何写接口实证"前提下成立。
---
## 6. 附:关键调用链路时序图(降级闭环)
### 6.1 审批降级跳转 + 状态回写(目标态)
```mermaid
sequenceDiagram
participant A as 坐席(PC Web)
participant F as 前端服务台
participant B as 后端
participant W as 企微审批(原系统)
participant H as approval_webhook/WS
A->>F: 点击"在企微审批中打开"
F->>W: 跳转 approval_v3#/?sp_id=... (新标签页)
Note over A,W: 审批人在企微客户端/管理后台执行 同意/拒绝/转交
W-->>B: sys_approval_change 回调 (status_change_event: 2/3/4)
B->>B: /approval/callback 回写本地待办状态 (待补全 approval.py:902)
B->>H: WebSocket 推送状态变更
H->>F: 待办状态刷新
F-->>A: 前端状态与企微一致
```
### 6.2 工单跳转(ITSM 接口未到位时)
```mermaid
sequenceDiagram
participant A as 坐席(PC Web)
participant F as 前端服务台
participant I as ITSM Web(原系统)
A->>F: 点击"在 ITSM 中打开"
F->>I: 跳转 ITSM workitem 详情深链 (新标签页)
Note over A,I: 坐席在 ITSM 内执行 接单/处理/结单/转派
Note over I: 状态变化由 ITSM 负责 (回写机制待确认)
```
---
*文档结束。本验证所有"推断"与"待确认"项均已明确标注,未将推断作为既成事实陈述。*
+361 -22
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,11 +31,71 @@ 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客户端依赖"""
from app.main import redis_client
return redis_client
"""获取Redis客户端依赖
使用 settings.create_redis_client() 自建连接,而非从 app.main 导入 redis_client
(后者是 lifespan 函数内的局部变量,不可跨模块导入)。
"""
return settings.create_redis_client()
# =============================================================================
@@ -775,6 +837,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 +1279,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,
+6 -3
View File
@@ -24,9 +24,12 @@ router = APIRouter()
# Redis客户端(依赖注入)
async def get_redis() -> aioredis.Redis:
"""获取Redis客户端依赖"""
from app.main import redis_client
return redis_client
"""获取Redis客户端依赖
使用 settings.create_redis_client() 自建连接,而非从 app.main 导入 redis_client
(后者是 lifespan 函数内的局部变量,不可跨模块导入)。
"""
return settings.create_redis_client()
# =============================================================================
@@ -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}`)
}