Files
wecom_it_smart_desk/docs/02-技术文档/技术架构/技术方案-REQ-AI-003-语音转文字-v1.0.md
T
Simon facc04aa65 chore: docs 结构整改 + compose 双目录对齐(合并重建提交)
本提交为 .git 对象库损坏后的重建提交,内容等价于原先三个本地提交
(5e2fd4c2 / 57a53c98 / 5d7e1873)的累积结果,未做任何额外改动。

一、docs 结构整改(整改 #14)
根因:重构时新结构为 untracked 文件,执行 git stash(未带 -u)未纳入,
随后 git reset 拉回 HEAD 旧 tracked 树,导致旧树复活、新旧两棵目录
树并存于 docs/,共 791 文件、双分类体系冲突。

修复动作:
- b2 同名异主题文件改名迁移保全 9 个
- C 类 39 个孤立文件按主题正确归类
- A/B1 类 222 个重复文件删除(新结构已有内容副本)
- 9 个旧独有空目录删除
- 270 处内部引用按 verified 映射改写
- 整改记录 #14 登记于 04-运维文档/部署运维

结果:docs 791 → 569 文件,顶层仅规范 8 类 + 治理文件,单树恢复。
残留:约 20 处指向从未存在文件的陈旧死链,归入独立文档卫生任务。

二、compose 双目录对齐(消除踩坑 A)
- docker-compose.yml:nginx 前端挂载全部由根目录 frontend-*/dist
  改为 src/frontend-*/dist(h5 / agent / admin / terminal)
- docker-compose.dev.yml:dev 服务 build context 与卷同步改 src/
- 效果:本地 docker compose up 不再把根目录 stale dist 挂回,
  与线上一致,分叉隐患消除(已 docker compose config 校验通过)

防复发铁律:
- 重构须提交;仓库修复须 git stash -u 或先 commit
- 新结构须 git add 并提交,避免再次 untracked 复活
- H5 改动只动 src/frontend-h5/,禁改根目录遗留 frontend-*/
2026-08-07 22:31:32 +08:00

1053 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 语音识别转文字 — 技术方案设计
> **项目**: IT 智能服务台(税友集团企微内嵌应用)
> **功能**: 语音输入转文字,填入聊天输入框(非发送语音消息)
> **版本**: v1.1
> **日期**: 2026-07-15v1.0 初版)/ 2026-08-03v1.1 增量更新)
> **架构师**: 高见远(Bob
> **维护人**: Duckula
>
> **变更记录**
> | 日期 | 版本 | 变更内容 | 变更人 | 变更原因 |
> |------|------|----------|--------|----------|
> | 2026-07-15 | v1.0 | 初版设计:H5 企微 JS-SDK + 坐席端 Web Speech API | 高见远 | 双端差异化方案 |
> | 2026-08-03 | v1.1 | **§10 增量更新**:①H5 PC/Mac 端企微改用前端录音 + 后端 `POST /api/voice/asr`(百度 ASR)兜底(v1.0 漏掉此分支);②加 `Depends(get_current_user)`;③编号冲突说明(与同号 PRD-REQ-AI-003-多模态视觉理解 区别) | Duckula | voice_asr.py P0 安全巡检修复 + 文档-代码不一致修复 |
---
## 1. 实现方案概述
### 1.1 方案C — H5端(企微 JS-SDK
H5 端运行在企微客户端 WebView 内,利用企微 JS-SDK 提供的录音和语音识别能力:
| 步骤 | API | 说明 |
|------|-----|------|
| 1. 鉴权 | `wx.config()` | 用后端签名初始化 JS-SDK |
| 2. 开始录音 | `wx.startRecord()` | 用户按下语音按钮时调用 |
| 3. 停止录音 | `wx.stopRecord()` | 用户松开按钮时调用,返回 `localId` |
| 4. 语音转文字 | `wx.translateVoice()` | 将录音识别为文字,返回 `translateResult` |
| 5. 填入输入框 | `inputText.value += result` | 识别文字追加到 van-field |
**核心优势**:企微原生能力,识别精度高,支持中英文混合,无需额外服务器资源。
**关键发现**:后端 **已存在** JS-SDK 签名接口 `GET /api/wecom/jsapi-config?url=xxx`(位于 `backend/app/api/wecom_jsapi.py`),且 `WecomService` 已实现 `get_jsapi_ticket()``generate_jsapi_signature()`。**后端无需任何改动。**
### 1.2 方案A — 坐席端(Web Speech API
坐席端运行在桌面 Chrome/Edge 浏览器,利用浏览器原生 Web Speech API
| 步骤 | API | 说明 |
|------|-----|------|
| 1. 创建识别器 | `new SpeechRecognition()` | 创建识别实例 |
| 2. 配置参数 | `continuous=true, interimResults=true` | 连续识别 + 实时中间结果 |
| 3. 启动识别 | `recognition.start()` | 用户点击语音按钮 |
| 4. 实时回调 | `onresult` 事件 | 边说边出字,实时更新 textarea |
| 5. 停止识别 | `recognition.stop()` | 用户再次点击或自动停止 |
| 6. 填入输入框 | `inputText.value = finalTranscript` | 最终文字填入 textarea |
**核心优势**:纯前端实现,零后端依赖,实时转写(边说边出字),体验流畅。
### 1.3 框架选型与依赖
| 端 | 技术 | 新增依赖 | 说明 |
|----|------|----------|------|
| H5端 | 企微 JS-SDK (`jweixin-1.2.0.js`) | 无(CDN 引入) | 已在 EmergencyDispatcher.vue 中使用 |
| 坐席端 | Web Speech API(浏览器原生) | 无 | Chrome/Edge 内置支持 |
**无需新增任何 npm 依赖。** 两个方案都使用平台原生能力,通过 CDN script 标签(H5)或浏览器内置 API(Agent)实现。
---
## 2. 文件列表及路径
### 2.1 H5端(frontend-h5
| # | 操作 | 文件路径 | 说明 |
|---|------|----------|------|
| 1 | [新建] | `src/types/wecom-jssdk.d.ts` | 企微 JS-SDK TypeScript 类型声明 |
| 2 | [新建] | `src/api/wecom.ts` | JS-SDK 签名 API 封装(调 `/api/wecom/jsapi-config` |
| 3 | [新建] | `src/composables/useWecomVoice.ts` | 语音识别 composable(录音+转文字+状态管理) |
| 4 | [修改] | `src/components/chat/InputBar.vue` | 工具栏添加语音按钮,集成 composable |
### 2.2 坐席端(frontend-agent
| # | 操作 | 文件路径 | 说明 |
|---|------|----------|------|
| 5 | [新建] | `src/types/speech-recognition.d.ts` | Web Speech API TypeScript 类型声明 |
| 6 | [新建] | `src/composables/useSpeechRecognition.ts` | 语音识别 composable(实时转写+状态管理) |
| 7 | [修改] | `src/components/chat/ReplyBox.vue` | 工具栏添加语音按钮,替换占位代码 |
### 2.3 后端(backend
| # | 操作 | 文件路径 | 说明 |
|---|------|----------|------|
| — | 无改动 | — | JS-SDK 签名接口已存在(`app/api/wecom_jsapi.py` |
### 2.4 文件关系图
```
frontend-h5/src/
├── types/
│ └── wecom-jssdk.d.ts [新建] 类型声明(Wx, WxConfig, WxError 等)
├── api/
│ └── wecom.ts [新建] getJsapiConfig(url) → {corp_id, signature, ...}
├── composables/
│ └── useWecomVoice.ts [新建] useWecomVoice() → { isRecording, start, stop, ... }
└── components/chat/
└── InputBar.vue [修改] +语音按钮 🎤, 集成 useWecomVoice
frontend-agent/src/
├── types/
│ └── speech-recognition.d.ts [新建] 类型声明(SpeechRecognition, SpeechRecognitionEvent 等)
├── composables/
│ └── useSpeechRecognition.ts [新建] useSpeechRecognition() → { isListening, start, stop, ... }
└── components/chat/
└── ReplyBox.vue [修改] +语音按钮 🎤, 替换 voice 占位代码
```
---
## 3. 接口设计
### 3.1 H5端 TypeScript 接口
#### 3.1.1 企微 JS-SDK 类型声明(`wecom-jssdk.d.ts`
```typescript
/** 企微 JS-SDK 全局对象 */
interface Wx {
config(config: WxConfigOptions): void
ready(callback: () => void): void
error(callback: (res: WxError) => void): void
// 录音相关
startRecord(): void
stopRecord(options: WxStopRecordOptions): void
translateVoice(options: WxTranslateVoiceOptions): void
// 工具
checkJsApi(options: WxCheckJsApiOptions): void
}
interface WxConfigOptions {
beta?: boolean
debug?: boolean
appId: string // corp_id
timestamp: number
nonceStr: string
signature: string
jsApiList: string[] // ['startRecord', 'stopRecord', 'translateVoice']
}
interface WxError {
errMsg: string
}
interface WxStopRecordOptions {
success: (res: { localId: string }) => void
fail: (res: WxError) => void
}
interface WxTranslateVoiceOptions {
localId: string
isShowProgressTips?: number // 0=不显示, 1=显示
success: (res: { translateResult: string }) => void
fail: (res: WxError) => void
}
interface WxCheckJsApiOptions {
jsApiList: string[]
success: (res: { checkResult: Record<string, boolean> }) => void
}
interface Window {
wx: Wx
}
```
#### 3.1.2 JS-SDK 签名 API 封装(`wecom.ts`
```typescript
/** JS-SDK 签名配置响应 */
interface JsapiConfig {
corp_id: string
agent_id: string
timestamp: number
nonce_str: string
signature: string
}
/**
* 获取企微 JS-SDK 签名配置
* @param url 当前页面 URL(不含 # 及其后)
* @returns 签名配置
*/
async function getJsapiConfig(url: string): Promise<JsapiConfig>
```
#### 3.1.3 语音识别 composable`useWecomVoice.ts`
```typescript
/** 语音识别状态 */
interface VoiceState {
isReady: boolean // JS-SDK 是否已初始化
isRecording: boolean // 是否正在录音
isTranslating: boolean // 是否正在转文字
error: string | null // 错误信息
}
/** useWecomVoice 返回值 */
interface UseWecomVoiceReturn {
state: VoiceState
/** 初始化 JS-SDK(幂等,多次调用安全) */
init(): Promise<void>
/** 开始录音 */
startRecording(): Promise<void>
/** 停止录音并转文字 */
stopAndTranslate(): Promise<string>
/** 检查是否支持语音识别 */
isSupported(): boolean
}
/**
* 企微语音识别 composable
* 封装 JS-SDK 录音+转文字的完整流程
*/
function useWecomVoice(): UseWecomVoiceReturn
```
### 3.2 坐席端 TypeScript 接口
#### 3.2.1 Web Speech API 类型声明(`speech-recognition.d.ts`
```typescript
interface SpeechRecognition extends EventTarget {
lang: string
continuous: boolean
interimResults: boolean
maxAlternatives: number
start(): void
stop(): void
abort(): void
onresult: ((event: SpeechRecognitionEvent) => void) | null
onerror: ((event: SpeechRecognitionErrorEvent) => void) | null
onend: (() => void) | null
onstart: (() => void) | null
}
interface SpeechRecognitionEvent extends Event {
resultIndex: number
results: SpeechRecognitionResultList
}
interface SpeechRecognitionResultList {
length: number
item(index: number): SpeechRecognitionResult
[index: number]: SpeechRecognitionResult
}
interface SpeechRecognitionResult {
length: number
isFinal: boolean
item(index: number): SpeechRecognitionAlternative
[index: number]: SpeechRecognitionAlternative
}
interface SpeechRecognitionAlternative {
transcript: string
confidence: number
}
interface SpeechRecognitionErrorEvent extends Event {
error: string
message: string
}
interface SpeechRecognitionStatic {
new (): SpeechRecognition
}
interface Window {
SpeechRecognition?: SpeechRecognitionStatic
webkitSpeechRecognition?: SpeechRecognitionStatic
}
```
#### 3.2.2 语音识别 composable`useSpeechRecognition.ts`
```typescript
/** 语音识别状态 */
interface SpeechState {
isListening: boolean // 是否正在监听
interimText: string // 实时中间结果(未确定)
finalText: string // 已确定的最终结果
error: string | null // 错误信息
isSupported: boolean // 浏览器是否支持
}
/** useSpeechRecognition 返回值 */
interface UseSpeechRecognitionReturn {
state: SpeechState
/** 开始语音识别 */
start(): void
/** 停止语音识别 */
stop(): void
/** 重置状态 */
reset(): void
}
/**
* Web Speech API 语音识别 composable
* 支持实时转写(边说边出字)
* @param lang 识别语言,默认 'zh-CN'
*/
function useSpeechRecognition(lang?: string): UseSpeechRecognitionReturn
```
### 3.3 后端 API 接口(已存在,无需修改)
```python
# backend/app/api/wecom_jsapi.py — 已存在
@router.get("/wecom/jsapi-config")
async def get_jsapi_config(url: str = Query(...)) -> dict:
"""返回 JS-SDK 签名配置
Response: { code: 0, data: { corp_id, agent_id, timestamp, nonce_str, signature } }
"""
```
---
## 4. 时序图
### 4.1 方案C — H5端企微 JS-SDK 录音转文字
```mermaid
sequenceDiagram
participant U as 用户
participant IB as InputBar.vue
participant UWV as useWecomVoice
participant API as /api/wecom/jsapi-config
participant WX as 企微服务器
participant WV as 企微WebView
Note over U,WV: 阶段1:初始化(页面加载时,幂等)
IB->>UWV: init()
UWV->>WV: 检查 window.wx 是否存在
alt JS-SDK 未加载
UWV->>WV: 动态加载 jweixin-1.2.0.js
end
UWV->>API: GET /api/wecom/jsapi-config?url=当前页面URL
API->>API: get_jsapi_ticket() + sha1 签名
API-->>UWV: { corp_id, agent_id, timestamp, nonce_str, signature }
UWV->>WV: wx.config({ appId, timestamp, ..., jsApiList: ['startRecord','stopRecord','translateVoice'] })
WV-->>UWV: wx.ready() 回调
UWV-->>IB: state.isReady = true
Note over U,WV: 阶段2:录音
U->>IB: 按下语音按钮 🎤
IB->>UWV: startRecording()
UWV->>WV: wx.startRecord()
UWV-->>IB: state.isRecording = true
IB->>IB: UI 切换为录音状态(按钮变红+提示"松开发送"
Note over U,WV: 阶段3:停止录音+转文字
U->>IB: 松开语音按钮
IB->>UWV: stopAndTranslate()
UWV->>WV: wx.stopRecord()
WV-->>UWV: { localId: "wxLocalId_xxx" }
UWV-->>IB: state.isTranslating = true
UWV->>WV: wx.translateVoice({ localId, isShowProgressTips: 1 })
WV->>WX: 上传录音+请求语音识别
WX-->>WV: 返回识别文字
WV-->>UWV: { translateResult: "帮我重置密码" }
UWV-->>IB: 返回识别文字
IB->>IB: inputText.value += translateResult
IB->>IB: state.isTranslating = false, state.isRecording = false
Note over U,WV: 异常分支
alt 录音超过60秒
WV-->>UWV: 自动触发 stopRecord 回调
UWV->>UWV: 自动执行 translateVoice
end
alt 转文字失败
WV-->>UWV: fail 回调 { errMsg }
UWV-->>IB: showToast("语音识别失败")
end
```
### 4.2 方案A — 坐席端 Web Speech API 实时转写
```mermaid
sequenceDiagram
participant U as 坐席
participant RB as ReplyBox.vue
participant USR as useSpeechRecognition
participant SR as SpeechRecognition
Note over U,SR: 阶段1:初始化
RB->>USR: useSpeechRecognition('zh-CN')
USR->>USR: 检查 window.SpeechRecognition || webkitSpeechRecognition
alt 浏览器不支持
USR-->>RB: state.isSupported = false
RB->>RB: 语音按钮显示为禁用/隐藏
else 浏览器支持
USR->>USR: state.isSupported = true
end
Note over U,SR: 阶段2:开始识别
U->>RB: 点击语音按钮 🎤
RB->>USR: start()
USR->>SR: new SpeechRecognition()
USR->>SR: lang='zh-CN', continuous=true, interimResults=true
USR->>SR: 注册 onresult / onerror / onend 回调
USR->>SR: start()
SR-->>USR: onstart 触发
USR-->>RB: state.isListening = true
RB->>RB: 按钮变红 + textarea 显示光标 + 提示"正在聆听..."
Note over U,SR: 阶段3:实时转写(边说边出字)
U->>SR: 说话:"帮我查一下"
SR-->>USR: onresult(event) — interimResults (isFinal=false)
USR->>USR: state.interimText = "帮我查一下"
USR-->>RB: 实时更新 textarea(灰色临时文字)
RB->>RB: inputText.value = finalText + interimText
U->>SR: 继续说话:"帮我查一下密码"
SR-->>USR: onresult(event) — final result (isFinal=true)
USR->>USR: state.finalText += "帮我查一下密码"
USR-->>RB: 确定文字追加到 textarea
RB->>RB: inputText.value = finalText
Note over U,SR: 阶段4:停止识别
U->>RB: 再次点击语音按钮
RB->>USR: stop()
USR->>SR: stop()
SR-->>USR: onend 触发
USR-->>RB: state.isListening = false
RB->>RB: 按钮恢复 + textarea 保持已识别文字
Note over U,SR: 异常分支
alt 识别出错
SR-->>USR: onerror(event) — error='not-allowed'
USR-->>RB: showToast("麦克风权限被拒绝")
end
alt 用户长时间不说话
SR-->>USR: onend 自动触发(超时停止)
USR-->>RB: state.isListening = false
end
```
---
## 5. 任务列表
### T01: H5端语音识别功能(企微JS-SDK方案)
| 属性 | 值 |
|------|-----|
| **Task ID** | T01 |
| **任务名** | H5端语音识别功能(企微JS-SDK方案) |
| **源文件** | `frontend-h5/src/types/wecom-jssdk.d.ts` [新建]<br>`frontend-h5/src/api/wecom.ts` [新建]<br>`frontend-h5/src/composables/useWecomVoice.ts` [新建]<br>`frontend-h5/src/components/chat/InputBar.vue` [修改] |
| **依赖** | 无 |
| **优先级** | P0 |
| **复杂度** | 中等 |
| **预估工时** | 4-6 小时 |
**实现要点**
1. 创建 `wecom-jssdk.d.ts`:声明 `Wx` 接口及 `startRecord`/`stopRecord`/`translateVoice` 等方法类型
2. 创建 `wecom.ts`:封装 `getJsapiConfig(url)` 调用后端 `/api/wecom/jsapi-config`
3. 创建 `useWecomVoice.ts`:核心 composable,封装 JS-SDK 加载、wx.config 鉴权、录音、转文字完整流程
- 参考 `EmergencyDispatcher.vue` 中已有的 `loadWeworkSDK()` / `wxConfig()` 模式
- `init()` 幂等设计:多次调用安全,避免重复加载 SDK
- `jsApiList` 需包含 `['startRecord', 'stopRecord', 'translateVoice']`
4. 修改 `InputBar.vue`
- 工具栏添加语音按钮(🎤),放在文件按钮后
- 按下开始录音,松开停止+转文字
- 录音中 UI 状态:按钮变红 + 底部提示"松开识别文字"
- 识别结果追加到 `inputText.value`
### T02: 坐席端语音识别功能(Web Speech API方案)
| 属性 | 值 |
|------|-----|
| **Task ID** | T02 |
| **任务名** | 坐席端语音识别功能(Web Speech API方案) |
| **源文件** | `frontend-agent/src/types/speech-recognition.d.ts` [新建]<br>`frontend-agent/src/composables/useSpeechRecognition.ts` [新建]<br>`frontend-agent/src/components/chat/ReplyBox.vue` [修改] |
| **依赖** | 无(与 T01 完全独立) |
| **优先级** | P0 |
| **复杂度** | 中等 |
| **预估工时** | 3-4 小时 |
**实现要点**
1. 创建 `speech-recognition.d.ts`:声明 `SpeechRecognition` 接口及事件类型
2. 创建 `useSpeechRecognition.ts`:核心 composable
- `continuous=true`:连续识别模式
- `interimResults=true`:启用实时中间结果
- `lang='zh-CN'`:中文识别
- `onresult` 回调中区分 `isFinal`(最终结果)和 interim(中间结果)
- 最终结果追加到 `finalText`,中间结果实时更新 `interimText`
3. 修改 `ReplyBox.vue`
- 工具栏添加语音按钮(🎤),放在文件按钮和分隔线之间
- 替换现有占位代码 `voice: '语音消息功能开发中'`
- 点击开始识别,再次点击停止
- 实时转写:textarea 中显示 `finalText + interimText`
- 识别中 UI 状态:按钮变红 + 麦克风动画
### 任务依赖图
```mermaid
graph LR
T01[T01: H5端语音识别<br>企微JS-SDK方案]
T02[T02: 坐席端语音识别<br>Web Speech API方案]
T01 --- T02
style T01 fill:#e1f5fe,stroke:#0288d1,stroke-width:2px
style T02 fill:#e8f5e9,stroke:#388e3c,stroke-width:2px
```
> **说明**:T01 和 T02 完全独立,可并行开发。两者无文件交叉,无接口依赖。
---
## 6. 后端改动
### 6.1 JS-SDK 签名接口 — 已存在,无需修改
**已有接口**`GET /api/wecom/jsapi-config?url=xxx`
**文件位置**`backend/app/api/wecom_jsapi.py`
**返回格式**
```json
{
"code": 0,
"data": {
"corp_id": "wwa8c87970b2011f41",
"agent_id": "1000133",
"timestamp": 1718500000,
"nonce_str": "5K8264ILTKCH...",
"signature": "f7c8e9..."
}
}
```
**底层实现**
- `WecomService.get_jsapi_ticket()`:从 Redis 缓存或企微 API 获取 jsapi_ticketTTL 6900s
- `WecomService.generate_jsapi_signature()``sha1(jsapi_ticket={ticket}&noncestr={nonce}&timestamp={ts}&url={url})`
**已在路由注册**`router.py` 第 224 行 `api_router.include_router(wecom_jsapi_router, tags=["企微JS-SDK"])`
### 6.2 企微配置 — 已存在
以下配置已在 `backend/app/config.py` 中定义,通过环境变量注入:
| 配置项 | 环境变量 | 说明 |
|--------|----------|------|
| `wecom_corp_id` | `WECOM_CORP_ID` | 企业ID |
| `wecom_agent_id` | `WECOM_AGENT_ID` | 应用AgentId |
| `wecom_secret` | `WECOM_SECRET` | 应用Secret(用于获取 access_token → jsapi_ticket |
### 6.3 上传接口白名单 — 无需修改
**原因**:企微 `translateVoice()` 在内部完成录音上传和识别,音频数据不经过我们的后端上传接口。识别结果直接以文字形式返回前端。因此 **不需要**`ALLOWED_EXTENSIONS` 中添加音频格式。
> 如果未来需要将录音文件保存到服务器(如审计需求),才需要在 `upload.py` 的 `ALLOWED_EXTENSIONS` 中添加 `mp3`、`amr`、`wav` 等格式。
### 6.4 企微管理后台配置 — 需确认
在企微管理后台(work.weixin.qq.com)需要确保以下配置:
1. **应用可信域名**`itsupport.servyou.com.cn` 已配置(OAuth2 已用)
2. **JS-SDK 权限**:应用管理 → 自建应用 → 开发者接口 → 确认已开启"语音识别"权限
3. **可信域名验证**:域名根目录需放置企微验证文件(如 `WW_verify_xxx.txt`)— 应已配置
> ⚠️ **需确认**:企微管理后台是否已为当前应用开启 `startRecord`/`stopRecord`/`translateVoice` 的 JS-SDK 权限。部分接口需要在应用详情页单独申请。
---
## 7. 错误处理与降级策略
### 7.1 H5端(企微 JS-SDK
| 场景 | 检测方式 | 降级策略 |
|------|----------|----------|
| **非企微环境** | `navigator.userAgent` 不含 `wxwork` | 语音按钮隐藏或显示 Toast"请在企业微信中打开"index.html 已有环境拦截) |
| **JS-SDK 加载失败** | `script.onerror` 回调 | Toast"企微 SDK 加载失败",语音按钮禁用 |
| **wx.config 鉴权失败** | `wx.error()` 回调 | Toast"企微授权失败",记录 errMsg 到日志,语音按钮禁用 |
| **录音权限被拒绝** | `wx.startRecord()` 无响应或 `wx.error` | Toast"请允许麦克风权限" |
| **translateVoice 失败** | `fail` 回调 `{ errMsg }` | Toast"语音识别失败,请重试",保留输入框已有内容 |
| **录音超 60 秒** | 企微自动触发 `stopRecord` | composable 内监听自动停止,自动执行转文字 |
| **网络错误** | API 请求超时/失败 | Toast"网络异常,请检查网络后重试" |
| **jsapi_ticket 获取失败** | 后端返回 code=5001 | Toast"签名服务暂时不可用",语音按钮禁用 |
### 7.2 坐席端(Web Speech API
| 场景 | 检测方式 | 降级策略 |
|------|----------|----------|
| **浏览器不支持** | `!window.SpeechRecognition && !window.webkitSpeechRecognition` | 语音按钮隐藏,控制台 warning |
| **麦克风权限被拒绝** | `onerror` 事件 `error='not-allowed'` | ElMessage.error"请允许麦克风权限后重试" |
| **网络错误**Web Speech API 依赖网络) | `onerror` 事件 `error='network'` | ElMessage.error"网络异常,语音识别不可用" |
| **未检测到语音** | `onerror` 事件 `error='no-speech'` | ElMessage.info"未检测到语音,请重试" |
| **识别中断** | `onerror` 事件 `error='aborted'` | 静默处理,不提示(用户主动中断) |
| **识别服务不可用** | `onerror` 事件 `error='service-not-allowed'` | ElMessage.error"语音识别服务不可用" |
| **长时间无语音输入** | `onend` 自动触发(超时停止) | 保留已识别文字,按钮恢复初始状态 |
### 7.3 通用降级原则
1. **语音功能是辅助输入手段**,不是核心功能 — 降级不影响正常文字输入
2. **所有错误都 Toast/ElMessage 提示**,不阻塞 UI
3. **语音按钮在不支持时隐藏而非禁用**,避免用户困惑
4. **识别失败时保留输入框已有内容**,不清空
---
## 8. 关键实现细节与陷阱
### 8.1 企微 JS-SDK 陷阱
#### 8.1.1 JS-SDK 文件加载顺序
```
EmergencyDispatcher.vue 现有加载方式(可参考):
1. 先加载 wwLogin-1.2.7.js(企微登录SDK
2. 再加载 jweixin-1.2.0.js(企微JS-SDK
```
**陷阱**`jweixin-1.2.0.js` 文件名含"1.2.0"但实际是企微最新版 JS-SDK。不要被版本号误导。
**建议**`useWecomVoice.ts``init()` 中只需加载 `jweixin-1.2.0.js`,不需要加载 `wwLogin-1.2.7.js`(那是登录用的)。
#### 8.1.2 wx.config 的 jsApiList
```javascript
wx.config({
beta: true, // 必须开启,否则部分接口不可用
debug: false,
appId: config.corp_id, // 注意:企微用 corp_id,不是 agent_id
timestamp: config.timestamp,
nonceStr: config.nonce_str, // 注意大小写:nonceStr(驼峰)
signature: config.signature,
jsApiList: [
'startRecord',
'stopRecord',
'translateVoice',
// 如果已有 agentConfig 需求,也加上
'agentConfig'
]
})
```
**陷阱**
- `appId` 字段传 `corp_id`(企业ID),不是 `agent_id`
- `nonceStr` 是驼峰命名,后端返回的是 `nonce_str`(下划线),前端需要映射
- `beta: true` 必须设置,否则 `translateVoice` 等接口可能不可用
#### 8.1.3 translateVoice 的 60 秒限制
企微 `startRecord()` 最长录音时间为 **60 秒**。超过 60 秒,企微会自动触发 `stopRecord` 回调。
**处理方案**
```typescript
// 在 startRecording() 中设置定时器
let autoStopTimer: ReturnType<typeof setTimeout> | null = null
function startRecording() {
wx.startRecord()
// 59 秒自动停止(留 1 秒余量)
autoStopTimer = setTimeout(() => {
if (state.isRecording) {
stopAndTranslate()
}
}, 59000)
}
function stopAndTranslate() {
if (autoStopTimer) {
clearTimeout(autoStopTimer)
autoStopTimer = null
}
// ... stopRecord + translateVoice
}
```
#### 8.1.4 translateVoice 返回格式
`translateVoice``success` 回调返回 `{ translateResult: string }`
**陷阱**`translateResult` 可能包含换行符或前后空格,需要 `trim()` 处理。
#### 8.1.5 页面 URL 传参
签名接口需要传入当前页面 URL(不含 `#` 及其后):
```typescript
const url = window.location.href.split('#')[0]
```
**陷阱**Vue Router 使用 hash 模式时,URL 格式为 `https://xxx/#/h5/chat`。必须去掉 `#` 后面的部分,否则签名不匹配。
> **需确认**H5 端 Vue Router 使用的是 hash 模式还是 history 模式。如果是 history 模式,URL 本身不含 `#`,但仍需确保传入的是完整 URL。
#### 8.1.6 多次 init 幂等性
用户可能多次进入聊天页面,`init()` 会被多次调用。需要确保:
- JS-SDK script 只加载一次(检查 `window.wx` 是否存在)
- `wx.config()` 可以多次调用(企微允许重复 config)
- 使用 `Promise` 缓存避免并发 init
### 8.2 Web Speech API 陷阱
#### 8.2.1 continuous 和 interimResults 参数
```typescript
recognition.continuous = true // 连续识别,不会在第一个结果后自动停止
recognition.interimResults = true // 返回中间结果(未确定的文字)
recognition.lang = 'zh-CN' // 中文识别
recognition.maxAlternatives = 1 // 只返回最佳结果
```
**陷阱**
- `continuous=false`(默认)时,识别到第一个停顿就自动停止 — 不适合长文本输入
- `interimResults=false` 时,只有最终结果才回调 — 用户体验差(说完才出字)
- 两个参数必须同时为 `true` 才能实现"边说边出字"
#### 8.2.2 onresult 事件处理
```typescript
recognition.onresult = (event: SpeechRecognitionEvent) => {
let interimText = ''
let finalText = ''
// 从 resultIndex 开始遍历新的结果
for (let i = event.resultIndex; i < event.results.length; i++) {
const result = event.results[i]
if (result.isFinal) {
finalText += result[0].transcript
} else {
interimText += result[0].transcript
}
}
// 更新状态
state.interimText = interimText
if (finalText) {
state.finalText += finalText
}
}
```
**陷阱**
- 必须从 `event.resultIndex` 开始遍历,而不是从 0,否则会重复处理旧结果
- `result[0]` 是最佳候选,`result[1]` 是次佳候选(如果 `maxAlternatives > 1`
- `isFinal=true` 的结果不会改变,可以安全追加
#### 8.2.3 onend 自动重启
Web Speech API 在以下情况会自动触发 `onend`
- 用户长时间不说话(约 10-15 秒静默)
- 网络中断
- 浏览器内部超时
**处理方案**:如果用户仍然处于"聆听"状态(未主动停止),自动重启识别:
```typescript
recognition.onend = () => {
if (shouldKeepListening) {
// 自动重启(短暂延迟避免竞态)
setTimeout(() => {
try {
recognition.start()
} catch (e) {
// 可能报错 "recognition has already started"
console.warn('重启识别失败:', e)
}
}, 100)
} else {
state.isListening = false
}
}
```
#### 8.2.4 HTTPS 要求
Web Speech API 要求页面必须在 **HTTPS**`localhost` 下运行。
**当前环境**
- 正式地址 `https://itsupport.servyou.com.cn` — ✅ HTTPS
- 本地开发 `http://localhost:5174` — ✅ localhost 豁免
- 坐席端 `http://localhost:5175` — ✅ localhost 豁免
**陷阱**:如果坐席端通过 HTTP IP 访问(如 `http://10.80.0.x:5175`),Web Speech API 不可用。
#### 8.2.5 Chrome 隐身模式限制
Chrome 隐身模式下 Web Speech API 可能不可用(取决于 Chrome 版本和隐私设置)。
**处理方案**`onerror` 中检测 `error='service-not-allowed'`,提示用户使用正常模式。
#### 8.2.6 识别器实例管理
**陷阱**`SpeechRecognition` 实例不能重复调用 `start()`。如果在 `onend` 后需要重启,必须确保上一个实例已完全停止。
```typescript
// 错误:重复 start
recognition.start()
recognition.start() // 抛出 InvalidStateError
// 正确:等待 onend 后再 start
recognition.stop()
recognition.onend = () => {
recognition.start() // 安全
}
```
### 8.3 CSP 安全策略
H5 端 `index.html` 已有 CSP 配置:
```html
<meta http-equiv="Content-Security-Policy" content="
default-src 'self';
script-src 'self' 'unsafe-eval' 'unsafe-inline' https://res.wx.qq.com;
...
connect-src 'self' https://qyapi.weixin.qq.com wss://* ...;
" />
```
**确认**
- `script-src` 已包含 `https://res.wx.qq.com` — ✅ 可加载企微 JS-SDK
- `connect-src` 已包含 `https://qyapi.weixin.qq.com` — ✅ JS-SDK 可与企微服务器通信
- 坐席端无 CSP 限制(或限制较宽松)— ✅ Web Speech API 不受 CSP 影响
**无需修改 CSP 配置。**
---
## 9. 待明确事项
### 9.1 需用户确认的技术决策
| # | 问题 | 影响 | 默认假设 |
|---|------|------|----------|
| 1 | 企微管理后台是否已为当前应用开启 `startRecord`/`stopRecord`/`translateVoice` 的 JS-SDK 权限? | H5 端语音功能是否可用 | 已开启(因为 EmergencyDispatcher 已在使用 agentConfig |
| 2 | H5 端 Vue Router 使用 hash 模式还是 history 模式? | 签名 URL 传参方式 | hash 模式(需去掉 `#` 后部分) |
| 3 | 坐席端是否需要支持 Firefox | Firefox 使用 `mozSpeechRecognition`(实验性) | 不需要,坐席端限定 Chrome/Edge |
| 4 | 是否需要将语音识别的文字自动发送,还是仅填入输入框等待用户确认? | 交互设计 | 仅填入输入框(用户确认后手动发送) |
| 5 | H5 端语音按钮交互方式:按住说话(press-to-talk)还是点击开始/点击结束? | UX 交互设计 | 按住说话(移动端常见交互) |
| 6 | 坐席端语音按钮交互方式:点击开始/再次点击结束? | UX 交互设计 | 点击开始/再次点击结束(桌面端常见交互) |
### 9.2 假设说明
1. **后端 JS-SDK 签名接口稳定可用**:已验证 `wecom_jsapi.py` 代码完整,`WecomService``get_jsapi_ticket()` 有 Redis 缓存+降级机制
2. **企微应用已配置可信域名**`itsupport.servyou.com.cn` 已用于 OAuth2 登录,JS-SDK 共用同一域名配置
3. **坐席端浏览器版本足够新**Chrome 25+ / Edge 79+ 支持 Web Speech API(当前主流版本均满足)
4. **企微客户端版本支持 translateVoice**:企微 iOS/Android 客户端 2.4+ 支持(当前版本远超)
---
## 附录:类图
```mermaid
classDiagram
class Wx {
<<interface>>
+config(options: WxConfigOptions) void
+ready(callback: Function) void
+error(callback: Function) void
+startRecord() void
+stopRecord(options: object) void
+translateVoice(options: object) void
}
class JsapiConfig {
+corp_id: string
+agent_id: string
+timestamp: number
+nonce_str: string
+signature: string
}
class UseWecomVoiceReturn {
+state: VoiceState
+init() Promise~void~
+startRecording() Promise~void~
+stopAndTranslate() Promise~string~
+isSupported() boolean
}
class VoiceState {
+isReady: boolean
+isRecording: boolean
+isTranslating: boolean
+error: string|null
}
class SpeechRecognition {
<<interface>>
+lang: string
+continuous: boolean
+interimResults: boolean
+start() void
+stop() void
+abort() void
+onresult: Function
+onerror: Function
+onend: Function
}
class UseSpeechRecognitionReturn {
+state: SpeechState
+start() void
+stop() void
+reset() void
}
class SpeechState {
+isListening: boolean
+interimText: string
+finalText: string
+error: string|null
+isSupported: boolean
}
class InputBar {
+inputText: string
+handleVoiceStart() void
+handleVoiceEnd() void
}
class ReplyBox {
+inputText: string
+toggleVoice() void
}
UseWecomVoiceReturn --> VoiceState
UseWecomVoiceReturn ..> Wx : uses
UseWecomVoiceReturn ..> JsapiConfig : fetches
InputBar --> UseWecomVoiceReturn : composes
UseSpeechRecognitionReturn --> SpeechState
UseSpeechRecognitionReturn ..> SpeechRecognition : wraps
ReplyBox --> UseSpeechRecognitionReturn : composes
```
---
## 10. 增量更新记录(v1.1
> **更新日期**: 2026-08-03
> **触发事件**: voice_asr.py P0 安全巡检 + 文档-代码一致性核查
### 10.1 背景:v1.0 与实际部署的偏离
v1.0 设计时仅覆盖两条技术路径:
- H5 端 = 企微 JS-SDK`wx.translateVoice`
- 坐席端 = Web Speech API
**实际生产中 H5 端是"双策略自动切换"**v1.0 漏掉了 PC/Mac 端的兜底分支):
| 环境 | v1.0 设计 | 实际部署(InputBar.vue `voiceStrategy` computed|
|------|-----------|----------------------------------------------|
| 手机端企微 | 企微 JS-SDK ✅ | 企微 JS-SDK ✅(`useWecomVoice`|
| **PC/Mac 端企微** | v1.0 未考虑)| **前端录音 + 后端 `POST /api/voice/asr`(百度 ASR** |
| 普通浏览器 H5 | (v1.0 未考虑)| 不显示语音按钮(InputBar.vue 未启用 `useSpeechRecognition` 分支)|
| 坐席端 | Web Speech API ✅ | Web Speech API ✅(`useSpeechRecognition`,无后端依赖)|
### 10.2 为什么必须有"百度 ASR 兜底"
**企微 PC/Mac 客户端内置浏览器(WebView)的两个硬限制**
1. **不支持 Web Speech API** —— `SpeechRecognition` / `webkitSpeechRecognition` 在企微 WebView 中不存在
2. **企微 JS-SDK 的 `startRecord` / `translateVoice` 永远挂起** —— 调用不回调 `success`/`fail`(属于 SDK 已知行为,非我们可控)
**PC/Mac 端企微用户**既不能用 Web Speech API,也不能用企微 JS-SDK,必须走**"前端录音 + 后端识别"**的第三条路径。
### 10.3 实际技术方案
**H5 端 PC/Mac 企微用户**
```
前端(InputBar.vue → useAudioRecorder → api/voice.ts
│ 1. Web Audio API 采集 PCM (16kHz/16bit/mono)
│ 2. 录音停止后封装 FormDatamultipart/form-data,字段名 audio
后端 POST /api/voice/asr
│ 3. Depends(get_current_user) 鉴权(H5 端 apiClient 全局拦截器自动注入 Bearer Token
│ 4. 大小校验(≤ 10MB+ Content-Type 白名单校验
│ 5. Redis 缓存 baidu:asr:tokenTTL 30 天)
│ 6. httpx.raw POST → 百度 ASR 极速版 APIdev_pid=80001
返回 {code:0, data:{text:"..."}} → inputText.value += result
```
**关键文件**
| 文件 | 角色 |
|------|------|
| `src/frontend-h5/src/composables/useAudioRecorder.ts` | 前端 PCM 录音(Web Audio API|
| `src/frontend-h5/src/api/voice.ts` | 封装 multipart POST 请求 |
| `src/backend/app/api/voice_asr.py` | 后端 ASR 端点(已加 auth 2026-08-03|
| `src/frontend-h5/src/components/chat/InputBar.vue:246-265` | 策略选择 computedwecom/baidu/none|
### 10.4 P0 安全巡检修复(2026-08-03
**原问题**`POST /api/voice/asr` 端点无 `Depends(get_current_user)`,任意用户可上传文件消耗百度 API 配额。
**修复**`src/backend/app/api/voice_asr.py`):
```python
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from app.dependencies import UserInfo, get_current_user
@router.post("/asr")
async def transcribe_audio(
audio: UploadFile = File(..., description="PCM 音频数据(16kHz, 16-bit, mono"),
current_user: UserInfo = Depends(get_current_user), # 新增
):
...
```
**加固项**
- `MAX_AUDIO_SIZE = 10 * 1024 * 1024`10MB 上限 ≈ 5 分钟录音)
- `ALLOWED_AUDIO_CONTENT_TYPES` 白名单(`audio/pcm`/`audio/wav`/`audio/x-wav`/`audio/wave`/`application/octet-stream`
- 日志带 `current_user.employee_id`(审计追溯)
**前端兼容性**`src/frontend-h5/src/api/index.ts:38-44` 全局拦截器自动注入 `Authorization: Bearer ${token}`**无破坏性**。
### 10.5 编号冲突说明(待治理)
**本技术方案编号 `REQ-AI-003` 与产品 PRD 同号冲突**
- 本技术方案 `REQ-AI-003` = 语音转文字(功能)
- 产品 PRD `PRD-REQ-AI-003-多模态视觉理解-v1.0.md` = 视觉理解(**不同功能**
**冲突原因**v1.0 编写时未走规范 `docs/00-产品开发流程与文档管理规范.md §7.3 需求编号全局唯一性` 校验。
**治理建议**(待规范治理批次执行):
- 把语音转文字编号重命名为 `REQ-AI-009`AI 模块下一个可用序号)
- 同时更新 `docs/01-产品文档/03-AI服务/PRD-REQ-AI-003-多模态视觉理解-v1.0.md` 中的"语音理解(后续迭代)"引用
- 治理时间窗:建议放在下一次 AI 模块迭代时一并处理(避免本次 P0 修复范围蔓延)
### 10.6 文档-代码一致性核查清单
为避免再次出现"文档与代码偏离"的情况,本节列出本方案相关文档的核查清单:
| 文档 | 应包含内容 | 状态 |
|------|-----------|------|
| `docs/05-运营文档/02-用户手册/手册-坐席端.md` §5.4 | 三种环境的真实语音方案 | 已修正为 v1.1 |
| `docs/04-运维文档/运维指南/配置清单与环境变量.md` §5 | `BAIDU_ASR_*` 调用方说明 | 已补充 |
| `docs/01-产品文档/03-AI服务/PRD-REQ-AI-003-多模态视觉理解-v1.0.md` §2.1 | 语音理解不在范围说明 | 仍准确(无须改)|
| `docs/02-技术文档/技术架构/IT智能服务台-系统架构设计文档v2.md` §7.3 | H5 模块描述 | 待补"H5 双策略"说明 |
### 10.7 测试矩阵(v1.1 加 auth 后)
| 场景 | 预期结果 |
|------|---------|
| 未带 Bearer Token 调用 `/api/voice/asr` | 401 Unauthorized |
| 带有效 Token,正常 PCM<10MB, audio/pcm| 200 + {text: "..."} |
| 带有效 TokenPCM > 10MB | 3001 错误(音频过大)|
| 带有效 TokenContent-Type=text/plain | 3001 错误(不支持的音频格式)|
| Token 过期 | 401(与项目其他端点一致)|