当前"云小调"系统已实现首页案件数据加载和右下角智能外呼气泡组件,但外呼流程需要手动触发,缺少自动化能力。根据产品需求,调解流程应在案件数据加载完成后自动启动 AI 智能外呼,提升调解效率。
核心挑战:
1. 何时触发外呼?如何避免重复触发?
2. 如何处理多人外呼场景(申请人、被申请人)?
3. 如何管理 jobId 状态和生命周期?
4. 如何设计轮询策略平衡实时性与服务器压力?
5. 如何处理页面刷新、组件卸载等边界场景?
技术栈约束:
- React + Ant Design 4.24.12
- Context API 管理全局状态
- localStorage 持久化存储
- 前端 Mock 数据(当前阶段)
loadCaseData 后触发方案对比:
| 方案 | 优点 | 缺点 | 决策 |
|---|---|---|---|
A. 在 CaseDataContext 中触发 |
集中管理数据流,逻辑清晰 | 增加 Context 耦合度 | ✅ 采用 |
B. 在 OutboundCallWidget 组件内触发 |
解耦数据和外呼逻辑 | 依赖组件挂载时机,可能触发延迟 | ❌ 不采用 |
| C. 在 App.js 路由层触发 | 全局控制 | 难以获取案件数据,需要额外传递 | ❌ 不采用 |
选择 A 的理由:
- loadCaseData 是唯一的数据入口,在此处触发可确保数据就绪
- 避免组件挂载时机不确定导致的触发延迟
- 便于实现幂等性检查(基于 localStorage 判断是否已有活跃任务)
数据结构设计:javascript // localStorage key: 'outbound_call_jobs' [ { jobId: "1770602736933-4794-83bb-e2cec968ebfc", callStatus: "Scheduling", // 或 Executing, Paused, Drafted, Succeeded, Failed, Cancelled personId: "2303191513081131", mediationId: 20, startTime: 1738228800000, // 触发时间戳 retryCount: 0, // 重试次数 pollStartTime: 1738228800000 // 轮询开始时间(用于2小时超时检测) } ]
状态分类:
- 活跃状态(需轮询):Scheduling, Executing, Paused, Drafted
- 终态(需清除):Succeeded, Failed, Cancelled
方案对比:
| 方案 | 优点 | 缺点 | 决策 |
|---|---|---|---|
| A. localStorage 持久化 | 页面刷新后状态不丢失,支持断点续传 | 需要手动清理终态数据 | ✅ 采用 |
| B. Context 内存存储 | 简单,自动清理 | 页面刷新后丢失,无法继续监听 | ❌ 不采用 |
| C. IndexedDB 存储 | 支持复杂查询 | 过度设计,增加复杂度 | ❌ 不采用 |
选择 A 的理由:
- 用户刷新页面后可继续监听外呼状态,提升体验
- 终态清理逻辑简单,可在轮询中实现
参数配置:javascript const POLL_INTERVAL = 10000; // 10秒 const MAX_POLL_DURATION = 7200000; // 2小时(毫秒) const MAX_RETRY_COUNT = 10; // 最大重试次数
方案对比:
| 方案 | 轮询间隔 | 服务器压力 | 实时性 | 决策 |
|---|---|---|---|---|
| A. 5秒间隔 | 5s | 较高 | 高 | ❌ 压力过大 |
| B. 10秒间隔 | 10s | 中等 | 较高 | ✅ 采用 |
| C. 30秒间隔 | 30s | 低 | 低 | ❌ 延迟过高 |
选择 B 的理由:
- 10秒间隔在实时性和服务器压力之间取得平衡
- 对于电话外呼场景,10秒延迟可接受(通话时长通常数分钟)
超时设计:
- 2小时后自动停止轮询,避免无限轮询
- 超时后从 localStorage 移除 jobId,防止脏数据累积
重试机制:
- 单次查询失败累加 retryCount,不立即清除 jobId
- 连续失败 10 次后展示错误提示并清除该 jobId
- 避免因偶发网络抖动导致任务丢失
UI 设计: ┌─────────────────────────┐ │ 智能体工作中 │ │ 正在与申请人(张三)通话... │ │ 已持续: 02:35 │ └─────────────────────────┘ ┌─────────────────────────┐ │ 智能体工作中 │ │ 正在与被申请人(李四)通话...│ │ 已持续: 01:20 │ └─────────────────────────┘
方案对比:
| 方案 | 优点 | 缺点 | 决策 |
|---|---|---|---|
| A. 纵向堆叠多个气泡 | 清晰展示每个任务,易扩展 | 占用屏幕空间 | ✅ 采用 |
| B. 单个气泡轮播展示 | 节省空间 | 用户难以同时了解所有任务 | ❌ 不采用 |
| C. 折叠列表 | 灵活 | 增加交互复杂度 | ❌ 不采用 |
选择 A 的理由:
- 外呼任务数量有限(通常 1-2 个),堆叠不会占用过多空间
- 直观展示每个任务状态,符合用户心智模型
触发条件:javascript // 伪代码 const activeJobs = getActiveJobsFromStorage(); // 从 localStorage 获取活跃任务 if (activeJobs.length > 0) { console.log('已有活跃外呼任务,跳过发起新外呼'); return; } // 否则,发起新外呼 await OutboundBotAPIService.makeCallV2(...);
边界场景处理:
1. 首次进入页面:无活跃 jobId,发起新外呼
2. 刷新页面:检测到活跃 jobId,继续监听,不发起新外呼
3. 通话结束后再次刷新:jobId 已清除,可发起新外呼
4. 多标签页同时打开:共享 localStorage,避免重复触发
风险描述:多个用户同时轮询可能增加服务器负载。
缓解措施:
- 采用 10 秒轮询间隔(非 5 秒或更短)
- 设置 2 小时最大轮询时长,避免无限轮询
- 终态任务立即停止轮询,减少无效请求
Trade-off:牺牲部分实时性(10秒延迟)换取服务器稳定性。
风险描述:jobId 数据累积可能超出 localStorage 容量(5MB)。
缓解措施:
- 终态任务立即清除
- 定期清理超过 24 小时的历史数据(后续扩展)
- 单个 jobId 对象约 200 字节,可存储数千条记录
Trade-off:当前风险较低,暂不引入复杂的存储淘汰策略。
风险描述:用户快速刷新页面可能触发多次 makeCallV2 调用。
缓解措施:
- 在 CaseDataContext 中使用 hasLoaded 标志防止重复加载
- makeCallV2 调用前检查活跃 jobId,确保幂等性
- 后端接口应支持幂等性设计(基于 caseId + mediationId 去重)
Trade-off:前后端协同保障,前端尽最大努力避免重复调用。
风险描述:定时器未清理可能导致内存泄漏或组件卸载后仍执行回调。
缓解措施:
- 在 useEffect 返回清理函数:return () => clearInterval(intervalId)
- 使用 useRef 保存 isMounted 状态,回调中检查组件是否已卸载
- 考虑使用 AbortController 取消未完成的 API 请求
Trade-off:增加代码复杂度,但避免生产环境内存泄漏和控制台报错。
CaseDataContext 中添加外呼触发逻辑OutboundCallWidget 轮询逻辑如发现严重问题,可通过以下步骤回滚:
1. 注释 CaseDataContext 中外呼触发代码
2. 恢复 OutboundCallWidget 原有轮询逻辑
3. 清除 localStorage 中 outbound_call_jobs 数据
window.addEventListener('storage', ...) 监听 localStorage 变化startTime 字段格式是否统一?