本提交为 .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-*/
38 KiB
语音识别转文字 — 技术方案设计
项目: IT 智能服务台(税友集团企微内嵌应用) 功能: 语音输入转文字,填入聊天输入框(非发送语音消息) 版本: v1.1 日期: 2026-07-15(v1.0 初版)/ 2026-08-03(v1.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)
/** 企微 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)
/** 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)
/** 语音识别状态 */
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)
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)
/** 语音识别状态 */
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 接口(已存在,无需修改)
# 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 录音转文字
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 实时转写
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 [新建]frontend-h5/src/api/wecom.ts [新建]frontend-h5/src/composables/useWecomVoice.ts [新建]frontend-h5/src/components/chat/InputBar.vue [修改] |
| 依赖 | 无 |
| 优先级 | P0 |
| 复杂度 | 中等 |
| 预估工时 | 4-6 小时 |
实现要点:
- 创建
wecom-jssdk.d.ts:声明Wx接口及startRecord/stopRecord/translateVoice等方法类型 - 创建
wecom.ts:封装getJsapiConfig(url)调用后端/api/wecom/jsapi-config - 创建
useWecomVoice.ts:核心 composable,封装 JS-SDK 加载、wx.config 鉴权、录音、转文字完整流程- 参考
EmergencyDispatcher.vue中已有的loadWeworkSDK()/wxConfig()模式 init()幂等设计:多次调用安全,避免重复加载 SDKjsApiList需包含['startRecord', 'stopRecord', 'translateVoice']
- 参考
- 修改
InputBar.vue:- 工具栏添加语音按钮(🎤),放在文件按钮后
- 按下开始录音,松开停止+转文字
- 录音中 UI 状态:按钮变红 + 底部提示"松开识别文字"
- 识别结果追加到
inputText.value
T02: 坐席端语音识别功能(Web Speech API方案)
| 属性 | 值 |
|---|---|
| Task ID | T02 |
| 任务名 | 坐席端语音识别功能(Web Speech API方案) |
| 源文件 | frontend-agent/src/types/speech-recognition.d.ts [新建]frontend-agent/src/composables/useSpeechRecognition.ts [新建]frontend-agent/src/components/chat/ReplyBox.vue [修改] |
| 依赖 | 无(与 T01 完全独立) |
| 优先级 | P0 |
| 复杂度 | 中等 |
| 预估工时 | 3-4 小时 |
实现要点:
- 创建
speech-recognition.d.ts:声明SpeechRecognition接口及事件类型 - 创建
useSpeechRecognition.ts:核心 composablecontinuous=true:连续识别模式interimResults=true:启用实时中间结果lang='zh-CN':中文识别onresult回调中区分isFinal(最终结果)和 interim(中间结果)- 最终结果追加到
finalText,中间结果实时更新interimText
- 修改
ReplyBox.vue:- 工具栏添加语音按钮(🎤),放在文件按钮和分隔线之间
- 替换现有占位代码
voice: '语音消息功能开发中' - 点击开始识别,再次点击停止
- 实时转写:textarea 中显示
finalText + interimText - 识别中 UI 状态:按钮变红 + 麦克风动画
任务依赖图
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
返回格式:
{
"code": 0,
"data": {
"corp_id": "wwa8c87970b2011f41",
"agent_id": "1000133",
"timestamp": 1718500000,
"nonce_str": "5K8264ILTKCH...",
"signature": "f7c8e9..."
}
}
底层实现:
WecomService.get_jsapi_ticket():从 Redis 缓存或企微 API 获取 jsapi_ticket(TTL 6900s)WecomService.generate_jsapi_signature():sha1(jsapi_ticket={ticket}&noncestr={nonce}×tamp={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)需要确保以下配置:
- 应用可信域名:
itsupport.servyou.com.cn已配置(OAuth2 已用) - JS-SDK 权限:应用管理 → 自建应用 → 开发者接口 → 确认已开启"语音识别"权限
- 可信域名验证:域名根目录需放置企微验证文件(如
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 通用降级原则
- 语音功能是辅助输入手段,不是核心功能 — 降级不影响正常文字输入
- 所有错误都 Toast/ElMessage 提示,不阻塞 UI
- 语音按钮在不支持时隐藏而非禁用,避免用户困惑
- 识别失败时保留输入框已有内容,不清空
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
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_idnonceStr是驼峰命名,后端返回的是nonce_str(下划线),前端需要映射beta: true必须设置,否则translateVoice等接口可能不可用
8.1.3 translateVoice 的 60 秒限制
企微 startRecord() 最长录音时间为 60 秒。超过 60 秒,企微会自动触发 stopRecord 回调。
处理方案:
// 在 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(不含 # 及其后):
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 参数
recognition.continuous = true // 连续识别,不会在第一个结果后自动停止
recognition.interimResults = true // 返回中间结果(未确定的文字)
recognition.lang = 'zh-CN' // 中文识别
recognition.maxAlternatives = 1 // 只返回最佳结果
陷阱:
continuous=false(默认)时,识别到第一个停顿就自动停止 — 不适合长文本输入interimResults=false时,只有最终结果才回调 — 用户体验差(说完才出字)- 两个参数必须同时为
true才能实现"边说边出字"
8.2.2 onresult 事件处理
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 秒静默)
- 网络中断
- 浏览器内部超时
处理方案:如果用户仍然处于"聆听"状态(未主动停止),自动重启识别:
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 后需要重启,必须确保上一个实例已完全停止。
// 错误:重复 start
recognition.start()
recognition.start() // 抛出 InvalidStateError
// 正确:等待 onend 后再 start
recognition.stop()
recognition.onend = () => {
recognition.start() // 安全
}
8.3 CSP 安全策略
H5 端 index.html 已有 CSP 配置:
<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-SDKconnect-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 假设说明
- 后端 JS-SDK 签名接口稳定可用:已验证
wecom_jsapi.py代码完整,WecomService的get_jsapi_ticket()有 Redis 缓存+降级机制 - 企微应用已配置可信域名:
itsupport.servyou.com.cn已用于 OAuth2 登录,JS-SDK 共用同一域名配置 - 坐席端浏览器版本足够新:Chrome 25+ / Edge 79+ 支持 Web Speech API(当前主流版本均满足)
- 企微客户端版本支持 translateVoice:企微 iOS/Android 客户端 2.4+ 支持(当前版本远超)
附录:类图
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)的两个硬限制:
- 不支持 Web Speech API ——
SpeechRecognition/webkitSpeechRecognition在企微 WebView 中不存在 - 企微 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. 录音停止后封装 FormData(multipart/form-data,字段名 audio)
▼
后端 POST /api/voice/asr
│
│ 3. Depends(get_current_user) 鉴权(H5 端 apiClient 全局拦截器自动注入 Bearer Token)
│ 4. 大小校验(≤ 10MB)+ Content-Type 白名单校验
│ 5. Redis 缓存 baidu:asr:token(TTL 30 天)
│ 6. httpx.raw POST → 百度 ASR 极速版 API(dev_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 |
策略选择 computed(wecom/baidu/none) |
10.4 P0 安全巡检修复(2026-08-03)
原问题:POST /api/voice/asr 端点无 Depends(get_current_user),任意用户可上传文件消耗百度 API 配额。
修复(src/backend/app/api/voice_asr.py):
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: "..."} |
| 带有效 Token,PCM > 10MB | 3001 错误(音频过大) |
| 带有效 Token,Content-Type=text/plain | 3001 错误(不支持的音频格式) |
| Token 过期 | 401(与项目其他端点一致) |