Files
wecom_it_smart_desk/docs/02-技术文档/技术架构/技术方案-REQ-AI-003-语音转文字-v1.0.md
T

1053 lines
38 KiB
Markdown
Raw Normal View History

# 语音识别转文字 — 技术方案设计
> **项目**: 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(与项目其他端点一致)|