Files
Cyrene_v2/internal/services/channelbridge/voice_breaker.go
T
AskaEth a918819878
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
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),对齐留待下次改动。
2026-10-05 08:32:37 +08:00

77 lines
2.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}