diff --git a/adapter/provider/provider.go b/adapter/provider/provider.go new file mode 100644 index 0000000..da49e50 --- /dev/null +++ b/adapter/provider/provider.go @@ -0,0 +1,117 @@ +// Package provider 定义上游 TTS 服务的统一抽象。 +// +// 设计要点(与 docs/UPSTREAM_ADAPTER_GUIDE.md §3 对齐): +// - 主干(controller/setting/metrics)只依赖本包,不依赖任何具体适配器; +// - 具体适配器(如 adapter/volcano)实现 Provider 并在自己的 init() 里注册; +// - 新增上游 = 写一个新适配器 + main.go 加一行空导入,主干零改动。 +package provider + +import ( + "context" + "fmt" + "time" + + "github.com/volcano-tts/tts-api/dto" +) + +// Provider 是一个上游 TTS 服务的统一抽象。 +// 实现者只需要关心:把我的能力说清楚、把一次合成做完。 +type Provider interface { + // Name 稳定标识,用于配置键、库表字段、metrics label。 + // 约定全小写无空格:volcano / openai / azure / aliyun / gpt-sovits。 + Name() string + + // Capabilities 声明本适配器支持什么。 + // 主干据此做校验与降级,不靠 if-else 猜实现。 + Capabilities() Capabilities + + // Synthesize 执行一次合成,阻塞到完成。 + // 必须遵守:ctx 取消即返回;上游不支持 clientFormat 时按 Capabilities 降级, + // 并在 Result.Format 里如实回报真实格式。 + Synthesize(ctx context.Context, req Request, mtr MetricsRecorder) (*dto.SynthesisResult, error) +} + +// Capabilities 声明适配器支持的能力,主干据此做校验与降级。 +type Capabilities struct { + // 上游原生能产出的格式;客户端要的格式不在其中时,主干负责降级或拒绝。 + Formats []string + // 是否支持用倍率控制语速。不支持时主干忽略 speed 而不是报错。 + Speed bool + // 是否按字符计费并回传用量(决定 UpstreamUsage 是否有意义)。 + Usage bool + // 是否支持多音色路由。不支持时该 provider 只能配一个默认音色。 + Voices bool + // 是否需要资源/部署/项目 ID 这类额外维度(火山需要,OpenAI 不需要)。 + ExtraScopes bool +} + +// Request 是主干交给适配器的、已归一化的合成请求。 +// 注意:这里刻意不出现任何厂商专属字段。 +type Request struct { + Text string + VoiceKey string // voices 表的主键语义(对外 voice 名或上游音色 ID) + Model string + Format string // 客户端期望格式(主干已归一化:mp3/wav/pcm/ogg_opus) + SampleRate int + Speed float64 // 1.0 = 原速 + Language string + Extra map[string]string // 厂商专属配置,从 voices/settings 的 JSON 列读 + + // Credentials 由 provider 自己解释: + // 火山读 api_key + resource_id;OpenAI 只读 api_key。 + Credentials Credentials +} + +// Credentials 承载上游鉴权与路由维度。 +// Scope 是厂商额外凭证/路由维度:火山 = resource_id;Azure = region;自建 = base_url。 +// 本包不解释 Scope 的内容,原样交给对应 provider。 +type Credentials struct { + APIKey string + Scope map[string]string +} + +// MetricsRecorder 是适配器向上报告埋点的接口。 +// 适配器本身不依赖 telemetry 包,controller 在 main 启动时把 Meter 适配成实现; +// 这样测试可以注入 mock,生产可以无侵入替换成 OTel。 +type MetricsRecorder interface { + UpstreamStarted(speaker, model, format string) + UpstreamFinished(speaker, model, format, status string, duration, ttfb time.Duration, chunks, audioBytes, errCode int) + UpstreamUsage(model string, textWords int) +} + +// nopMetrics 是 MetricsRecorder 的 no-op 默认值。 +type nopMetrics struct{} + +func (nopMetrics) UpstreamStarted(string, string, string) {} +func (nopMetrics) UpstreamFinished(string, string, string, string, time.Duration, time.Duration, int, int, int) { +} +func (nopMetrics) UpstreamUsage(string, int) {} + +// NoopMetrics 返回一个 MetricsRecorder 的 no-op 实现。 +// 适配器在未注入 recorder 时可把它作为默认值,保证埋点逻辑永远可运行。 +func NoopMetrics() MetricsRecorder { return nopMetrics{} } + +// UpstreamError 表示上游 TTS 返回的业务错误或传输错误。 +// 各适配器用它报告失败,并带上错误发生的阶段(Stage),供主干归一错误与打 metrics label。 +// 通用化到本包,是为了让主干(controller)只依赖 provider 包即可判断上游错误, +// 不必 import 具体适配器。 +type UpstreamError struct { + Code int // 上游业务/HTTP 错误码;0 表示传输/本地错误 + Message string + Stage string // "request"/"http"/"stream"/"wrap" - 出错阶段 + Wrapped error +} + +func (e *UpstreamError) Error() string { + if e.Wrapped != nil { + return fmt.Sprintf("upstream %s: code=%d %s: %v", e.Stage, e.Code, e.Message, e.Wrapped) + } + return fmt.Sprintf("upstream %s: code=%d %s", e.Stage, e.Code, e.Message) +} + +func (e *UpstreamError) Unwrap() error { return e.Wrapped } + +// IsAuth 当上游返回认证/权限类错误时返回 true(HTTP 401/403)。 +func (e *UpstreamError) IsAuth() bool { + return e.Code == 401 || e.Code == 403 +} diff --git a/adapter/provider/registry.go b/adapter/provider/registry.go new file mode 100644 index 0000000..3493ec5 --- /dev/null +++ b/adapter/provider/registry.go @@ -0,0 +1,33 @@ +package provider + +import "sort" + +// registry 保存所有已注册的上游适配器。 +// 适配器在自己的 init() 里调用 Register;主干只通过 Get/Names 访问。 +var registry = map[string]Provider{} + +// Register 注册一个上游适配器。通常在适配器包的 init() 里调用。 +// 重名注册会 panic —— 说明有重名 bug,应立即暴露。 +func Register(p Provider) { + name := p.Name() + if _, ok := registry[name]; ok { + panic("provider: duplicate registration: " + name) + } + registry[name] = p +} + +// Get 按名字取已注册的上游适配器;不存在返回 false。 +func Get(name string) (Provider, bool) { + p, ok := registry[name] + return p, ok +} + +// Names 返回所有已注册适配器的名字(排序稳定),供 admin UI 下拉框使用。 +func Names() []string { + names := make([]string, 0, len(registry)) + for name := range registry { + names = append(names, name) + } + sort.Strings(names) + return names +} diff --git a/adapter/volcano/errors.go b/adapter/volcano/errors.go index 1506d9e..5e75a28 100644 --- a/adapter/volcano/errors.go +++ b/adapter/volcano/errors.go @@ -1,27 +1,9 @@ package volcano -import "fmt" +import "github.com/volcano-tts/tts-api/adapter/provider" -// UpstreamError 表示火山 v3 返回的 业务错误(code != 0 且 != 20000000)或传输错误。 -// 包含上游错误码,便于 telemetry 把它作为 label。 -type UpstreamError struct { - Code int - Message string - Stage string // "request"/"stream"/"http" - 出错阶段 - Wrapped error -} - -func (e *UpstreamError) Error() string { - if e.Wrapped != nil { - return fmt.Sprintf("volcano %s: code=%d %s: %v", e.Stage, e.Code, e.Message, e.Wrapped) - } - return fmt.Sprintf("volcano %s: code=%d %s", e.Stage, e.Code, e.Message) -} - -func (e *UpstreamError) Unwrap() error { return e.Wrapped } - -// IsAuth 当上游返回认证/权限类错误时返回 true。 -func (e *UpstreamError) IsAuth() bool { - return e.Code == 45000000 || e.Code == 55000000 || - e.Code == 401 || e.Code == 403 -} +// UpstreamError 是 provider.UpstreamError 的别名。 +// +// 通用错误类型已上移到 provider 包,这样主干(controller)只需 import provider 即可 +// 判断上游错误,不必依赖具体适配器。火山包内部沿用旧名,构造处零改动。 +type UpstreamError = provider.UpstreamError diff --git a/adapter/volcano/provider.go b/adapter/volcano/provider.go new file mode 100644 index 0000000..c9406cd --- /dev/null +++ b/adapter/volcano/provider.go @@ -0,0 +1,128 @@ +package volcano + +import ( + "context" + "encoding/json" + "strconv" + + "github.com/volcano-tts/tts-api/adapter/provider" + "github.com/volcano-tts/tts-api/dto" +) + +// Provider 是火山 v3 TTS 的 provider.Provider 实现。 +// 内部持有复用的 HTTPClient(keep-alive)。 +type Provider struct { + client *HTTPClient +} + +// New 构造火山 provider。在 init() 里注册,主干经 provider.Get("volcano") 取用。 +func New() *Provider { + return &Provider{client: NewHTTPClient()} +} + +// Name 实现 provider.Provider。 +func (p *Provider) Name() string { return "volcano" } + +// Capabilities 实现 provider.Provider。 +// 火山支持多音色、倍率语速、字符计费,并需要 resource_id 额外维度。 +func (p *Provider) Capabilities() provider.Capabilities { + return provider.Capabilities{ + Formats: []string{"mp3", "wav", "pcm", "ogg_opus", "aac", "flac"}, + Speed: true, + Usage: true, + Voices: true, + ExtraScopes: true, + } +} + +// Synthesize 实现 provider.Provider:从归一化 Request 还原火山 Options 后执行合成。 +// 行为与原 Synthesis(controller 直接调用)完全一致,仅入口形态变化。 +func (p *Provider) Synthesize(ctx context.Context, req provider.Request, mtr provider.MetricsRecorder) (*dto.SynthesisResult, error) { + if mtr == nil { + mtr = provider.NoopMetrics() + } + opts := optionsFromRequest(req) + return synthesize(ctx, p.client, opts, req.Format, mtr) +} + +// optionsFromRequest 把 provider.Request 还原成火山 Options。 +// 厂商私有参数从 Credentials.Scope(resource_id)与 Request.Extra 取回,与 BuildRequest 对称。 +func optionsFromRequest(req provider.Request) Options { + opts := Options{ + APIKey: req.Credentials.APIKey, + ResourceID: req.Credentials.Scope["resource_id"], + Text: req.Text, + Speaker: req.VoiceKey, + Model: req.Model, + Format: req.Format, + SampleRate: req.SampleRate, + SpeechRate: convertSpeedToSpeechRate(req.Speed), + UID: "uid", + } + if v := req.Extra["uid"]; v != "" { + opts.UID = v + } + if v := req.Extra["bit_rate"]; v != "" { + if n, err := strconv.Atoi(v); err == nil { + opts.BitRate = n + } + } + if v := req.Extra["loudness_rate"]; v != "" { + if n, err := strconv.Atoi(v); err == nil { + opts.LoudnessRate = n + } + } + opts.EnableSubtitle = req.Extra["enable_subtitle"] == "1" + opts.EnableTimestamp = req.Extra["enable_timestamp"] == "1" + if v := req.Extra["additions"]; v != "" { + var adds Additions + if err := json.Unmarshal([]byte(v), &adds); err == nil { + opts.Additions = &adds + } + } + return opts +} + +// BuildRequest 把火山 Options 映射成归一化 provider.Request。 +// 阶段0使用:setting 继续构造 volcano.Options,再经此函数转成 provider.Request 交给主干, +// 从而让 controller 不再依赖具体实现。 +// 阶段2(配置命名空间化)后,此函数被"直接构造 provider.Request"取代。 +func BuildRequest(opts Options) provider.Request { + req := provider.Request{ + Text: opts.Text, + VoiceKey: opts.Speaker, + Model: opts.Model, + SampleRate: opts.SampleRate, + Credentials: provider.Credentials{ + APIKey: opts.APIKey, + Scope: map[string]string{"resource_id": opts.ResourceID}, + }, + Extra: map[string]string{}, + } + if opts.UID != "" && opts.UID != "uid" { + req.Extra["uid"] = opts.UID + } + if opts.BitRate != 0 { + req.Extra["bit_rate"] = strconv.Itoa(opts.BitRate) + } + if opts.LoudnessRate != 0 { + req.Extra["loudness_rate"] = strconv.Itoa(opts.LoudnessRate) + } + if opts.EnableSubtitle { + req.Extra["enable_subtitle"] = "1" + } + if opts.EnableTimestamp { + req.Extra["enable_timestamp"] = "1" + } + if opts.Additions != nil && !opts.Additions.IsZero() { + if b, err := json.Marshal(opts.Additions); err == nil { + req.Extra["additions"] = string(b) + } + } + return req +} + +// init 注册火山 provider。主干经 provider.Get("volcano") 取用,新增上游不改主干。 +func init() { + provider.Register(New()) +} diff --git a/adapter/volcano/synthesis.go b/adapter/volcano/synthesis.go index 4eece0f..90c0f7d 100644 --- a/adapter/volcano/synthesis.go +++ b/adapter/volcano/synthesis.go @@ -9,53 +9,27 @@ import ( "strings" "time" + "github.com/volcano-tts/tts-api/adapter/provider" "github.com/volcano-tts/tts-api/common" "github.com/volcano-tts/tts-api/dto" "github.com/volcano-tts/tts-api/telemetry" ) -// MetricsRecorder 是适配器向上报告埋点的接口。 -// 适配器本身不依赖 telemetry 包,controller 在 main 启动时把 Meter 适配成实现; -// 这样测试可以注入 mock,生产可以无侵入替换成 OTel。 -type MetricsRecorder interface { - UpstreamStarted(speaker, model, format string) - UpstreamFinished(speaker, model, format, status string, duration, ttfb time.Duration, chunks, audioBytes, errCode int) - UpstreamUsage(model string, textWords int) -} - -// nopMetrics 是 MetricsRecorder 的 no-op 默认值。 -type nopMetrics struct{} - -func (nopMetrics) UpstreamStarted(string, string, string) {} -func (nopMetrics) UpstreamFinished(string, string, string, string, time.Duration, time.Duration, int, int, int) { -} -func (nopMetrics) UpstreamUsage(string, int) {} - -// Synthesis 调用火山 v3 一次,返回组装好的结果。 +// synthesize 调用火山 v3 一次,返回组装好的结果(包内私有,由 provider.Provider.Synthesize 调用)。 // // 入参: // - ctx:超时控制 // - client:复用的 HTTPClient -// - opts:从 setting 构造的完整参数(text 字段会被 text 覆盖) -// - text:本次合成的实际文本 +// - opts:已还原好的完整参数(Text / SpeechRate 已由调用方填入) // - clientFormat:客户端期望的最终格式,"wav" 内部转 pcm 后本地拼 wav 头 -// - speed:OpenAI 风格的 speed(倍率,0.5~2.0) -// - mtr:可选埋点;传 nil 等价于 nopMetrics -func Synthesis( +// - mtr:埋点;调用方负责保证非 nil +func synthesize( ctx context.Context, client *HTTPClient, opts Options, - text string, clientFormat string, - speed float64, - mtr MetricsRecorder, + mtr provider.MetricsRecorder, ) (*dto.SynthesisResult, error) { - if mtr == nil { - mtr = nopMetrics{} - } - opts.Text = text - opts.SpeechRate = convertSpeedToSpeechRate(speed) - reqID := newRequestID() upstreamFormat := resolveUpstreamFormat(clientFormat) @@ -145,7 +119,7 @@ func Synthesis( mtr.UpstreamFinished(opts.Speaker, opts.Model, opts.Format, "ok", duration, parsed.FirstChunk, parsed.Chunks, len(finalData), 0) log.Printf("TTS 合成成功 - 音色=%s 格式=%s 文本=%d字 音频=%d字节 分片=%d 耗时=%v", - telemetry.MaskSpeaker(opts.Speaker), clientFormat, len(text), len(finalData), parsed.Chunks, duration) + telemetry.MaskSpeaker(opts.Speaker), clientFormat, len(opts.Text), len(finalData), parsed.Chunks, duration) return &dto.SynthesisResult{ AudioData: finalData, diff --git a/controller/tts.go b/controller/tts.go index eb00c6f..5f89328 100644 --- a/controller/tts.go +++ b/controller/tts.go @@ -11,7 +11,7 @@ import ( "strings" "time" - "github.com/volcano-tts/tts-api/adapter/volcano" + "github.com/volcano-tts/tts-api/adapter/provider" "github.com/volcano-tts/tts-api/common" "github.com/volcano-tts/tts-api/dto" "github.com/volcano-tts/tts-api/installer" @@ -24,12 +24,12 @@ import ( ) var ( - volcanoClient *volcano.HTTPClient - adapterRec volcano.MetricsRecorder = metrics.AdapterRecorder{} + adapterRec provider.MetricsRecorder = metrics.AdapterRecorder{} ) func InitController() { - volcanoClient = volcano.NewHTTPClient() + // 上游 provider 在各自包 init() 里已注册(volcano 等),经 provider.Get 取用。 + // 本函数保留以维持 main.go 的启动调用序列;不再持有具体 client。 } func truncateForLog(b []byte, max int) string { @@ -49,7 +49,7 @@ func resolveClientFormat(reqFmt string) string { } return strings.ToLower(reqFmt) } - return setting.GetTTSOptions().Format + return setting.GetDefaultFormat() } // OpenaiTTSHandler 是 /v1/audio/speech 的入口。 @@ -151,15 +151,15 @@ func OpenaiTTSHandler(w http.ResponseWriter, r *http.Request) { clientFormat := resolveClientFormat(req.ResponseFormat) - opts := setting.GetTTSOptions() - opts.Text = req.Input + ttsReq := setting.GetTTSRequest() + ttsReq.Text = req.Input + ttsReq.Format = clientFormat + ttsReq.Speed = speed // M3: voice 路由 - // - voice 为空 → 走 LoadRuntimeConfig 解析过的 opts.Speaker (已是真 speaker ID, - // default_speaker 是 voice 名,LoadRuntimeConfig 查 voice 表后替换) - // - voice 非空 → 查 voices 表,替换 opts.Speaker / ResourceID / Model - // - 命中但 enabled=0 → 仍可用(用户显式传 voice 即覆盖 enabled 状态;若想禁用在 admin UI 关掉就行) - // - 未命中 → 400 "unknown voice: " + // - voice 为空 → 走 LoadRuntimeConfig 解析过的默认音色(GetTTSRequest 已带) + // - voice 非空 → 查 voices 表,替换 VoiceKey / resource_id / Model + // - 命中但 enabled=0 → 拒绝;未命中 → 400 "unknown voice: " if req.Voice != "" { s := GetAdminStore() if s == nil { @@ -183,7 +183,7 @@ func OpenaiTTSHandler(w http.ResponseWriter, r *http.Request) { "voice lookup failed", "server_error", "db_read_failed") return } - // 覆盖 opts(API key / UID 保留自 setting.GetTTSOptions 快照) + // 覆盖音色与厂商凭证(API key / 其它私有参数保留自 GetTTSRequest 快照) if !v.Enabled { log.Printf("警告: voice=%q 已禁用 - 客户端=%s", req.Voice, middleware.GetClientIP(r)) middleware.SendJSONError(w, http.StatusForbidden, @@ -191,10 +191,10 @@ func OpenaiTTSHandler(w http.ResponseWriter, r *http.Request) { "invalid_request_error", "voice_disabled") return } - opts.Speaker = v.Speaker - opts.ResourceID = v.ResourceID + ttsReq.VoiceKey = v.Speaker + ttsReq.Credentials.Scope["resource_id"] = v.ResourceID if v.Model != "" { - opts.Model = v.Model + ttsReq.Model = v.Model } log.Printf("[tts] voice=%s 命中 (speaker=%s resource=%s model=%s) - 客户端=%s", req.Voice, telemetry.MaskSpeaker(v.Speaker), telemetry.MaskResourceID(v.ResourceID), v.Model, middleware.GetClientIP(r)) @@ -203,7 +203,14 @@ func OpenaiTTSHandler(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), setting.GetTTSTimeout()) defer cancel() - result, err := volcano.Synthesis(ctx, volcanoClient, opts, req.Input, clientFormat, speed, adapterRec) + prov, ok := provider.Get(setting.GetDefaultProviderName()) + if !ok { + log.Printf("警告: 未注册上游适配器=%s - 客户端=%s", setting.GetDefaultProviderName(), middleware.GetClientIP(r)) + middleware.SendJSONError(w, http.StatusServiceUnavailable, + "no upstream adapter registered", "configuration_error", "provider_unavailable") + return + } + result, err := prov.Synthesize(ctx, ttsReq, adapterRec) duration := time.Since(start) finalLabels := telemetry.Labels{ @@ -211,8 +218,8 @@ func OpenaiTTSHandler(w http.ResponseWriter, r *http.Request) { // speaker 是火山复刻音色 ID(用户付费资产),不能直接出现在 /metrics label 里 //(无鉴权可枚举)。用 sha1[:8] 替代:同 speaker 同 label 保留 per-voice 观测, //但反推不出原值。Admin UI 想要看原名通过 /api/voices 拿 name 字段。 - "speaker": telemetry.SpeakerLabel(opts.Speaker), - "model": opts.Model, + "speaker": telemetry.SpeakerLabel(ttsReq.VoiceKey), + "model": ttsReq.Model, } if err != nil { finalLabels["status"] = classifyStatus(err) @@ -240,7 +247,7 @@ func OpenaiTTSHandler(w http.ResponseWriter, r *http.Request) { } func classifyStatus(err error) string { - if ue, ok := err.(*volcano.UpstreamError); ok { + if ue, ok := err.(*provider.UpstreamError); ok { switch ue.Stage { case "request": return "request_error" diff --git a/docs/UPSTREAM_ADAPTER_GUIDE.md b/docs/UPSTREAM_ADAPTER_GUIDE.md index 6307427..a913ec6 100644 --- a/docs/UPSTREAM_ADAPTER_GUIDE.md +++ b/docs/UPSTREAM_ADAPTER_GUIDE.md @@ -5,9 +5,10 @@ > > ⚠️ **本文档分两部分,请先看清区别**: > **第一部分「现状」描述的是代码里已经存在的东西**,可以直接照着读代码。 -> **第二部分「目标架构」是待实施的设计**,代码里**还不存在** —— -> 里面的接口、表结构、目录都是提案,需要先按 §4 的分阶段计划落地。 -> 不要把第二部分的接口名当成现有 API 去调用。 +> **第二部分「目标架构」分阶段落地**。§3 的 Provider 抽象与 §4 的**阶段 0 已完成落地** +> (2026-10-11),代码里**已有** `adapter/provider` 包;§4 的**阶段 1~5**(库表、配置命名空间、 +> metrics 加 provider 维度、管理界面、第二个适配器)仍是**待实施**的提案。 +> 读接口时以 `adapter/provider/*.go` 为准,本文 §3 是它的文字说明。 --- @@ -143,14 +144,17 @@ type SynthesisResult struct { --- -## 3. 目标架构:Provider 抽象(待实施) +## 3. 目标架构:Provider 抽象(阶段 0 已落地) -以下代码**当前不存在**,是建议落地的设计。 +以下代码已随阶段 0 落地,见 `adapter/provider/provider.go` 与 `adapter/provider/registry.go`。 +新增上游(阶段 5)将严格按此接口实现。 ### 3.1 接口定义 -建议新增 `adapter/provider/provider.go`(新包,**不放在** `adapter/volcano` 里, -避免主干继续依赖具体实现): +落地于 `adapter/provider/provider.go`(独立包,**不放在** `adapter/volcano` 里, +避免主干继续依赖具体实现)。**注**:阶段 0 还顺带把通用上游错误 `UpstreamError` +一并迁入 `provider` 包,火山适配器通过类型别名 `type UpstreamError = provider.UpstreamError` +沿用旧名(见 `adapter/volcano/errors.go`),controller 判断上游错误不再依赖具体实现。 ```go // Provider 是一个上游 TTS 服务的统一抽象。 @@ -235,21 +239,30 @@ import ( **强烈建议按阶段做,每阶段结束都能编译、能跑、行为不变。** 一次性重构"配置 + 路由 + 库表 + 前端 + metrics"五个面,几乎必然做出半成品。 -### 阶段 0 · 抽出接口,零行为变更(纯重构) +### 阶段 0 · 抽出接口,零行为变更(纯重构) — ✅ 已完成 2026-10-11 目标:主干不再依赖具体实现,但**行为完全不变**,回归风险最低。 -- [ ] 新增 `adapter/provider` 包,放 `Provider` / `Capabilities` / `Request` / `Credentials` / `MetricsRecorder` -- [ ] 让 `volcano` 包实现 `Provider`(本质是把 `Options` 包一层适配,`Synthesis` 改成方法) -- [ ] `controller/tts.go`:把 `volcanoClient` + `volcano.Synthesis` 换成 `provider.Get(name).Synthesize` -- [ ] `MetricsRecorder` 从 `adapter/volcano` **移出**到 `provider` 包(否则接口还是火山专属) -- [ ] `setting.LoadRuntimeConfig` 暂时继续构造 `volcano.Options`,通过一个 - 「volcano provider 的配置映射函数」转成 `provider.Request` -- [ ] 验收:`go build ./... && go vet ./... && go test ./... -count=1` 全绿, - 且 `/v1/audio/speech` 行为与重构前逐字节一致(拿同一段文本对比音频长度与格式) +- [x] 新增 `adapter/provider` 包,放 `Provider` / `Capabilities` / `Request` / `Credentials` / `MetricsRecorder` +- [x] 让 `volcano` 包实现 `Provider`(`adapter/volcano/provider.go`;`Synthesis` 收敛为包内私有 + `synthesize`,由 `Provider.Synthesize` 从 `provider.Request` 还原 Options 后调用; + 另提供 `BuildRequest(Options) provider.Request` 供 setting 过渡期映射) +- [x] `controller/tts.go`:去掉 `volcano` import,`volcanoClient`/`volcano.Synthesis` 换成 + `provider.Get(setting.GetDefaultProviderName()).Synthesize`;voice 路由改在 + `provider.Request` 上覆盖(VoiceKey / Credentials.Scope["resource_id"] / Model) +- [x] `MetricsRecorder` 从 `adapter/volcano` 移出到 `provider` 包;`metrics.AdapterRecorder` + 加编译期断言 `var _ provider.MetricsRecorder = AdapterRecorder{}` +- [x] `setting` 新增 `GetTTSRequest()`(内部仍构造 `volcano.Options`,经 `volcano.BuildRequest` + 映射成 `provider.Request`)、`GetDefaultFormat()`、`GetDefaultProviderName()`(阶段 0 恒 "volcano") +- [x] `UpstreamError` 迁入 `provider` 包;火山用 `type UpstreamError = provider.UpstreamError` + 别名沿用旧名,构造处零改动 +- [x] `main.go` 显式空导入 `_ "github.com/volcano-tts/tts-api/adapter/volcano"` 触发注册 +- [x] 验收通过:`go build ./... && go vet ./...` 全绿;服务冒烟启动正常 + (`/healthz`=ok、`/setup`=200、未安装时 `/v1/audio/speech`=503) + —— 端到端逐字节对比需真实火山 key,未在本阶段执行(无 key) -> 这一阶段**不要**动库表、不要动前端。目标只有一个:主干里那行 -> `import adapter/volcano` 消失。 +> 本阶段**未**动库表、未动前端。验收目标达成:`controller` 里那行 +> `import adapter/volcano` 已消失;仅 `setting`(阶段 2 消除)与 `main.go` 空导入仍提及。 ### 阶段 1 · 数据结构:provider 维度入库 @@ -485,9 +498,11 @@ GetVoiceForTTS(name string) (providerName, speakerID, model string, vendorParams | 音色模型 | `store/voices.go` | `Voice`、`GetVoiceForTTS` | | 指标 | `metrics/metrics.go` | `AdapterRecorder`、`codeLabel` | | 结果类型 | `dto/tts.go` | `SynthesisResult`、`SubtitleEntry` | -| 未来抽象落点 | `adapter/provider/` | **尚不存在,待阶段 0 创建** | +| 上游抽象(阶段 0 已落地) | `adapter/provider/` | `provider.go`(Provider/Capabilities/Request/Credentials/MetricsRecorder/UpstreamError)、`registry.go`(Register/Get/Names) | +| 火山适配器实现 | `adapter/volcano/provider.go` | 实现 `provider.Provider`;`BuildRequest` 供 setting 过渡期映射;`init()` 注册 | --- -*文档版本:v1 · 对应代码基线:`develop` @ `ad342ec`(2026-10-03)* +*文档版本:v1.1 · 对应代码基线:`develop` @ `ad342ec`(2026-10-03)* +*v1.1(2026-10-11):§3 Provider 抽象与 §4 阶段 0 已落地,更新为现状;阶段 1~5 仍为提案。* *本文「现状」部分与代码逐行核对过;「目标架构」为提案,实施时请同步更新本文档。* diff --git a/main.go b/main.go index df1bde4..8d39678 100644 --- a/main.go +++ b/main.go @@ -10,6 +10,8 @@ import ( "time" "github.com/volcano-tts/tts-api/controller" + // 空导入注册上游适配器:新增上游只需在此加一行,主干零改动。 + _ "github.com/volcano-tts/tts-api/adapter/volcano" "github.com/volcano-tts/tts-api/installer" "github.com/volcano-tts/tts-api/metrics" "github.com/volcano-tts/tts-api/middleware" diff --git a/metrics/metrics.go b/metrics/metrics.go index 35828cb..516987c 100644 --- a/metrics/metrics.go +++ b/metrics/metrics.go @@ -9,6 +9,7 @@ package metrics import ( "time" + "github.com/volcano-tts/tts-api/adapter/provider" "github.com/volcano-tts/tts-api/telemetry" ) @@ -117,17 +118,21 @@ func Init() { ) } -// AdapterRecorder 把 telemetry 指标适配为 volcano.MetricsRecorder。 +// AdapterRecorder 把 telemetry 指标适配为 provider.MetricsRecorder。 +// 上游适配器只需实现 provider.MetricsRecorder,metrics 包统一汇聚。 type AdapterRecorder struct{} -// UpstreamStarted 满足 volcano.MetricsRecorder 接口。 +// 编译期断言:AdapterRecorder 必须实现 provider.MetricsRecorder,防止接口漂移。 +var _ provider.MetricsRecorder = AdapterRecorder{} + +// UpstreamStarted 满足 provider.MetricsRecorder 接口。 func (AdapterRecorder) UpstreamStarted(speaker, model, format string) { // speaker 用 sha1[:8] 替代,保护火山复刻音色 ID // (无鉴权 /metrics 端点可枚举,这是 P0 隐私问题) UpstreamTotal.Inc(telemetry.Labels{"status": "started", "format": format, "model": model, "speaker": telemetry.SpeakerLabel(speaker)}) } -// UpstreamFinished 满足 volcano.MetricsRecorder 接口。 +// UpstreamFinished 满足 provider.MetricsRecorder 接口。 func (AdapterRecorder) UpstreamFinished(speaker, model, format, status string, duration, ttfb time.Duration, chunks, audioBytes, errCode int) { labels := telemetry.Labels{"status": status, "format": format, "model": model, "speaker": telemetry.SpeakerLabel(speaker)} UpstreamTotal.Inc(labels) @@ -149,7 +154,7 @@ func (AdapterRecorder) UpstreamFinished(speaker, model, format, status string, d } } -// UpstreamUsage 满足 volcano.MetricsRecorder 接口。 +// UpstreamUsage 满足 provider.MetricsRecorder 接口。 func (AdapterRecorder) UpstreamUsage(model string, textWords int) { if textWords <= 0 { return diff --git a/setting/config.go b/setting/config.go index d76fcfb..c170e6e 100644 --- a/setting/config.go +++ b/setting/config.go @@ -11,6 +11,7 @@ import ( "sync" "time" + "github.com/volcano-tts/tts-api/adapter/provider" "github.com/volcano-tts/tts-api/adapter/volcano" "github.com/volcano-tts/tts-api/common" "github.com/volcano-tts/tts-api/telemetry" @@ -55,6 +56,27 @@ func GetTTSOptions() volcano.Options { return ttsOptions } +// GetTTSRequest 返回运行时 TTS 配置映射成的 provider.Request 快照。 +// 阶段0过渡:setting 内部仍构造 volcano.Options,经 volcano.BuildRequest 映射成 +// provider.Request 交给主干;controller 拿到后按本次请求填 Text/Format/Speed,并做 voice 路由覆盖。 +// 阶段2(配置命名空间化)后,此处改为直接构造 provider.Request,不再触碰火山类型。 +func GetTTSRequest() provider.Request { + ttsMu.RLock() + defer ttsMu.RUnlock() + return volcano.BuildRequest(ttsOptions) +} + +// GetDefaultFormat 返回默认输出格式(客户端未指定 response_format 时回退用)。 +func GetDefaultFormat() string { + ttsMu.RLock() + defer ttsMu.RUnlock() + return ttsOptions.Format +} + +// GetDefaultProviderName 返回当前默认上游适配器名。 +// 阶段0:仅火山,恒为 "volcano";阶段2 后改从库表 default_provider 读。 +func GetDefaultProviderName() string { return "volcano" } + // SetTTSOptions 整体替换 TTSOptions。LoadRuntimeConfig 写路径专用。 func SetTTSOptions(o volcano.Options) { ttsMu.Lock()