Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5311a526af | |||
| 9294cf12c1 | |||
| 9292f41763 |
@@ -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-02~08-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` 起始标签在 L133,L132 为注释行),`<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_token(IT 支持应用 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_userid(itsm_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 占位 toast;L903 「不向 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
@@ -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_type(all/approval/ticket),其余部分是 userid
|
||||||
|
return remainder.rsplit(":", 1)[0]
|
||||||
|
|
||||||
|
|
||||||
|
async def _patch_todo_cache(
|
||||||
|
redis: aioredis.Redis,
|
||||||
|
sp_no: str,
|
||||||
|
todo_status: str,
|
||||||
|
sp_status: int,
|
||||||
|
) -> List[str]:
|
||||||
|
"""就地改写待办列表缓存中命中的审批条目,并返回受影响的坐席 userid 列表。
|
||||||
|
|
||||||
|
做什么:扫描 todo:cache:*,找到 items 中 id == "approval:{sp_no}" 的条目,
|
||||||
|
更新其 status 与 description.sp_status,然后按剩余 TTL 写回。
|
||||||
|
为什么:待办不落库,缓存就是坐席端当前看到的「本地待办」;不改写的话,
|
||||||
|
坐席在缓存过期前仍会看到已终结的审批单。
|
||||||
|
|
||||||
|
单个 Key 处理失败不影响其他 Key。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
redis: Redis 异步客户端
|
||||||
|
sp_no: 企微审批单号(本地待办反查键)
|
||||||
|
todo_status: 回写后的本地待办状态
|
||||||
|
sp_status: 企微审批单状态(同步写入 description.sp_status)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List[str]: 命中该审批单的坐席 userid 列表(去重,顺序稳定)
|
||||||
|
"""
|
||||||
|
item_id = f"approval:{sp_no}"
|
||||||
|
affected_agents: List[str] = []
|
||||||
|
seen_agents: set = set()
|
||||||
|
|
||||||
|
try:
|
||||||
|
keys = await redis.keys(f"{TODO_CACHE_KEY_PREFIX}*")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"扫描待办缓存失败: sp_no={sp_no}, error={e}")
|
||||||
|
return affected_agents
|
||||||
|
|
||||||
|
for raw_key in keys or []:
|
||||||
|
key = raw_key.decode("utf-8") if isinstance(raw_key, bytes) else str(raw_key)
|
||||||
|
try:
|
||||||
|
raw_value = await redis.get(key)
|
||||||
|
if not raw_value:
|
||||||
|
continue
|
||||||
|
if isinstance(raw_value, bytes):
|
||||||
|
raw_value = raw_value.decode("utf-8")
|
||||||
|
|
||||||
|
payload: Dict[str, Any] = json.loads(raw_value)
|
||||||
|
items = payload.get("items")
|
||||||
|
if not isinstance(items, list):
|
||||||
|
continue
|
||||||
|
|
||||||
|
matched = False
|
||||||
|
for item in items:
|
||||||
|
if not isinstance(item, dict) or item.get("id") != item_id:
|
||||||
|
continue
|
||||||
|
item["status"] = todo_status
|
||||||
|
description = item.get("description")
|
||||||
|
if isinstance(description, dict):
|
||||||
|
description["sp_status"] = sp_status
|
||||||
|
matched = True
|
||||||
|
|
||||||
|
if not matched:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 保留剩余 TTL 写回(拿不到有效 TTL 时用兜底值,避免写成永不过期)
|
||||||
|
ttl = await redis.ttl(key)
|
||||||
|
if not isinstance(ttl, int) or ttl <= 0:
|
||||||
|
ttl = TODO_CACHE_FALLBACK_TTL
|
||||||
|
await redis.setex(key, ttl, json.dumps(payload, ensure_ascii=False))
|
||||||
|
|
||||||
|
agent_userid = _parse_agent_userid_from_cache_key(key)
|
||||||
|
if agent_userid and agent_userid not in seen_agents:
|
||||||
|
seen_agents.add(agent_userid)
|
||||||
|
affected_agents.append(agent_userid)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"回写待办缓存失败: key={key}, sp_no={sp_no}, error={e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
return affected_agents
|
||||||
|
|
||||||
|
|
||||||
|
async def _save_approval_status_snapshot(
|
||||||
|
redis: aioredis.Redis,
|
||||||
|
sp_no: str,
|
||||||
|
snapshot: Dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
"""持久化一份审批状态回写快照(TTL 7 天)。
|
||||||
|
|
||||||
|
用途:待办列表缓存只有 45s,快照可用于排障、审计以及回调乱序时的追溯。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
redis: Redis 异步客户端
|
||||||
|
sp_no: 审批单号
|
||||||
|
snapshot: 快照内容
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await redis.setex(
|
||||||
|
TODO_APPROVAL_STATUS_KEY.format(sp_no=sp_no),
|
||||||
|
TODO_APPROVAL_STATUS_TTL,
|
||||||
|
json.dumps(snapshot, ensure_ascii=False),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"写入审批状态快照失败: sp_no={sp_no}, error={e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def _push_todo_status_to_agents(
|
||||||
|
agent_userids: List[str], payload: Dict[str, Any]
|
||||||
|
) -> None:
|
||||||
|
"""向相关坐席推送待办状态变更事件(复用现有 WS 推送通道)。
|
||||||
|
|
||||||
|
推送失败不抛异常(坐席可能不在线),由前端下次拉取兜底。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
agent_userids: 目标坐席 userid 列表
|
||||||
|
payload: 事件数据(对应前端 msg.data)
|
||||||
|
"""
|
||||||
|
if not agent_userids:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 延迟导入,避免 api 层与 services 层在模块加载期形成循环依赖
|
||||||
|
from app.services.ws_manager import manager as ws_manager
|
||||||
|
|
||||||
|
message = {"type": "todo_status_changed", "data": payload}
|
||||||
|
for agent_userid in agent_userids:
|
||||||
|
try:
|
||||||
|
await ws_manager.send_to_agent(agent_userid, message)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"推送待办状态变更失败: agent={agent_userid}, "
|
||||||
|
f"sp_no={payload.get('sp_no')}, error={e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def writeback_approval_todo_status(
|
||||||
|
sp_no: str,
|
||||||
|
sp_status: int,
|
||||||
|
status_change_event: int,
|
||||||
|
redis: aioredis.Redis,
|
||||||
|
template_id: str = "",
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""企微审批回调 → 坐席待办状态回写 + WS 推送(最终一致)。
|
||||||
|
|
||||||
|
流程:
|
||||||
|
1. 映射 status_change_event/sp_status → 本地待办状态
|
||||||
|
2. 就地改写命中的待办列表缓存条目,得到受影响坐席
|
||||||
|
3. 写入状态快照(TTL 7 天)
|
||||||
|
4. 向受影响坐席推送 todo_status_changed 事件
|
||||||
|
|
||||||
|
全流程 try/except 保护:回写失败只记日志,绝不影响回调 ACK
|
||||||
|
(企微回调失败会重试,且服务台侧有 45s 缓存过期兜底)。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sp_no: 审批单号(本地待办反查键,本地 id = "approval:{sp_no}")
|
||||||
|
sp_status: 企微审批单状态
|
||||||
|
status_change_event: 企微状态变化类型
|
||||||
|
redis: Redis 异步客户端
|
||||||
|
template_id: 审批模板 ID(可选,仅用于日志与快照)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: 回写结果 {success, sp_no, status, agents}
|
||||||
|
"""
|
||||||
|
if not sp_no:
|
||||||
|
return {"success": False, "sp_no": sp_no, "message": "sp_no 为空,跳过回写"}
|
||||||
|
|
||||||
|
event_type = APPROVAL_EVENT_MAP.get(
|
||||||
|
status_change_event, f"unknown_{status_change_event}"
|
||||||
|
)
|
||||||
|
todo_status = _map_approval_todo_status(status_change_event, sp_status)
|
||||||
|
updated_at = datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 1. 回写待办列表缓存,拿到受影响坐席
|
||||||
|
affected_agents = await _patch_todo_cache(redis, sp_no, todo_status, sp_status)
|
||||||
|
|
||||||
|
# 2. 持久化状态快照
|
||||||
|
snapshot: Dict[str, Any] = {
|
||||||
|
"sp_no": sp_no,
|
||||||
|
"template_id": template_id,
|
||||||
|
"sp_status": sp_status,
|
||||||
|
"status_change_event": status_change_event,
|
||||||
|
"event_type": event_type,
|
||||||
|
"todo_status": todo_status,
|
||||||
|
"affected_agents": affected_agents,
|
||||||
|
"updated_at": updated_at,
|
||||||
|
}
|
||||||
|
await _save_approval_status_snapshot(redis, sp_no, snapshot)
|
||||||
|
|
||||||
|
# 3. 推送待办状态变更(仅在有命中坐席时推送)
|
||||||
|
await _push_todo_status_to_agents(
|
||||||
|
affected_agents,
|
||||||
|
{
|
||||||
|
"item_id": f"approval:{sp_no}",
|
||||||
|
"todo_type": "approval",
|
||||||
|
"sp_no": sp_no,
|
||||||
|
"sp_status": sp_status,
|
||||||
|
"status": todo_status,
|
||||||
|
"event_type": event_type,
|
||||||
|
"updated_at": updated_at,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"审批待办状态回写完成: sp_no={sp_no}, event={event_type}, "
|
||||||
|
f"todo_status={todo_status}, agents={affected_agents}"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"sp_no": sp_no,
|
||||||
|
"status": todo_status,
|
||||||
|
"event_type": event_type,
|
||||||
|
"agents": affected_agents,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"审批待办状态回写失败: sp_no={sp_no}, error={e}", exc_info=True)
|
||||||
|
return {"success": False, "sp_no": sp_no, "message": f"回写失败: {e}"}
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# API 端点
|
# 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}")
|
||||||
|
|||||||
@@ -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_changed,payload 字段完整)
|
||||||
|
# 5. 回调端点 POST /approval/callback 触发回写
|
||||||
|
# 6. webhook 路径 _writeback_agent_todo 的字符串状态 → 企微数值语义映射
|
||||||
|
#
|
||||||
|
# 依赖:本文件自带 FakeRedis(conftest 的 MockRedis 缺少 keys/ttl,
|
||||||
|
# 无法驱动 _patch_todo_cache 的扫描逻辑)。不需要真实 Redis / 企微环境。
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import fnmatch
|
||||||
|
import json
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
|
from app.api import approval as approval_mod
|
||||||
|
from app.api import approval_webhook as webhook_mod
|
||||||
|
from app.api.approval import (
|
||||||
|
TODO_APPROVAL_STATUS_KEY,
|
||||||
|
TODO_APPROVAL_STATUS_TTL,
|
||||||
|
TODO_CACHE_FALLBACK_TTL,
|
||||||
|
_map_approval_todo_status,
|
||||||
|
_parse_agent_userid_from_cache_key,
|
||||||
|
writeback_approval_todo_status,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 测试替身:FakeRedis
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRedis:
|
||||||
|
"""最小可用的异步 Redis 替身,覆盖回写链路用到的命令。
|
||||||
|
|
||||||
|
与 redis-py(decode_responses=False)行为对齐:get/keys 返回 bytes。
|
||||||
|
通过 ``decode_responses=True`` 可切换为返回 str,用于验证源码的双形态兼容。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, decode_responses: bool = False) -> None:
|
||||||
|
self._data: Dict[str, str] = {}
|
||||||
|
self._ttl: Dict[str, int] = {}
|
||||||
|
self.decode_responses = decode_responses
|
||||||
|
self.closed = False
|
||||||
|
# 故障注入:设为异常实例后,对应命令抛出该异常
|
||||||
|
self.fail_on_keys: Optional[Exception] = None
|
||||||
|
self.fail_on_setex: Optional[Exception] = None
|
||||||
|
|
||||||
|
# -- 内部工具 ---------------------------------------------------------
|
||||||
|
def _out(self, value: str):
|
||||||
|
return value if self.decode_responses else value.encode("utf-8")
|
||||||
|
|
||||||
|
# -- Redis 命令 -------------------------------------------------------
|
||||||
|
async def keys(self, pattern: str) -> List[Any]:
|
||||||
|
if self.fail_on_keys is not None:
|
||||||
|
raise self.fail_on_keys
|
||||||
|
return [self._out(k) for k in self._data if fnmatch.fnmatch(k, pattern)]
|
||||||
|
|
||||||
|
async def get(self, key: str):
|
||||||
|
value = self._data.get(key)
|
||||||
|
return None if value is None else self._out(value)
|
||||||
|
|
||||||
|
async def setex(self, key: str, ttl: int, value: str) -> bool:
|
||||||
|
if self.fail_on_setex is not None:
|
||||||
|
raise self.fail_on_setex
|
||||||
|
self._data[key] = value
|
||||||
|
self._ttl[key] = ttl
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def ttl(self, key: str) -> int:
|
||||||
|
if key not in self._data:
|
||||||
|
return -2 # key 不存在
|
||||||
|
return self._ttl.get(key, -1) # -1 = 无过期时间
|
||||||
|
|
||||||
|
async def delete(self, *keys) -> int:
|
||||||
|
removed = 0
|
||||||
|
for key in keys:
|
||||||
|
if key in self._data:
|
||||||
|
del self._data[key]
|
||||||
|
self._ttl.pop(key, None)
|
||||||
|
removed += 1
|
||||||
|
return removed
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
# -- 测试辅助 ---------------------------------------------------------
|
||||||
|
def seed_json(self, key: str, payload: Dict[str, Any], ttl: int = 45) -> None:
|
||||||
|
self._data[key] = json.dumps(payload, ensure_ascii=False)
|
||||||
|
self._ttl[key] = ttl
|
||||||
|
|
||||||
|
def seed_raw(self, key: str, raw: str, ttl: int = 45) -> None:
|
||||||
|
self._data[key] = raw
|
||||||
|
self._ttl[key] = ttl
|
||||||
|
|
||||||
|
def load_json(self, key: str) -> Dict[str, Any]:
|
||||||
|
return json.loads(self._data[key])
|
||||||
|
|
||||||
|
def ttl_of(self, key: str) -> Optional[int]:
|
||||||
|
return self._ttl.get(key)
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeSettings:
|
||||||
|
"""替身 settings:仅暴露 create_redis_client,返回注入的 FakeRedis。
|
||||||
|
|
||||||
|
直接 patch ``webhook_mod.settings.create_redis_client`` 会触碰 pydantic
|
||||||
|
frozen 实例的属性描述符而报错;改为替换模块级 ``settings`` 全局名,既避开
|
||||||
|
pydantic 内部又保留 ``_writeback_agent_todo`` 对 ``settings.create_redis_client``
|
||||||
|
的调用语义。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, client: "FakeRedis") -> None:
|
||||||
|
self._client = client
|
||||||
|
|
||||||
|
def create_redis_client(self):
|
||||||
|
return self._client
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 测试数据工厂
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def make_approval_item(sp_no: str, status: str = "pending", sp_status: int = 1) -> Dict[str, Any]:
|
||||||
|
"""构造一条与 TodoSourceService._map_to_todo_item 同构的审批待办条目。"""
|
||||||
|
return {
|
||||||
|
"id": f"approval:{sp_no}",
|
||||||
|
"type": "approval",
|
||||||
|
"title": "IT资产升级申请",
|
||||||
|
"priority": "high",
|
||||||
|
"status": status,
|
||||||
|
"description": {
|
||||||
|
"sp_no": sp_no,
|
||||||
|
"template_name": "IT资产升级申请",
|
||||||
|
"template_id": "Bs7ucTGsPuFhxfk8pn8EydxrWxkVetB4JR8Pb6PHS",
|
||||||
|
"applicant": "zhangsan",
|
||||||
|
"apply_time": 1754500000,
|
||||||
|
"sp_status": sp_status,
|
||||||
|
"current_approver": "agent001",
|
||||||
|
},
|
||||||
|
"assigned_agent_id": "agent001",
|
||||||
|
"corp_id": "test_corp",
|
||||||
|
"created_at": "2026-08-08T10:00:00",
|
||||||
|
"updated_at": "2026-08-08T10:00:00",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def make_ticket_item(ticket_id: str = "T1001") -> Dict[str, Any]:
|
||||||
|
"""构造一条工单待办条目(用于验证非审批条目不受影响)。"""
|
||||||
|
return {
|
||||||
|
"id": f"ticket:{ticket_id}",
|
||||||
|
"type": "ticket",
|
||||||
|
"title": "打印机故障",
|
||||||
|
"priority": "normal",
|
||||||
|
"status": "pending",
|
||||||
|
"description": {"ticket_id": ticket_id},
|
||||||
|
"assigned_agent_id": "agent001",
|
||||||
|
"corp_id": "test_corp",
|
||||||
|
"created_at": "2026-08-08T10:00:00",
|
||||||
|
"updated_at": "2026-08-08T10:00:00",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def cache_key(agent: str, todo_type: str = "all") -> str:
|
||||||
|
"""与 TodoAggregatorService._cache_key 保持一致的 Key 拼接。"""
|
||||||
|
return f"todo:cache:{agent}:{todo_type}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fake_redis() -> FakeRedis:
|
||||||
|
return FakeRedis()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def ws_send() -> AsyncMock:
|
||||||
|
"""替换 ws_manager.send_to_agent,捕获 WS 推送。"""
|
||||||
|
from app.services import ws_manager as ws_manager_mod
|
||||||
|
|
||||||
|
mock = AsyncMock()
|
||||||
|
with patch.object(ws_manager_mod.manager, "send_to_agent", mock):
|
||||||
|
yield mock
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 1. 状态映射(纯函数)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestApprovalStatusMapping:
|
||||||
|
"""status_change_event / sp_status → 本地待办状态。"""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"event, expected",
|
||||||
|
[
|
||||||
|
(1, "pending"), # 提单:单子仍在流转
|
||||||
|
(2, "resolved"), # 同意:终结
|
||||||
|
(3, "resolved"), # 驳回:终结
|
||||||
|
(4, "resolved"), # 转审:对当前审批人已终结
|
||||||
|
(5, "pending"), # 催办:仍在流转
|
||||||
|
(6, "resolved"), # 撤销:终结
|
||||||
|
(8, "resolved"), # 通过后撤销:终结
|
||||||
|
(10, "pending"), # 添加备注:仍在流转
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_event_maps_to_expected_todo_status(self, event: int, expected: str):
|
||||||
|
# sp_status 传 1(审批中)以确保结果确实来自 event 映射而非兜底
|
||||||
|
assert _map_approval_todo_status(event, 1) == expected
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"sp_status, expected",
|
||||||
|
[
|
||||||
|
(1, "pending"),
|
||||||
|
(2, "resolved"),
|
||||||
|
(3, "resolved"),
|
||||||
|
(4, "resolved"),
|
||||||
|
(6, "resolved"),
|
||||||
|
(7, "resolved"),
|
||||||
|
(10, "resolved"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_unknown_event_falls_back_to_sp_status(self, sp_status: int, expected: str):
|
||||||
|
# event=99 不在映射表中 → 回退 sp_status 映射
|
||||||
|
assert _map_approval_todo_status(99, sp_status) == expected
|
||||||
|
|
||||||
|
def test_unknown_event_and_unknown_sp_status_defaults_pending(self):
|
||||||
|
assert _map_approval_todo_status(99, 999) == "pending"
|
||||||
|
|
||||||
|
|
||||||
|
class TestCacheKeyParsing:
|
||||||
|
"""待办缓存 Key → 坐席 userid。"""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"key, expected",
|
||||||
|
[
|
||||||
|
("todo:cache:agent001:all", "agent001"),
|
||||||
|
("todo:cache:agent001:approval", "agent001"),
|
||||||
|
("todo:cache:WangWu:ticket", "WangWu"),
|
||||||
|
# 容错:userid 内含冒号时按「去掉前缀与末段」解析
|
||||||
|
("todo:cache:corp:agent001:all", "corp:agent001"),
|
||||||
|
# 非本前缀 / 缺末段 → 空串
|
||||||
|
("other:cache:agent001:all", ""),
|
||||||
|
("todo:cache:agent001", ""),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_parse_agent_userid(self, key: str, expected: str):
|
||||||
|
assert _parse_agent_userid_from_cache_key(key) == expected
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 2~4. 回写主入口:缓存改写 + 快照 + WS 推送
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestWritebackApprovalTodoStatus:
|
||||||
|
"""writeback_approval_todo_status 主入口。"""
|
||||||
|
|
||||||
|
async def test_approved_event_updates_cache_snapshot_and_agents(
|
||||||
|
self, fake_redis: FakeRedis, ws_send: AsyncMock
|
||||||
|
):
|
||||||
|
# Arrange:坐席 agent001 的待办缓存中有一条 pending 的 SP001
|
||||||
|
key = cache_key("agent001")
|
||||||
|
fake_redis.seed_json(key, {"items": [make_approval_item("SP001")], "total": 1})
|
||||||
|
|
||||||
|
# Act:企微回调「同意」
|
||||||
|
result = await writeback_approval_todo_status(
|
||||||
|
sp_no="SP001",
|
||||||
|
sp_status=2,
|
||||||
|
status_change_event=2,
|
||||||
|
redis=fake_redis,
|
||||||
|
template_id="TPL_X",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert 1:返回值
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["status"] == "resolved"
|
||||||
|
assert result["event_type"] == "approved"
|
||||||
|
assert result["agents"] == ["agent001"]
|
||||||
|
|
||||||
|
# Assert 2:缓存条目就地改写
|
||||||
|
item = fake_redis.load_json(key)["items"][0]
|
||||||
|
assert item["status"] == "resolved"
|
||||||
|
assert item["description"]["sp_status"] == 2
|
||||||
|
|
||||||
|
# Assert 3:7 天状态快照
|
||||||
|
snap_key = TODO_APPROVAL_STATUS_KEY.format(sp_no="SP001")
|
||||||
|
snapshot = fake_redis.load_json(snap_key)
|
||||||
|
assert fake_redis.ttl_of(snap_key) == TODO_APPROVAL_STATUS_TTL
|
||||||
|
assert snapshot["sp_no"] == "SP001"
|
||||||
|
assert snapshot["template_id"] == "TPL_X"
|
||||||
|
assert snapshot["sp_status"] == 2
|
||||||
|
assert snapshot["status_change_event"] == 2
|
||||||
|
assert snapshot["event_type"] == "approved"
|
||||||
|
assert snapshot["todo_status"] == "resolved"
|
||||||
|
assert snapshot["affected_agents"] == ["agent001"]
|
||||||
|
assert snapshot["updated_at"]
|
||||||
|
|
||||||
|
# Assert 4:WS 推送
|
||||||
|
ws_send.assert_awaited_once()
|
||||||
|
agent_arg, message = ws_send.await_args.args
|
||||||
|
assert agent_arg == "agent001"
|
||||||
|
assert message["type"] == "todo_status_changed"
|
||||||
|
assert message["data"]["item_id"] == "approval:SP001"
|
||||||
|
assert message["data"]["todo_type"] == "approval"
|
||||||
|
assert message["data"]["sp_no"] == "SP001"
|
||||||
|
assert message["data"]["sp_status"] == 2
|
||||||
|
assert message["data"]["status"] == "resolved"
|
||||||
|
assert message["data"]["event_type"] == "approved"
|
||||||
|
|
||||||
|
async def test_submitted_event_keeps_pending(self, fake_redis: FakeRedis, ws_send: AsyncMock):
|
||||||
|
key = cache_key("agent001")
|
||||||
|
fake_redis.seed_json(key, {"items": [make_approval_item("SP001")], "total": 1})
|
||||||
|
|
||||||
|
result = await writeback_approval_todo_status(
|
||||||
|
sp_no="SP001", sp_status=1, status_change_event=1, redis=fake_redis
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["status"] == "pending"
|
||||||
|
assert result["event_type"] == "submitted"
|
||||||
|
item = fake_redis.load_json(key)["items"][0]
|
||||||
|
assert item["status"] == "pending"
|
||||||
|
assert item["description"]["sp_status"] == 1
|
||||||
|
|
||||||
|
async def test_rejected_event_resolves(self, fake_redis: FakeRedis, ws_send: AsyncMock):
|
||||||
|
key = cache_key("agent001")
|
||||||
|
fake_redis.seed_json(key, {"items": [make_approval_item("SP001")], "total": 1})
|
||||||
|
|
||||||
|
result = await writeback_approval_todo_status(
|
||||||
|
sp_no="SP001", sp_status=3, status_change_event=3, redis=fake_redis
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["status"] == "resolved"
|
||||||
|
assert result["event_type"] == "rejected"
|
||||||
|
item = fake_redis.load_json(key)["items"][0]
|
||||||
|
assert item["status"] == "resolved"
|
||||||
|
assert item["description"]["sp_status"] == 3
|
||||||
|
|
||||||
|
async def test_other_sp_no_not_touched(self, fake_redis: FakeRedis, ws_send: AsyncMock):
|
||||||
|
"""不匹配的审批单(SP999)与工单条目必须原样保留。"""
|
||||||
|
key = cache_key("agent001")
|
||||||
|
fake_redis.seed_json(
|
||||||
|
key,
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
make_approval_item("SP001"),
|
||||||
|
make_approval_item("SP999"),
|
||||||
|
make_ticket_item("T1001"),
|
||||||
|
],
|
||||||
|
"total": 3,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
await writeback_approval_todo_status(
|
||||||
|
sp_no="SP001", sp_status=2, status_change_event=2, redis=fake_redis
|
||||||
|
)
|
||||||
|
|
||||||
|
items = {i["id"]: i for i in fake_redis.load_json(key)["items"]}
|
||||||
|
assert items["approval:SP001"]["status"] == "resolved"
|
||||||
|
assert items["approval:SP999"]["status"] == "pending"
|
||||||
|
assert items["approval:SP999"]["description"]["sp_status"] == 1
|
||||||
|
assert items["ticket:T1001"]["status"] == "pending"
|
||||||
|
|
||||||
|
async def test_empty_sp_no_is_skipped_safely(self, fake_redis: FakeRedis, ws_send: AsyncMock):
|
||||||
|
key = cache_key("agent001")
|
||||||
|
fake_redis.seed_json(key, {"items": [make_approval_item("SP001")], "total": 1})
|
||||||
|
|
||||||
|
result = await writeback_approval_todo_status(
|
||||||
|
sp_no="", sp_status=2, status_change_event=2, redis=fake_redis
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "sp_no" in result["message"]
|
||||||
|
# 未触碰缓存、未写快照、未推送
|
||||||
|
assert fake_redis.load_json(key)["items"][0]["status"] == "pending"
|
||||||
|
assert TODO_APPROVAL_STATUS_KEY.format(sp_no="") not in fake_redis._data
|
||||||
|
ws_send.assert_not_awaited()
|
||||||
|
|
||||||
|
async def test_multiple_agents_and_types_all_patched(
|
||||||
|
self, fake_redis: FakeRedis, ws_send: AsyncMock
|
||||||
|
):
|
||||||
|
"""同一审批单出现在多个坐席、多种 type 的缓存里时全部改写并去重推送。"""
|
||||||
|
fake_redis.seed_json(
|
||||||
|
cache_key("agent001", "all"), {"items": [make_approval_item("SP001")]}
|
||||||
|
)
|
||||||
|
fake_redis.seed_json(
|
||||||
|
cache_key("agent001", "approval"), {"items": [make_approval_item("SP001")]}
|
||||||
|
)
|
||||||
|
fake_redis.seed_json(
|
||||||
|
cache_key("agent002", "all"), {"items": [make_approval_item("SP001")]}
|
||||||
|
)
|
||||||
|
# 未命中该单的坐席不应出现在 agents 中
|
||||||
|
fake_redis.seed_json(
|
||||||
|
cache_key("agent003", "all"), {"items": [make_approval_item("SP777")]}
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await writeback_approval_todo_status(
|
||||||
|
sp_no="SP001", sp_status=2, status_change_event=2, redis=fake_redis
|
||||||
|
)
|
||||||
|
|
||||||
|
assert sorted(result["agents"]) == ["agent001", "agent002"]
|
||||||
|
# agent001 出现在两个 Key 中,但只推送一次
|
||||||
|
assert ws_send.await_count == 2
|
||||||
|
for k in (cache_key("agent001", "all"), cache_key("agent001", "approval"), cache_key("agent002")):
|
||||||
|
assert fake_redis.load_json(k)["items"][0]["status"] == "resolved"
|
||||||
|
assert fake_redis.load_json(cache_key("agent003"))["items"][0]["status"] == "pending"
|
||||||
|
|
||||||
|
async def test_remaining_ttl_is_preserved(self, fake_redis: FakeRedis, ws_send: AsyncMock):
|
||||||
|
key = cache_key("agent001")
|
||||||
|
fake_redis.seed_json(key, {"items": [make_approval_item("SP001")]}, ttl=30)
|
||||||
|
|
||||||
|
await writeback_approval_todo_status(
|
||||||
|
sp_no="SP001", sp_status=2, status_change_event=2, redis=fake_redis
|
||||||
|
)
|
||||||
|
|
||||||
|
assert fake_redis.ttl_of(key) == 30
|
||||||
|
|
||||||
|
async def test_missing_ttl_falls_back_to_default(self, fake_redis: FakeRedis, ws_send: AsyncMock):
|
||||||
|
"""TTL 为 -1(永不过期)时必须落到兜底 45s,避免把缓存写成永久。"""
|
||||||
|
key = cache_key("agent001")
|
||||||
|
fake_redis.seed_raw(
|
||||||
|
key, json.dumps({"items": [make_approval_item("SP001")]}, ensure_ascii=False)
|
||||||
|
)
|
||||||
|
fake_redis._ttl[key] = -1
|
||||||
|
|
||||||
|
await writeback_approval_todo_status(
|
||||||
|
sp_no="SP001", sp_status=2, status_change_event=2, redis=fake_redis
|
||||||
|
)
|
||||||
|
|
||||||
|
assert fake_redis.ttl_of(key) == TODO_CACHE_FALLBACK_TTL
|
||||||
|
|
||||||
|
async def test_decoded_string_redis_client_supported(self, ws_send: AsyncMock):
|
||||||
|
"""decode_responses=True 的客户端(返回 str)同样能正确回写。"""
|
||||||
|
redis = FakeRedis(decode_responses=True)
|
||||||
|
key = cache_key("agent001")
|
||||||
|
redis.seed_json(key, {"items": [make_approval_item("SP001")]})
|
||||||
|
|
||||||
|
result = await writeback_approval_todo_status(
|
||||||
|
sp_no="SP001", sp_status=2, status_change_event=2, redis=redis
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["agents"] == ["agent001"]
|
||||||
|
assert redis.load_json(key)["items"][0]["status"] == "resolved"
|
||||||
|
|
||||||
|
async def test_malformed_cache_entry_does_not_block_others(
|
||||||
|
self, fake_redis: FakeRedis, ws_send: AsyncMock
|
||||||
|
):
|
||||||
|
"""脏缓存(非 JSON / items 非 list)被跳过,正常 Key 仍被改写。"""
|
||||||
|
fake_redis.seed_raw(cache_key("agentBad"), "not-a-json{{{")
|
||||||
|
fake_redis.seed_json(cache_key("agentNoItems"), {"total": 0})
|
||||||
|
good_key = cache_key("agentGood")
|
||||||
|
fake_redis.seed_json(good_key, {"items": [make_approval_item("SP001")]})
|
||||||
|
|
||||||
|
result = await writeback_approval_todo_status(
|
||||||
|
sp_no="SP001", sp_status=2, status_change_event=2, redis=fake_redis
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["agents"] == ["agentGood"]
|
||||||
|
assert fake_redis.load_json(good_key)["items"][0]["status"] == "resolved"
|
||||||
|
|
||||||
|
async def test_redis_scan_failure_is_swallowed(self, fake_redis: FakeRedis, ws_send: AsyncMock):
|
||||||
|
"""Redis 扫描失败不得抛出(回调 ACK 不能被回写拖垮)。"""
|
||||||
|
fake_redis.fail_on_keys = RuntimeError("redis down")
|
||||||
|
|
||||||
|
result = await writeback_approval_todo_status(
|
||||||
|
sp_no="SP001", sp_status=2, status_change_event=2, redis=fake_redis
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["agents"] == []
|
||||||
|
ws_send.assert_not_awaited()
|
||||||
|
|
||||||
|
async def test_no_agents_means_no_ws_push(self, fake_redis: FakeRedis, ws_send: AsyncMock):
|
||||||
|
"""无人命中时不推送,但快照照写(供缓存过期后追溯)。"""
|
||||||
|
result = await writeback_approval_todo_status(
|
||||||
|
sp_no="SP001", sp_status=2, status_change_event=2, redis=fake_redis
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["agents"] == []
|
||||||
|
ws_send.assert_not_awaited()
|
||||||
|
assert TODO_APPROVAL_STATUS_KEY.format(sp_no="SP001") in fake_redis._data
|
||||||
|
|
||||||
|
async def test_ws_push_failure_does_not_break_writeback(self, fake_redis: FakeRedis):
|
||||||
|
"""单个坐席推送失败(离线)不影响整体回写成功。"""
|
||||||
|
from app.services import ws_manager as ws_manager_mod
|
||||||
|
|
||||||
|
fake_redis.seed_json(cache_key("agent001"), {"items": [make_approval_item("SP001")]})
|
||||||
|
failing = AsyncMock(side_effect=RuntimeError("ws closed"))
|
||||||
|
|
||||||
|
with patch.object(ws_manager_mod.manager, "send_to_agent", failing):
|
||||||
|
result = await writeback_approval_todo_status(
|
||||||
|
sp_no="SP001", sp_status=2, status_change_event=2, redis=fake_redis
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert fake_redis.load_json(cache_key("agent001"))["items"][0]["status"] == "resolved"
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 5. 回调端点 POST /approval/callback
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def approval_client(fake_redis: FakeRedis):
|
||||||
|
"""只挂载 approval 路由的最小 app,Redis 依赖替换为 FakeRedis。"""
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(approval_mod.router)
|
||||||
|
app.dependency_overrides[approval_mod.get_redis] = lambda: fake_redis
|
||||||
|
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||||
|
yield ac
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
class TestApprovalCallbackEndpoint:
|
||||||
|
"""POST /approval/callback(企微 sys_approval_change)。"""
|
||||||
|
|
||||||
|
async def test_callback_triggers_writeback_and_updates_cache(
|
||||||
|
self, approval_client: AsyncClient, fake_redis: FakeRedis, ws_send: AsyncMock
|
||||||
|
):
|
||||||
|
key = cache_key("agent001")
|
||||||
|
fake_redis.seed_json(key, {"items": [make_approval_item("SP001")], "total": 1})
|
||||||
|
|
||||||
|
resp = await approval_client.post(
|
||||||
|
"/approval/callback",
|
||||||
|
params={
|
||||||
|
"sp_no": "SP001",
|
||||||
|
"sp_name": "IT资产升级申请",
|
||||||
|
"template_id": "TPL_X",
|
||||||
|
"apply_time": 1754500000,
|
||||||
|
"applyer_userid": "zhangsan",
|
||||||
|
"sp_status": 2,
|
||||||
|
"status_change_event": 2,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# 回调必须立即 ACK
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json() == {"errcode": 0, "errmsg": "ok"}
|
||||||
|
|
||||||
|
# 回写是 create_task 异步执行,让出事件循环等其完成
|
||||||
|
for _ in range(10):
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
|
item = fake_redis.load_json(key)["items"][0]
|
||||||
|
assert item["status"] == "resolved"
|
||||||
|
assert item["description"]["sp_status"] == 2
|
||||||
|
assert TODO_APPROVAL_STATUS_KEY.format(sp_no="SP001") in fake_redis._data
|
||||||
|
ws_send.assert_awaited_once()
|
||||||
|
|
||||||
|
async def test_callback_passes_all_fields_to_writeback(
|
||||||
|
self, approval_client: AsyncClient, fake_redis: FakeRedis
|
||||||
|
):
|
||||||
|
"""回调解析出的 sp_no / sp_status / event / template_id 需原样透传。"""
|
||||||
|
spy = AsyncMock(return_value={"success": True})
|
||||||
|
with patch.object(approval_mod, "writeback_approval_todo_status", spy):
|
||||||
|
resp = await approval_client.post(
|
||||||
|
"/approval/callback",
|
||||||
|
params={
|
||||||
|
"sp_no": "SP123",
|
||||||
|
"sp_name": "外修申请",
|
||||||
|
"template_id": "TPL_REPAIR",
|
||||||
|
"apply_time": 1754500001,
|
||||||
|
"applyer_userid": "lisi",
|
||||||
|
"sp_status": 3,
|
||||||
|
"status_change_event": 3,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
for _ in range(10):
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
spy.assert_awaited_once()
|
||||||
|
kwargs = spy.await_args.kwargs
|
||||||
|
assert kwargs["sp_no"] == "SP123"
|
||||||
|
assert kwargs["sp_status"] == 3
|
||||||
|
assert kwargs["status_change_event"] == 3
|
||||||
|
assert kwargs["template_id"] == "TPL_REPAIR"
|
||||||
|
assert kwargs["redis"] is fake_redis
|
||||||
|
|
||||||
|
async def test_callback_route_is_registered_in_production_app(self):
|
||||||
|
"""路由契约:企微回调 /approval/callback 在生产 app 中已注册且可响应。
|
||||||
|
|
||||||
|
注:本仓库 api_router 以「无 /api 前缀」挂载(main.py:918 注释说明
|
||||||
|
nginx 已通过 location /api/ 剥离前缀,请求到达后端时 /api 已被 strip),
|
||||||
|
故后端内部路径为 /approval/callback;外部(nginx 视角)/api/approval/callback
|
||||||
|
由网关映射,不属后端单测范围。
|
||||||
|
"""
|
||||||
|
from app.main import app as production_app
|
||||||
|
from app.api import approval as approval_mod
|
||||||
|
|
||||||
|
redis = FakeRedis()
|
||||||
|
key = cache_key("agent001")
|
||||||
|
redis.seed_json(key, {"items": [make_approval_item("SP001")], "total": 1})
|
||||||
|
|
||||||
|
production_app.dependency_overrides[approval_mod.get_redis] = lambda: redis
|
||||||
|
transport = ASGITransport(app=production_app)
|
||||||
|
try:
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||||
|
resp = await ac.post(
|
||||||
|
"/approval/callback",
|
||||||
|
params={
|
||||||
|
"sp_no": "SP001",
|
||||||
|
"sp_name": "IT资产升级申请",
|
||||||
|
"template_id": "TPL_X", # 非资产升级模板 → 不触发 _do_asset_urge
|
||||||
|
"apply_time": 1754500000,
|
||||||
|
"applyer_userid": "zhangsan",
|
||||||
|
"sp_status": 2,
|
||||||
|
"status_change_event": 2,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# 让端点内 create_task 异步回写跑完
|
||||||
|
for _ in range(10):
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
|
# 路由已注册(非 404)且按契约立即 ACK
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json() == {"errcode": 0, "errmsg": "ok"}
|
||||||
|
# 回写确实经由生产 app 的路由 + 注入的 Redis 生效
|
||||||
|
item = redis.load_json(key)["items"][0]
|
||||||
|
assert item["status"] == "resolved"
|
||||||
|
assert item["description"]["sp_status"] == 2
|
||||||
|
finally:
|
||||||
|
production_app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 6. webhook 路径:_writeback_agent_todo
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestWebhookWriteback:
|
||||||
|
"""approval_webhook._writeback_agent_todo(字符串状态 → 企微数值语义)。"""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"status, expected_sp_status, expected_event",
|
||||||
|
[
|
||||||
|
("pending", 1, 1),
|
||||||
|
("approved", 2, 2),
|
||||||
|
("completed", 2, 2),
|
||||||
|
("rejected", 3, 3),
|
||||||
|
("cancelled", 4, 6),
|
||||||
|
(" APPROVED ", 2, 2), # 大小写 / 空白容错
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_status_mapping_calls_writeback(
|
||||||
|
self, status: str, expected_sp_status: int, expected_event: int
|
||||||
|
):
|
||||||
|
redis = FakeRedis()
|
||||||
|
spy = AsyncMock(return_value={"success": True})
|
||||||
|
|
||||||
|
fake_settings = _FakeSettings(redis)
|
||||||
|
with patch.object(webhook_mod, "settings", fake_settings), \
|
||||||
|
patch.object(approval_mod, "writeback_approval_todo_status", spy):
|
||||||
|
await webhook_mod._writeback_agent_todo("SP001", status)
|
||||||
|
|
||||||
|
spy.assert_awaited_once()
|
||||||
|
kwargs = spy.await_args.kwargs
|
||||||
|
assert kwargs["sp_no"] == "SP001"
|
||||||
|
assert kwargs["sp_status"] == expected_sp_status
|
||||||
|
assert kwargs["status_change_event"] == expected_event
|
||||||
|
# Redis 客户端必须被释放
|
||||||
|
assert redis.closed is True
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("status", ["unknown_status", "", None])
|
||||||
|
async def test_unmapped_status_skips_writeback(self, status):
|
||||||
|
redis = FakeRedis()
|
||||||
|
spy = AsyncMock()
|
||||||
|
|
||||||
|
fake_settings = _FakeSettings(redis)
|
||||||
|
with patch.object(webhook_mod, "settings", fake_settings), \
|
||||||
|
patch.object(approval_mod, "writeback_approval_todo_status", spy):
|
||||||
|
await webhook_mod._writeback_agent_todo("SP001", status)
|
||||||
|
|
||||||
|
spy.assert_not_awaited()
|
||||||
|
|
||||||
|
async def test_end_to_end_webhook_updates_agent_cache(self, ws_send: AsyncMock):
|
||||||
|
"""webhook 全链路(不 mock 回写):缓存条目应被改写为 resolved。"""
|
||||||
|
redis = FakeRedis()
|
||||||
|
key = cache_key("agent001")
|
||||||
|
redis.seed_json(key, {"items": [make_approval_item("SP001")], "total": 1})
|
||||||
|
|
||||||
|
fake_settings = _FakeSettings(redis)
|
||||||
|
with patch.object(webhook_mod, "settings", fake_settings):
|
||||||
|
await webhook_mod._writeback_agent_todo("SP001", "approved")
|
||||||
|
|
||||||
|
item = redis.load_json(key)["items"][0]
|
||||||
|
assert item["status"] == "resolved"
|
||||||
|
assert item["description"]["sp_status"] == 2
|
||||||
|
ws_send.assert_awaited_once()
|
||||||
|
|
||||||
|
async def test_writeback_exception_is_swallowed(self):
|
||||||
|
"""回写抛异常不得冒泡(webhook 必须照常 ACK),且释放 Redis。"""
|
||||||
|
redis = FakeRedis()
|
||||||
|
boom = AsyncMock(side_effect=RuntimeError("boom"))
|
||||||
|
|
||||||
|
fake_settings = _FakeSettings(redis)
|
||||||
|
with patch.object(webhook_mod, "settings", fake_settings), \
|
||||||
|
patch.object(approval_mod, "writeback_approval_todo_status", boom):
|
||||||
|
await webhook_mod._writeback_agent_todo("SP001", "approved")
|
||||||
|
|
||||||
|
assert redis.closed is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestApprovalWebhookEndpointWiring:
|
||||||
|
"""POST /wecom/approval_webhook 端点是否接线到坐席回写。"""
|
||||||
|
|
||||||
|
async def test_endpoint_schedules_agent_writeback(self):
|
||||||
|
payload = webhook_mod.ApprovalWebhookPayload(
|
||||||
|
approval_id="SP001",
|
||||||
|
employee_id="zhangsan",
|
||||||
|
status="approved",
|
||||||
|
progress=100,
|
||||||
|
)
|
||||||
|
svc = MagicMock()
|
||||||
|
svc.update_progress.return_value = None # 记录不存在 → skipped
|
||||||
|
spy = AsyncMock()
|
||||||
|
|
||||||
|
with patch.object(webhook_mod, "WECOM_WEBHOOK_TOKEN", "unit_test_token"), \
|
||||||
|
patch.object(webhook_mod, "_writeback_agent_todo", spy):
|
||||||
|
ack = await webhook_mod.approval_webhook(
|
||||||
|
payload=payload, x_wecom_token="unit_test_token", svc=svc
|
||||||
|
)
|
||||||
|
for _ in range(10):
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
assert ack.success is True
|
||||||
|
assert ack.action == "skipped"
|
||||||
|
spy.assert_awaited_once_with("SP001", "approved")
|
||||||
|
|
||||||
|
async def test_invalid_token_rejected_before_writeback(self):
|
||||||
|
payload = webhook_mod.ApprovalWebhookPayload(
|
||||||
|
approval_id="SP001", employee_id="zhangsan", status="approved"
|
||||||
|
)
|
||||||
|
spy = AsyncMock()
|
||||||
|
|
||||||
|
with patch.object(webhook_mod, "WECOM_WEBHOOK_TOKEN", "unit_test_token"), \
|
||||||
|
patch.object(webhook_mod, "_writeback_agent_todo", spy):
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
await webhook_mod.approval_webhook(
|
||||||
|
payload=payload, x_wecom_token="wrong", svc=MagicMock()
|
||||||
|
)
|
||||||
|
|
||||||
|
assert exc.value.status_code == 401
|
||||||
|
spy.assert_not_awaited()
|
||||||
@@ -77,7 +77,7 @@ interface Props {
|
|||||||
todoItem: TodoItemData
|
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}`)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user