Files
wecom_it_smart_desk/docs/02-技术文档/技术架构/技术方案-REQ-AI-003-语音转文字-v1.0.md
T
Simon 44e77dcb0e chore(docs): docs/ 目录全面重新编号 + 重组
**重构前**(旧编号 02-11):
- docs/02-产品需求/      → 00 产品规划/PRD
- docs/03-技术架构/      → 01-05 子目录散落
- docs/04-原型设计/      → 01-02 产品设计(HTML 原型)
- docs/05-原型设计/      → screens/
- docs/06-测试素材/      → 02-E2E / 03-功能 / 04-版本测试
- docs/07-项目管理/      → 任务说明书/日报/计划
- docs/08-安全审计/      → 审计报告
- docs/09-堡垒运维/      → toolbox / deploy
- docs/10-项目管理/      → 任务说明书(重复)
- docs/11-历史归档/      → deploy-nas-archived

**重构后**(新编号 00-07,语义化):
- docs/00-产品开发流程与文档管理规范.md
- docs/00-版本迭代总览.md
- docs/01-产品文档/      (PRD/原型/认证/会话/AI 服务/坐席/集成)
- docs/02-技术文档/      (技术方案/架构图/重构记录/前端改造/实现配置)
- docs/03-测试文档/      (E2E/功能用例/版本报告/缺陷单)
- docs/04-运维文档/      (部署运维/运维指南)
- docs/05-运营文档/      (品牌推广/用户手册)
- docs/06-安全审计/      (审计报告)
- docs/07-项目管理/      (任务说明书/日报/计划/看板)

**净收益**:
- 目录编号与产品文档管理规范对齐(按文档阶段 01-07 编号)
- 消除 02-产品需求 与 10-项目管理 的编号重叠
- 子目录按文档类型分组(如 01-产品文档/00-产品规划、01-产品文档/01-认证与登录)
- 把运维/安全/项目管理从 0X 散落改为 04/06/07

合计 494 文件 + 78495 行 / - 14076 行
2026-08-03 18:46:55 +08:00

38 KiB
Raw Blame 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

/** 企微 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 语音识别 composableuseWecomVoice.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 语音识别 composableuseSpeechRecognition.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 小时

实现要点

  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 [新建]
frontend-agent/src/composables/useSpeechRecognition.ts [新建]
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 状态:按钮变红 + 麦克风动画

任务依赖图

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_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.pyALLOWED_EXTENSIONS 中添加 mp3amrwav 等格式。

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.tsinit() 中只需加载 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_id
  • nonceStr 是驼峰命名,后端返回的是 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 返回格式

translateVoicesuccess 回调返回 { 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 要求页面必须在 HTTPSlocalhost 下运行。

当前环境

  • 正式地址 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-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 代码完整,WecomServiceget_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+ 支持(当前版本远超)

附录:类图

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-SDKwx.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):

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 * 102410MB 上限 ≈ 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-009AI 模块下一个可用序号)
  • 同时更新 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(与项目其他端点一致)