采集面:mediafetch 新增 inline_voice.go(音频白名单,与 documents.ExtAllowed 互不污染; 复用既有来源白名单 / 逐跳校验 / 边读边限 / 超时);四个适配器零改动。 消费面:channelbridge 在流水线最前端把「语音字节 → 文本」归一并写回 msg.Content; inbound.go 的丢空判据放宽为「无文本且无可用媒体」。 微信优先平台自带转写(Content 非空 ⇒ 不调 ASR,不重复付费)。 降级(主人定的两层):新增 voice/outcome.go 的 STTOutcome 识破静默档位; errors.go 分类鉴权失败/欠费/限流(CircuitWorthy);voice_breaker.go 临时熔断 + 退避(TTL 恢复); 无论是否熔断,消息面都落一行中文诚实提示(绝不静默、绝不留空消息)。 独立短超时:VOICE_STT_TIMEOUT_SEC(默认 20s,同步 .env.example)。 kook 不再把 CDN 地址写进 Content(地址仍在 Media[].URL)。 设计先行:design/06 §1.1/§1.2、design/08 §4.1、design/24 §1.3/§4.2、开源对照清单。 变异 5 条(静默档位不带 Cause / Transcribed 误判 / 熔断恒放行 / ChatText 退化 / 平台文字判据取反)。 诚实边界:实施时官方音频规格尚未抓到 ⇒ MaxVoiceBytes 自定 5 MiB(保守)、采样率仍 16 kHz; 官方规格随后已抓到(10 MB / 5 min;格式含 amr、无 silk),对齐留待下次改动。
77 lines
2.8 KiB
Go
77 lines
2.8 KiB
Go
package channelbridge
|
||
|
||
import (
|
||
"sync"
|
||
"time"
|
||
)
|
||
|
||
// 本文件承载 IM 入站语音转写的**临时熔断**(切片 IM-ASR;design/06 §1.1):
|
||
// 上游「再试也是白试」的失败(未配置 / 鉴权失败 / 欠费 / 限流)打开它一小段时间,
|
||
// 期间**不再发起任何上游调用** —— 既不白等一次超时,也不每条语音都撞一次付费墙。
|
||
//
|
||
// 三条纪律:
|
||
// - **只对可分类的失败熔断**:判据是 `voice.CircuitWorthy`(哨兵链)。「格式不支持」
|
||
// 这类按内容发生的失败**不**熔断,否则一条被拒的语音会让后续语音全部降级。
|
||
// - **是临时的**:退避时长逐次翻倍、封顶;任意一次**成功**立即清零恢复,TTL 到期也
|
||
// 自动恢复。它刻意**没有**「永久禁用」这一档 —— 拿掉一个能力只能由配置决定。
|
||
// - **并发安全**:入站流水线并发(`maxConcurrent`),因此状态只经互斥锁访问。
|
||
type voiceBreaker struct {
|
||
mu sync.Mutex
|
||
openUntil time.Time
|
||
failures int
|
||
}
|
||
|
||
// 熔断的具名常量:起始 5 分钟、逐次翻倍、上限 1 小时。上限同时防止位移溢出。
|
||
const (
|
||
voiceBreakerBaseBackoff = 5 * time.Minute
|
||
voiceBreakerMaxBackoff = time.Hour
|
||
// voiceBreakerMaxShift 是退避翻倍的次数上限(5min << 6 = 320min,已超上限,
|
||
// 因此再翻也没有意义;同时避免 failures 无界增长后位移溢出)。
|
||
voiceBreakerMaxShift = 6
|
||
// voiceBreakerMinRetryAfter 是提示文案里最小可展示的剩余退避(避免出现「0s」)。
|
||
voiceBreakerMinRetryAfter = time.Second
|
||
)
|
||
|
||
// allowed 报告现在是否可以发起一次上游调用。
|
||
func (b *voiceBreaker) allowed(now time.Time) bool {
|
||
b.mu.Lock()
|
||
defer b.mu.Unlock()
|
||
return !now.Before(b.openUntil)
|
||
}
|
||
|
||
// open 记一次「再试也是白试」的失败,并按退避时长打开/延长熔断。
|
||
func (b *voiceBreaker) open(now time.Time) {
|
||
b.mu.Lock()
|
||
defer b.mu.Unlock()
|
||
b.failures++
|
||
shift := b.failures - 1
|
||
if shift > voiceBreakerMaxShift {
|
||
shift = voiceBreakerMaxShift
|
||
}
|
||
backoff := voiceBreakerBaseBackoff << shift
|
||
if backoff > voiceBreakerMaxBackoff || backoff <= 0 {
|
||
backoff = voiceBreakerMaxBackoff
|
||
}
|
||
b.openUntil = now.Add(backoff)
|
||
}
|
||
|
||
// succeed 记一次成功:**立即**恢复正常(熔断是临时的,成功即恢复)。
|
||
func (b *voiceBreaker) succeed() {
|
||
b.mu.Lock()
|
||
defer b.mu.Unlock()
|
||
b.failures = 0
|
||
b.openUntil = time.Time{}
|
||
}
|
||
|
||
// retryAfter 报告熔断还剩多久(未打开时为 0)。只用于日志与消息里的诚实提示 ——
|
||
// 调用方**不**据此做任何调度(恢复由时钟与成功两条路决定)。
|
||
func (b *voiceBreaker) retryAfter(now time.Time) time.Duration {
|
||
b.mu.Lock()
|
||
defer b.mu.Unlock()
|
||
left := b.openUntil.Sub(now)
|
||
if left < voiceBreakerMinRetryAfter {
|
||
return voiceBreakerMinRetryAfter
|
||
}
|
||
return left
|
||
}
|