feat(channels,voice): IM 各渠道语音消息可被读到(全渠道云端 ASR + 临时熔断)
采集面: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),对齐留待下次改动。
This commit is contained in:
1 parent
679cee04f7
commit
a918819878
20 files changed
+1685
-61
No files matched your search
@@ -0,0 +1,76 @@
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user