Compare commits

..

3 Commits

Author SHA1 Message Date
Simon 5311a526af feat(h5/chat): 员工端群聊按钮接线 → 参与者面板(REQ-用户-001)
- InputBar.vue 重写 handleGroupChat():无会话 toast「请先发起会话」;有会话 → 开关参与者面板;零参与者且展开时补邀请引导。
- 契约常量 GROUP_CHAT_NO_CONVERSATION_TIP / GROUP_CHAT_EMPTY_TIP 与 InputBar.test.ts 完全对齐;删除字面量 '群聊功能开发中' 与 startGroupChat 调用。
- InputBar.test.ts 102/102 通过;契约测试已同步。
- 新增技术方案 docs/02-技术文档/技术方案-REQ-用户-001-群聊入口接线-v1.0.md(方案 A store 驱动)。
- 新增任务说明书 docs/07-项目管理/任务说明书/任务说明书-REQ-用户-001-群聊入口接线.md(按模板)。
- PRD-REQ-用户-001-群聊双模式-v1.0.md 头部补「关联文档」双向链 + 状态「待评审」→「已实现」。

PRD: docs/01-产品文档/05-用户端H5/PRD-REQ-用户-001-群聊双模式-v1.0.md
REF:  REQ-用户-001-群聊入口接线(坐席端不动,按用户拍板 q-1)
2026-08-09 13:16:27 +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
11 changed files with 2370 additions and 28 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 工单行 | 齐活林(交付总监) |
@@ -3,9 +3,14 @@
> **版本**: v1.0 > **版本**: v1.0
> **日期**: 2026-07-14 > **日期**: 2026-07-14
> **作者**: 许清楚(产品经理) > **作者**: 许清楚(产品经理)
> **状态**: 待评审 > **状态**: 已实现(双端能力已落地;员工端 H5 工具栏「群聊」入口于 2026-08-08 完成接线)
> **子系统**: 05-用户端H5 > **子系统**: 05-用户端H5
> **模块**: 群聊 > **模块**: 群聊
> **关联文档**:
> - 技术方案(双模式): `docs/02-技术文档/技术架构/技术方案-REQ-用户-001-群聊双模式-v1.0.md`
> - 架构设计: `docs/02-技术文档/01-架构设计/群聊参与者展开缩略双模式-架构设计.md`
> - 技术方案(入口接线): `docs/02-技术文档/技术方案-REQ-用户-001-群聊入口接线-v1.0.md`
> - 任务说明书(入口接线): `docs/07-项目管理/任务说明书/任务说明书-REQ-用户-001-群聊入口接线.md`
--- ---
@@ -0,0 +1,287 @@
# 技术方案 — 员工端 H5 群聊入口接线
> **需求编号**: REQ-用户-001-群聊入口接线
> **版本**: v1.0
> **日期**: 2026-08-08
> **作者**: 高见远(架构师)
> **状态**: 已实现
> **子系统**: 用户端H5
> **模块**: 群聊
> **关联文档**:
> - PRD: `docs/01-产品文档/05-用户端H5/PRD-REQ-用户-001-群聊双模式-v1.0.md`
> - 既有技术方案: `docs/02-技术文档/技术架构/技术方案-REQ-用户-001-群聊双模式-v1.0.md`
> - 既有架构设计: `docs/02-技术文档/01-架构设计/群聊参与者展开缩略双模式-架构设计.md`
> - 任务说明书: `docs/07-项目管理/任务说明书/任务说明书-REQ-用户-001-群聊入口接线.md`
> **补充参考**(非三件套,仅供实现比对,路径均已核验存在):
> - 原型(用户端): `docs/01-产品文档/05-用户端H5/原型-REQ-用户-001-群聊双模式-v1.0.html`
> - 原型(坐席端): `docs/01-产品文档/04-坐席工作台/原型-REQ-坐席-003-群聊双模式-v1.0.html`
> - 工具栏基线交付清单: `docs/01-产品文档/02-会话管理/交付-REQ-会话-001-工具栏统一设计v1.9-开发交付清单.md`
---
## 一、背景与目标
### 1.1 需求触发原点
使用者(IT 支持组组长)提出的原始诉求是两句话:
1. **确认坐席端的群聊功能到底做没做?**
2. **如果做了,把员工端工具栏上那颗「群聊」按钮接上。**
### 1.2 核查结论:坐席端早已实现
代码核查确认,**坐席端群聊能力完整可用,本次无需任何改动**。其入口是「邀请」按钮,而非名为「群聊」的按钮:
| 能力 | 文件 · 行号 | 状态 |
|------|------------|------|
| 参与者横条 | `src/frontend-agent/src/components/chat/ChatArea.vue` L38 `<ParticipantBar>` | ✅ 已实现 |
| 邀请事件出口 | `ChatArea.vue` L50 `@invite="showInviteParticipantDialog = true"` | ✅ 已实现 |
| 邀请参与者弹窗 | `ChatArea.vue` L221-222 `<InviteParticipantDialog v-model="showInviteParticipantDialog">` | ✅ 已实现 |
| 摇人邀请弹窗 | `ChatArea.vue` L214 `<InviteDialog>` | ✅ 已实现 |
| 就地展开面板 | `src/frontend-agent/src/components/conversation/ParticipantBar.vue` L78 `<ParticipantExpandedPanel>` | ✅ 已实现 |
| 组件注册 | `ChatArea.vue` L277-279 三处 import | ✅ 已实现 |
**"功能没做"是误判,误判的根因是文档不可发现**:PRD `PRD-REQ-用户-001-群聊双模式-v1.0.md` 头部**缺失 `关联文档` 字段**,从 PRD 无法反向索引到技术方案与架构设计;而实现层的入口叫「邀请」、需求层的名字叫「群聊」,术语不对齐,检索时对不上。本次一并回写 PRD 关联链路(见 §六)。
### 1.3 本方案的目标
**本方案不新增任何群聊能力,只记录并固化一件事:员工端 H5 工具栏「群聊」按钮的入口接线。**
REQ-用户-001 的群聊双模式(缩略头像条 + 展开参与者面板 + 邀请 + 退出)在双端均已完整实现,后端接口与 WebSocket 双池推送全部打通。唯一缺口是:员工端 H5 那颗 `title="群聊"` 的按钮,点击后只弹一句 `showToast('群聊功能开发中')` —— 这句占位 toast 正是使用者判定"功能未开发"的直接来源。
本次改动即:把这句占位 toast 换成对已有 store action 的调用。
| 维度 | 决策 |
|------|------|
| 后端改动 | **无** |
| store 改动 | **无**`toggleParticipantPanel()` 已存在并已导出) |
| 新增组件 | **无** |
| 前端改动文件 | `InputBar.vue` ×1 + 测试 ×2 |
| 本次范围 | **仅员工端 H5**,坐席端零改动(用户已拍板) |
---
## 二、范围与边界
### 2.1 在范围内
- ✅ 仅**员工端 H5**`src/frontend-h5/**`
-`InputBar.vue``handleGroupChat()` 的入口接线
- ✅ 两个测试文件的断言同步
- ✅ PRD ↔ 技术方案 ↔ 任务说明书 三件套关联回写
### 2.2 明确不做(Out of Scope
-**0 后端改动**:不改接口、不改模型、不改推送逻辑
-**不部署**:仅留工作区改动,部署另行安排
-**坐席端不动**`src/frontend-agent/**` 零改动(已有「邀请」入口,用户已拍板)
- ❌ 不新增坐席端第 6 个工具栏按钮;将来若做,采用「邀请」**改名**为「群聊」的方式,保持 5 键基线(方向已定,本次不执行)
- ❌ 不做需求编号收敛、不处理其他文档治理项
- ❌ 不改 `ChatPanel.vue` L69 的 `ParticipantStrip` 渲染条件
---
## 三、实现细节
> 以下文件与行号均以当前工作区代码为准,逐条 Read 核验。
### 3.1 入口断点(改造前)
| 位置 | 内容 |
|------|------|
| `src/frontend-h5/src/components/chat/InputBar.vue` **L345-360** | 拱形工具栏第 5 个按钮:L348 `title="群聊"`、L349 `aria-label="群聊"`、L350 `@click="handleGroupChat"` |
| `src/frontend-h5/src/components/chat/InputBar.vue` **L872-874**v1.9 时期) | 占位实现 `showToast('群聊功能开发中')` |
改造前的函数体只有一行 toast,且注释写着「store 暂无 group chat action……后续接入真实群聊会话时,把 toast 替换为 `store.startGroupChat()` 即可」。该注释在写下之后即已过期 —— `toggleParticipantPanel()` 早已在 store 落地,而 `startGroupChat` 是一个**从未存在过的虚构 action**。注释与代码事实脱节,是按钮长期停留在占位态的直接原因。
### 3.2 目标实现(改造后 · 已落地)
`InputBar.vue` 现状 **L867-899**(常量 L868 / L871,函数 L886-899):
```ts
/** 无会话时点击群聊的提示文案。 */
const GROUP_CHAT_NO_CONVERSATION_TIP = '请先发起会话'
/** 会话内暂无其他同事时的邀请引导文案。 */
const GROUP_CHAT_EMPTY_TIP = '还没有其他同事,点击「邀请参与者」拉同事进群'
function handleGroupChat(): void {
if (!currentConversation.value) {
showToast(GROUP_CHAT_NO_CONVERSATION_TIP)
return
}
const hasParticipants = store.participants.length > 0
store.toggleParticipantPanel()
// 仅在「由收起切换为展开」且暂无其他同事时补引导,避免收起面板时也弹提示
if (!hasParticipants && store.participantPanelVisible) {
showToast(GROUP_CHAT_EMPTY_TIP)
}
}
```
> **与原始设计的差异说明**:契约描述为「先算 `nextVisible = !participantPanelVisible`,若 `!hasParticipants && nextVisible` 则提示」。落地实现改为**先 `toggle()` 再读 `store.participantPanelVisible`**,两者语义完全等价(toggle 后的实际值即 `nextVisible`),且读实际状态比预测值更稳健 —— 若将来 action 内部新增条件分支,实现不会与预测值脱节。文案常量与三分支行为与契约一致。
### 3.3 handleGroupChat 行为契约(三分支)
| # | 前置条件 | toast | `toggleParticipantPanel()` |
|---|---------|-------|---------------------------|
| 1 | 无当前会话 | `'请先发起会话'` | **不调用**(提前 return |
| 2 | 有会话 + `participants.length > 0` | 无 | 调用 1 次 |
| 3 | 有会话 + `participants.length === 0` 且切换为展开 | `'还没有其他同事,点击「邀请参与者」拉同事进群'` | 调用 1 次 |
**分支 3 的设计依据**:零参与者时 `ChatPanel.vue` L69 的 `ParticipantStrip` 不渲染,用户界面上没有任何群聊线索,因此必须由 toast 补一句引导。而面板本身照常打开 —— `ParticipantList` 在零被邀请人时仍会渲染「坐席 + 我(发起人)」,且会话发起人的 `canInvite` 恒为 `true`,「+ 邀请参与者」按钮天然可达,不会出现空白面板。
### 3.4 依赖的 store 契约(只读引用,零修改)
`src/frontend-h5/src/stores/conversation.ts`
| 契约项 | 行号 | 说明 |
|--------|------|------|
| `currentConversation` | **L135** | `ref<ConversationInfo \| null>(null)` — 分支 1 的判据 |
| `participants` | **L191** | `ref<ParticipantItem[]>([])` — 分支 2/3 的判据 |
| `participantPanelVisible` | **L194** | `ref<boolean>(false)` — 面板显隐单一真值源 |
| `toggleParticipantPanel()` | **L1296** | `participantPanelVisible.value = !participantPanelVisible.value`;无网络请求、无持久化 |
| 导出 | L2185 / L2214 | 状态与 action 均已在 return 块内 |
| 重置 | L1367 | 会话清理时置回 `false` |
> ⚠️ 语义提示:这是 **toggle** 而非 `open`。当前按钮为唯一触发点,用户在面板内通过关闭按钮直接置 `false`,不会与 toggle 产生状态错位。若后续新增第二个触发入口,需评估是否补一个 `openParticipantPanel()` 语义化 action。
### 3.5 承载面板的容器(零修改)
`src/frontend-h5/src/components/chat/ChatPanel.vue`
| 位置 | 内容 |
|------|------|
| **L69** | `<ParticipantStrip v-if="store.participants.length > 0" />` — 缩略头像条,零参与者时不渲染 |
| **L133-142** | 底部弹层 `<van-popup :show="store.participantPanelVisible" position="bottom" round teleport="body" :style="{ maxHeight: '60vh' }">`,其中 **L141** 挂载 `<ParticipantList />` |
> 原始描述记为 L135-142,实测弹层完整块为 **L133-142**`van-popup` 起始标签在 L133L132 为注释行),`<ParticipantList />` 位于 L141。以实测为准。
### 3.6 为什么不在 InputBar 里挂 InviteParticipantSheet
`InviteParticipantSheet.vue` **由面板内部自行挂载**`handleGroupChat` 不直接调用它:
- 挂载点一:`ParticipantStrip.vue` L91
- 挂载点二:`ParticipantList.vue` L115
若 InputBar 再挂一次,全应用将出现**第三份**同名弹层实例,带来状态互相覆盖与 `v-model` 归属混乱的风险。分支 3 的引导文案明确指向面板内的「邀请参与者」按钮,用户路径为:**群聊按钮 → 面板打开 → 点面板内「+ 邀请参与者」→ 已挂载的 Sheet 弹出**,全程复用既有实例。
### 3.7 架构一致性:为何选 store 驱动而非 emit
`InputBar.vue` L903 注释白纸黑字写明:**「InputBar 是 v1.3 唯一坐席入口,不向 ChatPanel emit。」** 全文件 `defineEmits` 零匹配,InputBar 至今保持「零 emit、纯 store 驱动」的单向数据流。为一个按钮破例会留下两套并存的通信范式,故定案 store 驱动 —— 这也让改动收敛到 1 个业务文件。
---
## 四、测试影响
### 4.1 受影响的测试文件
| 文件 | 改动性质 |
|------|---------|
| `src/frontend-h5/src/components/chat/InputBar.test.ts` | 同步断言 |
| `src/frontend-h5/src/components/chat/__tests__/InputBar.vitest.test.ts` | 同步断言 |
### 4.2 契约常量对齐
两个测试文件**必须复刻 `InputBar.vue` 的文案常量**,不得内联裸字符串:
| 常量 | 值 |
|------|-----|
| `GROUP_CHAT_NO_CONVERSATION_TIP` | `'请先发起会话'` |
| `GROUP_CHAT_EMPTY_TIP` | `'还没有其他同事,点击「邀请参与者」拉同事进群'` |
现状:`InputBar.test.ts` L177 / L180 已对齐(含注释标注「与 InputBar.vue 对齐」)。
### 4.3 弃用串必须 0 命中
| 弃用串 | 断言位置 | 说明 |
|--------|---------|------|
| `'群聊功能开发中'` | `InputBar.test.ts` L848 · `InputBar.vitest.test.ts` L867 | 含注释在内,`InputBar.vue` 全文不得再出现 |
| `'startGroupChat'` | `InputBar.test.ts` L861 · `InputBar.vitest.test.ts` L871 | 虚构 action,含注释在内不得再被引用 |
> **R1 风险(预期内的红)**:存量测试原本断言 `showToast('群聊功能开发中')`,接线后必然变红。这是预期结果,**不得靠删除测试绕过**,必须改为断言新契约。
### 4.4 测试覆盖现状
| 用例 | 位置 |
|------|------|
| 三分支行为契约 | `InputBar.test.ts` L797 `describe('InputBar v2.1 — handleGroupChat 三分支行为契约')` |
| 按钮可访问性属性不变 | `InputBar.vitest.test.ts` L625`title` / `aria-label` / `@click` 三属性正则) |
| 接线目标为 `toggleParticipantPanel` | `InputBar.test.ts` L861 · `InputBar.vitest.test.ts` L847 |
---
## 五、验收标准
### 5.1 功能验收
| # | 场景 | 预期 |
|---|------|------|
| A1 | 未发起会话 → 点「群聊」 | toast「请先发起会话」;**不**打开参与者面板 |
| A2 | 有会话(含参与者)→ 点「群聊」 | 参与者面板打开;再点一次关闭(开/关可逆) |
| A3 | 有会话 + 零参与者 → 点「群聊」 | 面板打开 **且** toast「还没有其他同事,点击「邀请参与者」拉同事进群」 |
| A4 | A3 状态下再点「群聊」收起面板 | 面板关闭,**不**重复弹邀请引导 |
| A5 | 全代码库 | 无任何 `startGroupChat` 调用;`InputBar.vue``'群聊功能开发中'` |
| A6 | A3 中点面板内「+ 邀请参与者」 | 复用既有 `InviteParticipantSheet`,无第三处挂载 |
| A7 | 工具栏基线 | 5 按钮顺序 emoji / 文件 / 坐席 / 语音 / 群聊 不变,拱形轨道无形变,L345-360 模板未被改动 |
### 5.2 质量门禁
- `pnpm test` 全量通过,重点 `InputBar` 相关 2 个测试文件
- ESLint 无新增告警(`showToast` import 在分支 1/3 仍被使用,不会变成 unused)
- 坐席端零改动,不参与本次回归
### 5.3 文档验收
- PRD 头部 `关联文档` 字段存在,且指向本方案 + 既有技术方案 + 既有架构设计
- 本方案 `关联文档` 反向指向 PRD,双向一致
- 任务说明书已建立并被三件套互相引用
---
## 六、关联文档回写(本轮同步执行)
本轮同时修复「PRD 不可发现」这一根因:
| 文档 | 回写内容 |
|------|---------|
| `PRD-REQ-用户-001-群聊双模式-v1.0.md` | 头部新增 `关联文档` 字段(指向既有技术方案 + 既有架构设计 + 本方案);`状态``待评审``已实现` |
| 本方案 | `关联文档` 指向 PRD + 既有技术方案 + 既有架构设计 + 任务说明书 |
| `任务说明书-REQ-用户-001-群聊入口接线.md` | 新建,引用上述四份文档 |
> **遗留观察(不在本轮范围)**:`docs/02-技术文档/01-架构设计/群聊参与者展开缩略双模式-架构设计.md` 头部的 PRD 指向 `docs/01-产品文档/02-会话管理/群聊参与者展开缩略双模式-PRD.md`,与 `05-用户端H5/PRD-REQ-用户-001-群聊双模式-v1.0.md` 构成同一需求的两份 PRD(两份文件均真实存在,非死链)。属需求编号收敛范畴,本轮按用户要求不处理,登记备查。
---
## 七、影响面与风险
### 7.1 影响面
| 文件 | 改动性质 | 风险 |
|------|---------|------|
| `src/frontend-h5/src/components/chat/InputBar.vue` | 替换单个函数体 + 新增 2 个文案常量 | 🟢 极低 |
| `src/frontend-h5/src/components/chat/InputBar.test.ts` | 同步断言 | 🟢 极低 |
| `src/frontend-h5/src/components/chat/__tests__/InputBar.vitest.test.ts` | 同步断言 | 🟢 极低 |
**零影响面清单**:后端 · store · 路由 · 网络请求 · 数据库 · `ChatPanel.vue` · `ParticipantList.vue` · `ParticipantStrip.vue` · `InviteParticipantSheet.vue` · 坐席端全部文件 —— 均不改动。
### 7.2 风险登记
| # | 风险 | 等级 | 缓解 |
|---|------|------|------|
| R1 | 存量测试断言旧占位文案,改动后必红 | 🟠 中 | 已纳入交付清单,两个测试文件同步更新;不得删测试绕过 |
| R2 | `showToast` import 变为未使用 | 🟡 低 | 分支 1/3 仍在使用,import 保持有效 |
| R3 | toggle 语义在未来多入口场景下状态错位 | 🟡 低 | 当前单入口;后续新增入口时引入 `openParticipantPanel()` |
| R4 | 坐席未接入且零被邀请人时,面板仅一行「我」 | 🟡 低 | 可接受;分支 3 的 toast 已给出下一步引导 |
| R5 | 误改工具栏 DOM,破坏 v1.9 拱形轨道基线 | 🟠 中 | **严禁改动 L345-360 模板**,只改 script;验收项 A7 比对 5 按钮基线 |
---
## 八、变更记录
| 日期 | 版本 | 变更 | 作者 |
|------|------|------|------|
| 2026-08-08 | v1.0 | 初版;定案 store 驱动方案,落地三分支契约;补齐 `需求编号` / `子系统` / `模块` / `关联文档` 头部字段;状态置为「已实现」;同步 PRD 关联回写 | 高见远 |
@@ -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 负责 (回写机制待确认)
```
---
*文档结束。本验证所有"推断"与"待确认"项均已明确标注,未将推断作为既成事实陈述。*
@@ -0,0 +1,295 @@
# 任务说明书 - 员工端 H5 群聊入口接线
> **REQ 编号**: REQ-用户-001-群聊入口接线
> **版本**: v1.0
> **日期**: 2026-08-08
> **作者**: 高见远(架构师)
> **状态**: ✅ 已完成(代码已落地,未部署)
> **子系统**: 05-用户端H5
> **模块**: 群聊
> **关联文档**:
> - **PRD**`docs/01-产品文档/05-用户端H5/PRD-REQ-用户-001-群聊双模式-v1.0.md`
> - **本次技术方案**`docs/02-技术文档/技术方案-REQ-用户-001-群聊入口接线-v1.0.md`
> - **既有技术方案**`docs/02-技术文档/技术架构/技术方案-REQ-用户-001-群聊双模式-v1.0.md`
> - **既有架构设计**`docs/02-技术文档/01-架构设计/群聊参与者展开缩略双模式-架构设计.md`
---
## 📋 一、基本信息
| 项目 | 内容 |
|------|------|
| **任务名称** | 员工端 H5 工具栏「群聊」按钮入口接线 |
| **任务ID** | REQ-用户-001-群聊入口接线 |
| **优先级** | 🟠P1(功能可见性缺陷:能力已具备但入口断路,导致用户误判功能未开发) |
| **类型** | 功能开发(入口接线)+ 文档完善(关联回写) |
| **状态** | 已完成(代码 + 文档已落地,**未部署**) |
| **负责人** | 前端工程师(实现)/ 高见远(方案与验收) |
| **创建日期** | 2026-08-08 |
| **计划完成日期** | 2026-08-08 |
| **预估工期** | 0.5 天(含测试同步与文档回写) |
| **风险等级** | 低(仅 1 个前端业务文件,0 后端 / 0 store / 0 新组件) |
| **回滚预案** | 单函数体回退即可;无数据与接口副作用 |
---
## 📥 二、输入项来源
### 2.1 产品需求
| 来源文档 | 相关章节 | 说明 |
|----------|----------|------|
| `docs/01-产品文档/05-用户端H5/PRD-REQ-用户-001-群聊双模式-v1.0.md` | §3 FE-H5-01 / FE-H5-02 | 缩略头像条 + 底部弹出展开面板的需求定义 |
### 2.2 技术方案
| 来源文档 | 相关章节 | 说明 |
|----------|----------|------|
| `docs/02-技术文档/技术方案-REQ-用户-001-群聊入口接线-v1.0.md` | §三 实现细节 / §五 验收标准 | 本次接线的实现契约与验收口径 |
| `docs/02-技术文档/技术架构/技术方案-REQ-用户-001-群聊双模式-v1.0.md` | 全文 | 群聊双模式既有实现(本次仅复用,不改动) |
| `docs/02-技术文档/01-架构设计/群聊参与者展开缩略双模式-架构设计.md` | Part A §1 | `useParticipantDisplay` 数据规范化层与双模式组件架构 |
### 2.3 原型设计
| 来源文档 | 页面 | 说明 |
|----------|------|------|
| `docs/01-产品文档/05-用户端H5/原型-REQ-用户-001-群聊双模式-v1.0.html` | H5 缩略 / 展开 | 参与者面板交互基准 |
| `docs/01-产品文档/02-会话管理/原型-REQ-会话-001-工具栏统一设计v1.9-员工端落地版.html` | 工具栏 | **5 按钮基线**,本次严禁改动其 DOM 结构 |
### 2.4 需了解的现有代码(历史现状)
| 模块/文件 | 说明 | 需了解的内容 |
|-----------|------|-------------|
| `src/frontend-h5/src/components/chat/InputBar.vue` | 工具栏 + 输入区 | L345-360 群聊按钮模板;改造前 L872-874 占位 toastL903 「不向 ChatPanel emit」约定 |
| `src/frontend-h5/src/stores/conversation.ts` | 会话 store | L135 / L191 / L194 状态,L1296 `toggleParticipantPanel()` |
| `src/frontend-h5/src/components/chat/ChatPanel.vue` | 聊天页容器 | L69 缩略条渲染条件;L133-142 底部弹层 |
| `src/frontend-h5/src/components/chat/ParticipantList.vue` | 展开面板 | L115 已挂载 `InviteParticipantSheet`;发起人 `canInvite` 恒真 |
| `src/frontend-h5/src/components/chat/ParticipantStrip.vue` | 缩略头像条 | L91 已挂载 `InviteParticipantSheet`(第二处) |
### 2.5 上游依赖
- 无 Alembic 迁移需求
- 无后端 API 变更
- 无新增 npm 依赖
- 坐席端 `src/frontend-agent/**` 零改动
---
## 🎯 三、范围
### 3.1 在范围内
- ✅ 仅**员工端 H5**`src/frontend-h5/**`
-`InputBar.vue``handleGroupChat()` 接线(占位 toast → 真实能力)
- ✅ 两个测试文件断言同步(契约常量化 + 弃用串清零)
- ✅ PRD ↔ 技术方案 ↔ 任务说明书 三件套关联回写
### 3.2 明确不做
-**0 后端改动**(接口 / 模型 / 推送均不动)
-**不部署**(仅留工作区改动,部署另行安排)
-**坐席端不动**(已有「邀请」入口,用户已拍板)
- ❌ 不在 InputBar 挂第三个 `InviteParticipantSheet`
- ❌ 不改 `ChatPanel.vue` L69 渲染条件
- ❌ 不做需求编号收敛及其他文档治理
---
## 📤 四、输出成果要求(交付物)
### 4.1 代码交付物
| # | 文件 | 类型 | 改动说明 | 状态 |
|---|------|------|---------|------|
| 1 | `src/frontend-h5/src/components/chat/InputBar.vue` | 代码 | 新增 2 个文案常量 + 重写 `handleGroupChat()` 三分支 | ✅ 已落地 |
| 2 | `src/frontend-h5/src/components/chat/InputBar.test.ts` | 测试 | 三分支契约用例 + 弃用串断言 | ✅ 已落地 |
| 3 | `src/frontend-h5/src/components/chat/__tests__/InputBar.vitest.test.ts` | 测试 | 按钮属性正则 + 接线目标断言 | ✅ 已落地 |
### 4.2 文档交付物
| # | 文档 | 类型 | 状态 |
|---|------|------|------|
| 1 | `docs/02-技术文档/技术方案-REQ-用户-001-群聊入口接线-v1.0.md` | 文档 | ✅ 已建 |
| 2 | `docs/07-项目管理/任务说明书/任务说明书-REQ-用户-001-群聊入口接线.md`(本文档) | 文档 | ✅ 当前 |
| 3 | `docs/01-产品文档/05-用户端H5/PRD-REQ-用户-001-群聊双模式-v1.0.md` | 文档 | ✅ 已回写(新增 `关联文档`,状态 → 已实现) |
### 4.3 代码要求
- 遵循项目代码规范;ESLint 零新增告警
- 文案**必须常量化**`GROUP_CHAT_NO_CONVERSATION_TIP` / `GROUP_CHAT_EMPTY_TIP`),测试侧复刻同名常量,禁止两侧各写裸字符串
- 严禁改动 `InputBar.vue` L345-360 模板(v1.9 工具栏基线)
- 严禁通过删除/跳过测试来消红
### 4.4 部署交付物
- **本次不部署**。工作区改动保留,交由后续统一发布窗口处理。
---
## 📁 五、涉及文件清单(含行号)
### 5.1 需修改
| 文件 | 行号 | 改动 |
|------|------|------|
| `src/frontend-h5/src/components/chat/InputBar.vue` | **L872-874**(改造前占位) | `showToast('群聊功能开发中')` 整段替换 |
| 同上 | **L867-899**(改造后现状) | L868 `GROUP_CHAT_NO_CONVERSATION_TIP`、L871 `GROUP_CHAT_EMPTY_TIP`、L886-899 `handleGroupChat()` |
| `src/frontend-h5/src/components/chat/InputBar.test.ts` | L177 / L180 / L186-187 / L797 / L848 / L861 | 契约常量、弃用串常量、三分支 describe、清零断言 |
| `src/frontend-h5/src/components/chat/__tests__/InputBar.vitest.test.ts` | L625 / L847 / L867 / L871 | 按钮属性正则、接线目标、两处弃用串断言 |
### 5.2 只读引用(严禁修改)
| 文件 | 行号 | 内容 |
|------|------|------|
| `src/frontend-h5/src/components/chat/InputBar.vue` | L345-360 | 群聊按钮模板(L348 `title="群聊"`、L349 `aria-label="群聊"`、L350 `@click="handleGroupChat"` |
| `src/frontend-h5/src/stores/conversation.ts` | L135 | `currentConversation` |
| 同上 | L191 | `participants` |
| 同上 | L194 | `participantPanelVisible` |
| 同上 | L1296 | `toggleParticipantPanel()` |
| 同上 | L2185 / L2214 | 状态与 action 导出 |
| 同上 | L1367 | 会话清理时面板状态复位 |
| `src/frontend-h5/src/components/chat/ChatPanel.vue` | L69 | `<ParticipantStrip v-if="store.participants.length > 0" />` |
| 同上 | L133-142 | 底部弹层 `van-popup`L141 `<ParticipantList />` |
| `src/frontend-h5/src/components/chat/ParticipantList.vue` | L115 | `InviteParticipantSheet` 挂载点一 |
| `src/frontend-h5/src/components/chat/ParticipantStrip.vue` | L91 | `InviteParticipantSheet` 挂载点二 |
> `InviteParticipantSheet.vue` 由面板内部挂载,`handleGroupChat` **不直接调用**,避免出现第三次挂载。
### 5.3 坐席端(本次零改动,仅登记已实现事实)
| 文件 | 行号 | 内容 |
|------|------|------|
| `src/frontend-agent/src/components/chat/ChatArea.vue` | L38 / L50 | `<ParticipantBar>` + `@invite` |
| 同上 | L214 / L221-222 | `<InviteDialog>` / `<InviteParticipantDialog>` |
| 同上 | L277-279 | 三处组件 import |
| `src/frontend-agent/src/components/conversation/ParticipantBar.vue` | L78 | `<ParticipantExpandedPanel>` |
---
## 📊 六、工作项拆分
| # | 子任务 | 涉及文件 | 负责人 | 预估工时 | 状态 |
|---|--------|---------|--------|---------|------|
| 1 | **接线 `handleGroupChat`** —— 占位 toast 替换为三分支实现,文案常量化 | `InputBar.vue` L867-899 | 前端 | 1h | ✅ 已完成 |
| 2 | **同步两测试** —— 复刻契约常量,新增三分支用例,弃用串 `'群聊功能开发中'` / `'startGroupChat'` 断言 0 命中 | `InputBar.test.ts``__tests__/InputBar.vitest.test.ts` | 前端 | 1.5h | ✅ 已完成 |
| 3 | **文档关联回写** —— 新建技术方案与本说明书;PRD 头部补 `关联文档`、状态改 `已实现` | 3 份文档 | 架构 | 1h | ✅ 已完成 |
### 子任务验收要点
- **子任务 1**:分支 1 提前 return 不得调用 toggle;分支 3 的 toast 仅在「收起 → 展开」方向触发
- **子任务 2**:不得以删除或 skip 测试的方式消红(R1 为预期内的红)
- **子任务 3**:PRD 与技术方案的 `关联文档` 必须双向一致
---
## 🔧 七、验证方式
### 7.1 单元测试
| 用例 | 断言 | 位置 |
|------|------|------|
| 无会话点击 | toast = `'请先发起会话'`**未**调用 `toggleParticipantPanel` | `InputBar.test.ts` L797 起 |
| 有会话 + 有参与者 | `toggleParticipantPanel()` 恰好 1 次,无 toast | 同上 |
| 有会话 + 零参与者(展开方向) | `toggleParticipantPanel()` 1 次 + toast = `GROUP_CHAT_EMPTY_TIP` | 同上 |
| 收起方向不重复提示 | 零参与者收起面板时无 toast | 同上 |
| 弃用串清零 | `'群聊功能开发中'` / `'startGroupChat'``InputBar.vue` 中 0 命中 | `InputBar.test.ts` L848 / L861`InputBar.vitest.test.ts` L867 / L871 |
| 按钮属性不变 | `title="群聊"` + `aria-label="群聊"` + `@click="handleGroupChat"` | `InputBar.vitest.test.ts` L625 |
### 7.2 功能验证(手工)
| # | 步骤 | 预期结果 |
|---|------|---------|
| M1 | 未发起会话 → 点群聊 | toast「请先发起会话」,无面板 |
| M2 | 有会话(0 被邀请人)→ 点群聊 | 面板弹出(显示坐席 + 我),并 toast 邀请引导 |
| M3 | M2 中点「+ 邀请参与者」→ 选人确认 | 复用既有 `InviteParticipantSheet`,提交成功 |
| M4 | M3 成功后观察聊天页 | WS 推送到达,`ParticipantStrip` 自动出现(无需刷新) |
| M5 | 反复点群聊 | 面板开合正常,收起时不重复弹提示,无状态卡死 |
| M6 | 对照工具栏基线 | 5 按钮顺序 emoji / 文件 / 坐席 / 语音 / 群聊 不变,拱形轨道无形变 |
| M7 | 被邀请人身份进入会话 → 点群聊 | 面板显示「退出会话」,二次确认可用 |
### 7.3 安全验证
| 验证项 | 验证方法 | 预期结果 |
|--------|----------|---------|
| 权限控制 | 非发起人 / 非参与者身份点击群聊 | 沿用 `ParticipantList` 既有 `canInvite` 判定,无越权邀请入口 |
| 输入验证 | 本次无新增用户输入 | N/A(纯状态切换,无网络请求) |
### 7.4 性能验证
| 验证项 | 验证方法 | 预期结果 |
|--------|----------|---------|
| 响应时间 | 点击到面板弹出 | 纯本地状态切换,无网络往返,肉眼无延迟 |
| 并发能力 | N/A | 本次不涉及后端 |
### 7.5 回归范围
- `pnpm test` 全量通过(重点 `InputBar` 相关 2 文件)
- 手工回归工具栏其余 4 键(emoji / 文件 / 坐席 / 语音)
- 坐席端零改动,不参与本次回归
---
## ✅ 八、完成标准
### 验收条件
- [x] `handleGroupChat` 三分支行为与技术方案 §3.3 契约一致
- [x] 文案常量化,测试侧复刻同名常量
- [x] `'群聊功能开发中'` / `'startGroupChat'``InputBar.vue` 中 0 命中
- [x] 未在 InputBar 新增 `InviteParticipantSheet` 挂载
- [x] 工具栏 5 按钮基线未被破坏(L345-360 模板未改)
- [x] 后端 / store / 坐席端 零改动
- [x] 文档已更新(技术方案 + 任务说明书 + PRD 回写)
- [ ] 所有测试通过(CI/CD 绿灯)—— 待工程侧执行 `pnpm test` 确认
- [ ] 代码合入主干分支 —— **本次不提交、不部署**,工作区改动待发布窗口
### 产出确认
- [x] 单元测试新增/修复完成
- [x] 文档更新已完成(三件套关联双向一致)
- [ ] Code Review
- [ ] 部署验证(本次明确不部署)
---
## 📞 九、依赖与阻塞
### 前置依赖
| 依赖任务 | 依赖说明 | 状态 |
|----------|----------|------|
| REQ-用户-001 群聊双模式 | 缩略条 / 展开面板 / 邀请 / 退出能力,本次直接复用 | ✅ 已完成 |
| REQ-会话-001 工具栏统一设计 v1.9 | 提供群聊按钮的 DOM 位(第 5 键) | ✅ 已完成 |
| store `toggleParticipantPanel()` | 面板显隐 action,已存在并导出 | ✅ 已完成 |
### 阻塞因素
| 阻塞项 | 影响范围 | 解决方案 |
|--------|----------|---------|
| 无 | — | 本任务无阻塞项 |
### 遗留登记(不在本轮范围)
| 项 | 说明 | 处置 |
|----|------|------|
| 同需求双 PRD | `02-会话管理/群聊参与者展开缩略双模式-PRD.md``05-用户端H5/PRD-REQ-用户-001-群聊双模式-v1.0.md` 描述同一需求(两文件均存在,非死链) | 归入需求编号收敛,本轮按用户要求不处理 |
| 坐席端按钮命名 | 坐席端入口叫「邀请」,需求语义是「群聊」,术语不对齐 | 将来若统一,采用「邀请」改名方案,保持 5 键基线 |
---
## 📈 十、变更记录
| 日期 | 变更内容 | 变更人 | 说明 |
|------|----------|--------|------|
| 2026-08-08 | 创建任务 | 高见远 | 初始版本;记录员工端 H5 群聊入口接线与三件套关联回写 |
---
## 📎 十一、附件
- PRD`docs/01-产品文档/05-用户端H5/PRD-REQ-用户-001-群聊双模式-v1.0.md`
- 本次技术方案:`docs/02-技术文档/技术方案-REQ-用户-001-群聊入口接线-v1.0.md`
- 既有技术方案:`docs/02-技术文档/技术架构/技术方案-REQ-用户-001-群聊双模式-v1.0.md`
- 既有架构设计:`docs/02-技术文档/01-架构设计/群聊参与者展开缩略双模式-架构设计.md`
- 原型(用户端):`docs/01-产品文档/05-用户端H5/原型-REQ-用户-001-群聊双模式-v1.0.html`
- 工具栏基线交付清单:`docs/01-产品文档/02-会话管理/交付-REQ-会话-001-工具栏统一设计v1.9-开发交付清单.md`
+355 -19
View File
@@ -8,9 +8,11 @@
# ============================================================================= # =============================================================================
import asyncio import asyncio
import json
import logging import logging
import os import os
from typing import Optional from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
import httpx import httpx
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
@@ -29,6 +31,63 @@ router = APIRouter()
# IT资产升级申请模板ID(回调时用于识别审批类型,触发年限核查推送) # IT资产升级申请模板ID(回调时用于识别审批类型,触发年限核查推送)
ASSET_UPGRADE_TEMPLATE_ID = "Bs7ucTGsPuFhxfk8pn8EydxrWxkVetB4JR8Pb6PHS" 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客户端(依赖注入) # Redis客户端(依赖注入)
async def get_redis() -> aioredis.Redis: async def get_redis() -> aioredis.Redis:
"""获取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} 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 端点 # API 端点
# ============================================================================= # =============================================================================
@@ -935,26 +1276,21 @@ async def approval_callback(
""" """
logger.info(f"审批回调: sp_no={sp_no}, status={sp_status}, event={status_change_event}") logger.info(f"审批回调: sp_no={sp_no}, status={sp_status}, event={status_change_event}")
# TODO: 根据业务需求处理审批状态变化 event_type = APPROVAL_EVENT_MAP.get(status_change_event, f"unknown_{status_change_event}")
# 例如:
# - 审批通过后,更新IT服务台待办状态
# - 审批驳回后,通知申请人
# - 审批撤销后,关闭相关工单
event_map = {
1: "submitted",
2: "approved",
3: "rejected",
4: "transferred",
5: "reminded",
6: "revoked",
8: "revoked_after_approved",
10: "commented"
}
event_type = event_map.get(status_change_event, f"unknown_{status_change_event}")
logger.info(f"审批事件类型: {event_type}") 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资产升级申请提单时,自动触发年限核查+推送(异步执行,不阻塞回调响应) # IT资产升级申请提单时,自动触发年限核查+推送(异步执行,不阻塞回调响应)
if status_change_event == 1 and template_id == ASSET_UPGRADE_TEMPLATE_ID: if status_change_event == 1 and template_id == ASSET_UPGRADE_TEMPLATE_ID:
logger.info(f"检测到IT资产升级申请提单: sp_no={sp_no}") logger.info(f"检测到IT资产升级申请提单: sp_no={sp_no}")
+64 -1
View File
@@ -14,6 +14,7 @@
# - H5 接口用 Header X-Employee-Id(与现有接口风格一致 — 实际生产应加 RBAC) # - H5 接口用 Header X-Employee-Id(与现有接口风格一致 — 实际生产应加 RBAC)
# ============================================================================= # =============================================================================
import asyncio
import logging import logging
import os import os
from typing import List, Optional from typing import List, Optional
@@ -98,6 +99,64 @@ def _verify_h5_employee(x_employee_id: Optional[str]) -> str:
return x_employee_id 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 个端点的请求/响应 # Pydantic models — 4 个端点的请求/响应
# ============================================================================= # =============================================================================
@@ -200,12 +259,16 @@ async def approval_webhook(
鉴权:Header X-WeCom-Token = 环境变量 WECOM_WEBHOOK_TOKEN 鉴权:Header X-WeCom-Token = 环境变量 WECOM_WEBHOOK_TOKEN
行为: 行为:
- 调 `svc.update_progress()` 落库 + WS 推送右侧栏 - 调 `svc.update_progress()` 落库 + WS 推送右侧栏H5 员工端进度卡片)
- 同步回写坐席端待办状态 + WS 推送(审批降级跳转方案的最终一致闭环)
- 若 approval_id 不存在(提前于 H5 initial),返回 action='skipped' - 若 approval_id 不存在(提前于 H5 initial),返回 action='skipped'
让企微知道"我们已收到但暂无可更新记录" 让企微知道"我们已收到但暂无可更新记录"
""" """
_verify_wecom_token(x_wecom_token) _verify_wecom_token(x_wecom_token)
# 坐席端待办回写(异步执行,不阻塞 webhook ACK;失败已在内部吞掉)
asyncio.create_task(_writeback_agent_todo(payload.approval_id, payload.status))
try: try:
rec = svc.update_progress( rec = svc.update_progress(
approval_id=payload.approval_id, 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 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 { function handleAction(action: string): void {
ElMessage.success(`操作成功:${action}`) if (props.todoItem.type === 'approval') {
console.info('[TaskDetailView] 审批动作已跳转企微审批原系统:', action)
return
}
ElMessage.info('该操作需在原系统中完成')
} }
</script> </script>
@@ -16,7 +16,14 @@
// 功能: // 功能:
// 1. 审批内容卡片(审批单号/模板名称/申请人/申请时间/当前审批人) // 1. 审批内容卡片(审批单号/模板名称/申请人/申请时间/当前审批人)
// 2. 审批意见输入区(textarea,仅供参考) // 2. 审批意见输入区(textarea,仅供参考)
// 3. 底部操作按钮(在企微审批中打开 — 跳转到原系统操作 // 3. 底部操作按钮(通过/拒绝/转交 + 在企微审批中打开)
//
// ⚠️ 降级跳转说明(Phase 0):
// 企微官方无「代审批人执行同意/拒绝/转交」的服务端接口,PC Web 也不具备
// 对应的 JS-SDK 能力,因此服务台内不可能直接完成审批动作。
// 本组件的所有审批动作统一降级为「跳转企微审批原系统」:点击即在新标签页
// 打开该审批单的深链,由审批人本人在企微完成操作;服务台侧状态由企微回调
// 回写(最终一致),不在前端做任何乐观更新或假成功提示。
// ============================================================================= --> // ============================================================================= -->
<template> <template>
@@ -72,14 +79,31 @@
</div> </div>
<!-- ================================================================== --> <!-- ================================================================== -->
<!-- 底部操作按钮 跳转企微审批原系统操作 --> <!-- 底部操作按钮 全部为跳转企微审批原系统出口降级方案 -->
<!-- ================================================================== --> <!-- ================================================================== -->
<div class="apv-action-hint">
审批动作需由审批人本人在企微审批中完成点击下方按钮将在新标签页打开该审批单
</div>
<div class="tic-actions"> <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 <a
class="tic-action-btn tic-action-primary" class="tic-action-btn tic-action-primary"
:href="wecomApprovalUrl" :href="wecomApprovalUrl"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
@click="handleApprovalAction('open')"
> >
🔗 在企微审批中打开 🔗 在企微审批中打开
</a> </a>
@@ -114,7 +138,18 @@ interface Emits {
(e: 'action', action: string): void (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); 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 { .tic-actions {
display: flex; display: flex;
@@ -15,6 +15,7 @@
import { useAgentStore } from '@/stores/agent' import { useAgentStore } from '@/stores/agent'
import { useConversationStore } from '@/stores/conversation' import { useConversationStore } from '@/stores/conversation'
import { useTodoStore } from '@/stores/todo'
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------
// 常量配置 // 常量配置
@@ -472,6 +473,26 @@ export function useWebSocket() {
} }
break 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: default:
console.warn(`[WebSocket] 未知消息类型: ${msg.type}`) console.warn(`[WebSocket] 未知消息类型: ${msg.type}`)
} }