14 Commits
Author SHA1 Message Date
tts-stage1 b8fee37078 fix(route): 不在 router 覆盖 template.VoiceKey,避免对外 voice 名误透传给上游
bug 描述:
  原 router.go:169 写 req.VoiceKey = voice,把"对外 voice 名"(OpenAI 风格
  字符串,如 "chun"/"alloy")覆盖到 template.VoiceKey,导致上游(provider)
  拿到的 VoiceKey 是对外字符串而非上游音色 ID(如 "BV001_streaming")。
  火山侧 optionsFromRequest (adapter/volcano/provider.go:55) 直接把
  VoiceKey 当 speaker 塞进 HTTP body,结果 speaker="chun" 在火山查不到
  -> 55000000。

修法:
  - router 只换"账号身份"(Credentials),其它字段(尤其 VoiceKey)由
    controller 维护;文档 §5 的设计本就是这样,代码是实现走样
  - 删 router.go:169 那行,加注释明确"不动 template.VoiceKey"
  - 同步修 SelectAndSynthesize 函数注释,把"调用方契约"写清楚:
      * voice (第 2 参数) = 对外 voice 名,用于过滤 Channel.Voices 白名单
      * template.VoiceKey 必须是上游 speaker ID,已由 controller 解析 voices 表填好
      * router 不修改 template 任何字段,只换 Credentials

回归保护(本地测试,不入库):
  - 新增 TestSelectAndSynthesize_VoiceKey_NotOverwrittenByExternalName:
    模拟完整链路(对外 voice="chun" + 模板 VoiceKey="BV001_streaming"),
    断言 router 调用 Synthesize 时 req.VoiceKey 仍是 "BV001_streaming"
  - 顺手重构 mockProvider:加 lastVoiceKey + failOn 字段,清理 FirstFailsSecondSucceeds
    测试的随机性陷阱(pickByWeight 是随机的,不应固定 chosen ID)
  - 全包 16 个测试通过;go build/vet/test 全绿

影响:
  - 客户端语义不变(对外 voice 名仍按 Channel.Voices 白名单过滤)
  - controller 行为不变(它原本就在 voice 路由后把 v.Speaker 填到 ttsReq.VoiceKey)
  - 上游调用正确(从错误地传对外字符串 -> 正确地传上游 speaker ID)
2026-10-11 16:27:18 +08:00
tts-stage1 a614ab55c9 feat(route): 阶段 1 多渠道路由分发层 (Channel + Router)
- store: 新增 channels 表 (name unique, credentials_json, voices CSV,
  priority, weight, status, auto_ban) + CRUD (List/Get/Insert/SetStatus/Delete)
  + ErrChannelDuplicate 区分 (避免与 ErrDuplicate 互相误报)
- store/db.go: schemaVersion 1 -> 2; channels 表 CREATE IF NOT EXISTS + 复合索引
- adapter/route: 新包,Channel (含 BuildRequest 覆盖 template.Credentials)
  + Router (Select / SelectAndSynthesize / pickByWeight 纯函数)
  + 错误区分 ErrNoChannels (走兜底) vs ErrVoiceNotFound (400)
  + AggregateError 聚合多渠道失败, 供 controller 日志逐个打印
- controller/router.go: SetRouter/GetRouter 句柄 (与 SetAdminStore 同一模式)
- controller/tts.go: 接入 Router, 零 channel 走 setting 兜底 (行为=阶段 0);
  voice 不被任何 channel 接受 -> 400 + voice_not_found; 抽出 finalizeSynth
  收敛合成结果->响应+metrics+日志三件套
- main.go: 启动期从 store 加载 channels, 转 route.Channel, 注入 controller;
  credentials_json 加载失败 -> fail-fast (避免一个错渠道拖崩全部请求)
- .gitignore: 补 .gotmp/ .gomodcache/ .dsh-acl/

验收 (设计文档 §9):
  - 零 channel 兜底, 行为=阶段 0
  - 加 1 个 channel: 走该渠道
  - 加 2+ 个同档不同 weight: pickByWeight weight-1-to-3 分布 ±5% (单测覆盖)
  - 不同 priority: 高优先级优先 (单测覆盖)
  - disabled 不参与选择 (单测覆盖)
  - 失败降级: 同档换下一个 -> 降档, 全失败返 AggregateError (单测覆盖)
  - go build ./... + go vet ./... 全绿
  - go test ./... (adapter/route 15 个, store 2 个, 全部通过)
2026-10-11 15:33:22 +08:00
dev 5c130f0be4 refactor(adapter): 抽出 provider 抽象层(阶段0),controller 不再依赖火山实现
- 新增 adapter/provider:Provider/Capabilities/Request/Credentials/MetricsRecorder/UpstreamError + 注册表
- 火山实现 Provider(provider.go),Synthesis 收敛为私有 synthesize,提供 BuildRequest 过渡映射
- controller 经 provider.Get(name).Synthesize 调用,voice 路由改在 Request 上覆盖
- UpstreamError 上移到 provider 包,火山用类型别名沿用旧名
- setting 新增 GetTTSRequest/GetDefaultFormat/GetDefaultProviderName
- main.go 空导入注册火山
- docs/UPSTREAM_ADAPTER_GUIDE.md 更新为 v1.1(阶段0已落地)
2026-10-11 13:39:09 +08:00
sun ec82e895c3 chore(ci): 删除 ci.yml workflow
该 workflow 没有实际价值:

- 仓库按策略不提供测试源码(*_test.go 被 .gitignore 屏蔽),因此其中的
  go test 步骤在干净克隆上只是空转(打印 no test files 后通过),它连
  "测试是否存在"都不会告诉你,自然守不住行为回归。
- 剩下的 go build / go vet 收益也很有限 —— 真正会用到编译产物的 docker.yml
  在发版打 tag 时本就会完整构建一次。

删除后仓库只保留 docker.yml(打 tag 时构建并推送镜像),不再有多余的自动化。

同步清理对它的引用:
- CHANGELOG 的"新增 CI 校验"条目(改述为"没有自动化质量门")
- .gitignore 里测试策略注释中指向 ci.yml 的那句
- docs/UPSTREAM_ADAPTER_GUIDE.md 第 7 节(原表述"CI 跑不到你的测试"已不准确,
  改为"没有任何自动化会跑你的测试")
2026-10-04 16:26:32 +08:00
sun 96a525446b docs(changelog): 补 v0.2.4 正式版本段
CI / build-and-test (push) Canceled after 0s
Docker Publish / build-and-push (push) Canceled after 0s
原 CHANGELOG 只有 [未发布]/0.2.0/0.1.0,而 v0.2.1、v0.2.2、v0.2.3 三个已发布版本均无条目。本次先补齐 v0.2.4 的正式版本段(含版本号与破坏性变更说明),v0.2.1~v0.2.3 的历史缺目留待后续补。

注意:本次改动在 tag v0.2.4 之后提交,因此 v0.2.4 的镜像产物中不含此 CHANGELOG 条目。
2026-10-04 15:21:53 +08:00
sun f7c7e6ee3a docs: v0.3.0 阶段三 文档、示例配置与发布说明
CI / build-and-test (push) Canceled after 0s
Docker Publish / build-and-push (push) Canceled after 0s
文档与配置同步 v0.3.0 的破坏性变更:

README.md:
- 特性列表更新:管理入口 /dashboard、凭证分离、默认无匿名监控端点
- 环境变量表补充 METRICS_ALLOW_CIDR,并说明未配任何管理凭证时服务拒绝启动
- 端点文档重写:/healthz(匿名且不含字段)、/health(需鉴权)、
  /metrics(默认 404,两种使用方式)、管理页面与安装页对照表
  (含"页面需鉴权为何又能直接打开"的内容协商说明)
- 公网部署安全清单重写:按端点列出真实暴露面,并提醒探针必须指向 /healthz
- 故障排查、架构表、迁移表中的 /admin 引用统一改为 /dashboard

.env.example:新增 METRICS_ALLOW_CIDR 与 admin_key 说明(含 Docker 网段警告)。

两处部署陷阱修复(阶段一给 /health 加鉴权引发的连带问题):
- docker-compose.yml 的 healthcheck 原指向 /health,加鉴权后会一律 401,
  导致容器被判定为 unhealthy → 改为 /healthz
- Dockerfile 的 HEALTHCHECK 同样指向 /health → 改为 /healthz
  (这两处若不改,升级后容器健康状态会持续异常,且现象与鉴权改动看起来无关,排查成本高)

新增 docs/RELEASE_NOTES_v0.3.0.md:可直接用于创建 Release,
含 Breaking Change 清单、新特性、验证方式与实测结果、升级步骤。

docs/IMPLEMENT_v0.3.0.md:
- 按合并后的阶段编号重写实施进度表与阶段标题
  (原「阶段 1 收紧鉴权」与「阶段 2 收口端点」合并为「阶段 1」,后续顺延),
  并说明合并原因:两者只做前者是"有风险无收益"
- 记录本轮实际验证结果

验证:go build / go vet / go test ./... -count=1 全绿。
2026-10-04 01:17:43 +08:00
sun 7b8f557f54 feat(router): v0.3.0 阶段二 管理入口迁移与前端同步
管理入口从 /admin 迁至 /dashboard:
- /dashboard、/dashboard/voices、/dashboard/settings 为新入口
- 原挂在 /dashboard 上的服务状态预览页(health.html)迁至 /dashboard/status,
  侧边栏新增「观测 → 详细状态」入口
- /admin 及子路径 301 永久重定向到对应新路径(页面用 301 是正确的)
- 静态资源同时注册 /dashboard/ 与 /admin/ 两套路径,
  避免过渡期出现"页面能开但丢样式"

前端同步:
- admin-shell.js:侧边栏导航、登出、登录跳转改指 /dashboard/*
- admin.html / admin-login.html / admin-voices.html / admin-settings.html:
  静态资源引用与登录跳转改指 /dashboard/*
- health.html:改用 admin-shell 的 http 实例并改调 /admin/health、/admin/metrics
  (该实例自动附加 Authorization: Bearer;原实现用裸 axios,不带凭证),
  并加登录校验与「返回控制台」入口
- setup.html:安装完成跳转目标改为 /dashboard,页面内 /admin 文案同步更新
- 各页面的 API 调用路径无需修改:axios baseURL 为 /api,
  故 /voices、/admin/overview 等自动落成 /api/admin/*

controller/setup.go:安装成功响应的 redirect 字段由 /admin 改为 /dashboard。

验证:go build / go vet / go test ./... -count=1 全绿;
另用真实服务器端到端验证:旧入口 301 全部正确、静态资源新旧路径均 200、
管理页面内容协商(html 200 / 非 html 401)、登录页公开、
各页面引用的静态资源均为新路径且无残留旧路径。
2026-10-04 01:17:26 +08:00
sun c18cf63518 feat(auth)!: v0.3.0 阶段一 鉴权收紧 + 健康/指标端点收口
本提交由初版方案的「阶段 1」与「阶段 2」合并而成。合并原因:两者是同一件事的两半——
只收紧鉴权而不收口端点,结果是"有风险无收益"(未配凭证的旧部署可能起不来,
而 /health、/dashboard、/metrics 仍匿名可读)。

一、鉴权收紧(原阶段 1)
- RequireAdmin 在凭证列表为空时不再放行。此前 len(keys)==0 直接 next.ServeHTTP,
  导致未配凭证的部署上管理接口完全裸奔,也让后续给端点加鉴权的加固形同虚设。
  现在该情况返回 401 并记录明确日志。
- 新增可选独立管理凭证 admin_key,取值优先级:
  admin_key(DB) > auth_key(DB) > OPENAI_TTS_API_KEY(env)。
  middleware.ValidateAPIKey(业务侧 /v1/audio/speech)保持只看 auth_key,
  于是配置 admin_key 后业务调用方持有的 key 无法访问管理接口,权限隔离成立;
  不配置则回退 auth_key,老部署行为不变。
  新增 PUT /api/admin/settings/admin-key 与旧前缀别名,
  凭证只写不读(GET 仅返回打码值与 admin_key_set / admin_key_source)。
- normal 模式下管理凭证为空时启动期 fail-fast。RequireAdmin 改为拒绝后,
  若此处不拦,服务会正常起来但 /dashboard 与全部 /api/admin/* 都是 401,
  等于把自己锁在门外;宁可启动失败并打印明确原因。

二、端点收口(原阶段 2)
- /health 改为鉴权(原匿名泄漏版本、commit、运行时长、内存、goroutine、配置错误文本)
- /dashboard 改为鉴权,并对浏览器 HTML 请求做内容协商(Accept 含 text/html 时
  返回页面外壳,由前端据 sessionStorage 显示登录视图;非 HTML 请求无凭证一律 401)。
  不做无条件 401 的原因:SPA 登录态存在 sessionStorage、不随请求发送,
  服务端无从判断是否已登录,强行 401 会把"打开看到登录页"变成"打开直接报错"。
  页面外壳不含任何数据,数据全部来自鉴权后的 API。
- /api/setup/status 改为鉴权(原本 normal 模式下仍匿名返回 installed/mode)
- 根路径 /metrics 默认完全不注册(访问 404);配置 METRICS_ALLOW_CIDR 后按内网
  白名单开放,非白名单返回 404 而非 403,不向扫描者确认端点存在。
- 新增 GET /healthz 匿名存活探针,只回 200 与字面量 ok、不含任何字段,解决
  "给 /health 加鉴权后 K8s 探针与 Docker HEALTHCHECK 会一律 401 导致 Pod 反复重启";
  新增 GET /api/admin/health 鉴权版详细健康数据。

IP 白名单中间件复用 GetClientIP(已处理 XFF 与 TRUSTED_PROXY_HOPS);
main.go 启动日志同步改为指向 /healthz 与 /api/admin/health。

BREAKING CHANGE:
1. /health、/dashboard、/api/setup/status 不再匿名可读;根路径 /metrics 需配
   METRICS_ALLOW_CIDR,否则 404。
2. 未配置 auth_key / admin_key / OPENAI_TTS_API_KEY 任一时,normal 模式下服务拒绝启动。
3. K8s 探针与 Docker HEALTHCHECK 必须改指 /healthz;Prometheus 请改用鉴权版
   /api/admin/metrics 或配置 METRICS_ALLOW_CIDR(Docker 中勿填 127.0.0.1/32,
   那是容器自身回环,应填容器内网网段)。

验证:go build / go vet / go test ./... -count=1 全绿(7 个包);
另用真实服务器端到端验证端点矩阵、内容协商、凭证隔离与 CIDR 白名单两种形态。
2026-10-04 01:17:06 +08:00
sun 49f57e4d28 docs: 新增上游适配器开发指南
CI / build-and-test (push) Canceled after 0s
面向 多渠道多上游适配器 愿景的落地文档,分两部分:第一部分逐行核对现有火山适配器的真实契约(包的职责划分、主干对适配器的 7 项硬性依赖、MetricsRecorder 埋点约定、格式降级规则、以及 6 条实测踩过的上游协议坑);第二部分给出 Provider 抽象的目标设计与分阶段实施计划(阶段 0 抽接口零行为变更 -> 阶段 1 provider 入库 -> 阶段 2 配置命名空间化 -> 阶段 3 metrics 加 provider 维度 -> 阶段 4 管理界面 -> 阶段 5 第二个真实适配器)。

同时如实记录三处现状缺陷,避免按文档实施时踩坑:voice.resource_id 是火山专属概念却成了全表 NOT NULL 通用列;store/migrate.go 的迁移框架定义了 Migration/migrations 但切片为空、migrate() 从不读取它(实际只做 CREATE TABLE IF NOT EXISTS,对已存在的表不做任何变更),且 schema_version 的 INSERT 未传值、实际写入版本号是 0;setting/config.go 整体耦合 volcano.Options,主干因此无法插入第二个上游。
2026-10-03 23:41:22 +08:00
sun ad342ec8f1 ci: 新增 build/vet/test 质量门
CI / build-and-test (push) Canceled after 0s
此前仓库唯一的 workflow 只在打 tag 时构建 Docker 镜像,不做任何编译或测试校验,加上测试源码不入库,导致 go test 全绿从来没有被自动化校验过。

新增 ci.yml:push / PR 到 develop、main 时跑 go build ./... / go vet ./... / go test ./... -count=1,再单跑一遍集成测试并输出明细。版本以 go-version-file 跟随 go.mod;-count=1 禁用测试缓存。
2026-10-03 16:18:54 +08:00
sun 24f8accf71 chore: 测试源码不入库,订正过期的 UI handoff 文档与 CHANGELOG
.gitignore 恢复整体屏蔽 *_test.go:测试只保留在本地磁盘供开发者执行,不作为交付物外流。因此仓库内不提供自动化测试。

docs/UI_HANDOFF.md 标注为历史文档:它是改造两个单文件 .html、admin <= 35KB 的外包任务书,而 admin 后台此后已重构成多页 SPA(admin-shell.js / admin.css + 登录/音色/设置三页),原约束与验收标准全部失效。补上当前真实文件结构与新的验收清单。
2026-10-03 16:13:47 +08:00
sun 6ac2473099 fix(volcano): PostStream 对未初始化 client 返回错误而不是 panic
Synthesis 里 client 是包级 volcanoClient,只在启动时赋值。不经过 main 的调用路径拿到 nil client 时,(*HTTPClient).PostStream 在 h.client.Do 处 nil 解引用崩溃。

装配错误应该让这一次请求以上游不可用失败:controller 会归一成 5xx 并记日志,而不是拖垮整个进程。
2026-10-03 16:13:47 +08:00
sun 1e2be0cd5b fix(telemetry): 指标写入对空接收者安全,修 nil 解引用 panic
metrics 包的全局指标(UpstreamTotal 等)默认是 nil,只有 main 调过 metrics.Init() 之后才有值。任何不经过 main 的调用路径都会在 controller/tts.go -> adapter/volcano/synthesis.go -> metrics.AdapterRecorder 处 nil 解引用,整个进程崩掉。

生产路径本来没问题(二进制一定先 Init),但这类崩溃只在运行时暴露,且漏掉指标初始化属于很容易踩的装配错误,不该以 panic 收场。

改动: Counter.Add / Gauge.Set / Gauge.Add / Histogram.Observe 增加空接收者判断,nil 时静默忽略(与 noop 语义一致);在包注释里写成显式设计约定,避免以后被当成冗余代码删掉。
2026-10-03 16:13:47 +08:00
sun 21e1fd1fbb ci(docker): 修 workflow 非法表达式, secrets 改经 job env 透传
Docker Publish / build-and-push (push) Canceled after 0s
GitHub Actions 拒绝加载本 workflow, 报:
  Invalid workflow file: .github/workflows/docker.yml#L1
  (Line: 49, Col: 13): Unrecognized named-value: 'secrets'.
  Located at position 40 within expression:
  github.event_name != 'pull_request' && secrets.DOCKERHUB_USERNAME != ''

原因: secrets 上下文不允许在 step 的 if: 表达式里引用(GitHub 沙箱限制)。
secrets 只在 with: / env: / run: 等位置可用, if: 里必须走 env 中转。

修复:
  - 新增 job 级 env: DOCKERHUB_USERNAME / DOCKERHUB_TOKEN (值来自 secrets)
  - DockerHub login step: if 改 env.DOCKERHUB_USERNAME != ''
  - DockerHub login step: username/password 改读 env.*
  - metadata-action images: DockerHub 那行改 env.DOCKERHUB_USERNAME,
    enable 判断同样改 env 上下文

副作用: 无。secret 未配置时行为不变(跳过 login + 不渲染 DockerHub image)。
已用 js-yaml 校验 YAML 结构合法, 并扫描确认全部 step 的 if: 不含 secrets 引用。
2026-09-28 23:30:57 +08:00
42 changed files with 3346 additions and 225 deletions
+21 -1
View File
@@ -29,5 +29,25 @@ OPENAI_TTS_API_KEY=
# 装完后可改(也支持环境变量作为 DB 缺失时的 fallback) # 装完后可改(也支持环境变量作为 DB 缺失时的 fallback)
# ========================================== # ==========================================
# X-Forwarded-For 解析跳数(可选, 0=启发式默认, N>0=精确 N 跳反代) # X-Forwarded-For 解析跳数(可选, 0=启发式默认, N>0=精确 N 跳反代)
# 装好后推荐到 /admin 改 trusted_proxy_hops # 装好后推荐到 /dashboard 改 trusted_proxy_hops
# TRUSTED_PROXY_HOPS=2 # TRUSTED_PROXY_HOPS=2
# ==========================================
# v0.3.0 新增
# ==========================================
# 根路径 /metrics 的内网 CIDR 白名单(可选, 逗号分隔, 也接受裸 IP)
# - 不配置:根路径 /metrics 完全不注册(访问 404)。
# Prometheus 请改用鉴权版 /api/admin/metrics 并带 Bearer 凭证。
# - 配置后:根路径 /metrics 仅对白名单来源开放,非白名单返回 404。
# ⚠️ Docker 部署必须填「容器内网网段」,不要填 127.0.0.1/32 ——
# 那是容器自身回环,宿主机/其他容器里的 Prometheus 进不来。
# 例:
# METRICS_ALLOW_CIDR=10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
# 管理后台专用凭证(可选)
# - 不配置:管理接口回退使用 DB 里的 auth_key(即 OPENAI_TTS_API_KEY 那套),行为与旧版一致。
# - 配置后:管理后台/管理接口只认这个 key,业务调用方拿到的 auth_key 无法访问管理接口。
# 建议通过 WebUI 配置(/dashboard → 设置),不必写在这里;此处仅作说明。
# 对应 DB 设置项:admin_key
+11 -5
View File
@@ -25,6 +25,11 @@ jobs:
permissions: permissions:
contents: read contents: read
packages: write packages: write
# secrets 不能在 step 的 if: 表达式里直接引用(GitHub 沙箱限制),
# 必须先经 job 级 env 透传,再用 env.XXX != '' 判断。
env:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -46,11 +51,11 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to DockerHub - name: Login to DockerHub
if: github.event_name != 'pull_request' && secrets.DOCKERHUB_USERNAME != '' if: env.DOCKERHUB_USERNAME != ''
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ env.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ env.DOCKERHUB_TOKEN }}
- name: Extract version from git - name: Extract version from git
id: version id: version
@@ -63,10 +68,11 @@ jobs:
uses: docker/metadata-action@v5 uses: docker/metadata-action@v5
with: with:
# 多个 image base:metadata-action 会为每个 base 渲染完整 tag 列表。 # 多个 image base:metadata-action 会为每个 base 渲染完整 tag 列表。
# DockerHub image 用 enable 保护:username secret 没设时不渲染,避免非法 image 名。 # DockerHub image 用 enable 保护:username 未配置(secret 未设)时不渲染,
# 避免生成 "docker.io//ttshub" 这种非法 image 名导致 metadata 步骤报错。
images: | images: |
name=${{ env.REGISTRY }}/${{ github.repository }} name=${{ env.REGISTRY }}/${{ github.repository }}
name=docker.io/${{ secrets.DOCKERHUB_USERNAME }}/ttshub,enable=${{ secrets.DOCKERHUB_USERNAME != '' }} name=docker.io/${{ env.DOCKERHUB_USERNAME }}/ttshub,enable=${{ env.DOCKERHUB_USERNAME != '' }}
tags: | tags: |
type=semver,pattern={{version}} type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}}.{{minor}}
+16 -6
View File
@@ -1,10 +1,15 @@
# Go build cache # Go build cache
.gocache/ .gocache/
.gotmp/
.gomodcache/
*.exe *.exe
*.test *.test
*.out *.out
# Go test sources (prevent accidental commit; tests live outside the repo by policy) # Go 测试源码:不入库(测试保留在本地磁盘,由开发者自行 go test ./... -count=1)
# 理由:测试用例会暴露内部实现细节与断言,不作为交付物外流。
# 注意:本规则会把测试文件挡在 git 之外,因此仓库内不提供自动化测试,
# 也没有自动化质量门 —— 守行为回归只能靠开发者在本地执行测试。
*_test.go *_test.go
# Editor / OS # Editor / OS
@@ -33,14 +38,19 @@ bin/
# Local dev scripts (per-user; not part of repo) # Local dev scripts (per-user; not part of repo)
start-local.ps1 start-local.ps1
# Windows / PowerShell 脚本:一律不入库、不外流
# ui-handoff/serve.ps1 与 start-local.ps1 是本地起服便利脚本(含本机路径与端口),
# 不属于交付物。这两个文件在文件系统层面删除被拒,因此只能在 git 层屏蔽。
*.ps1
# UI handoff bundle(改造前的单文件副本 + 离线 mock 预览;仅本地对照,不部署)
ui-handoff/
# Runtime database artifacts (created by M1 installer flow) # Runtime database artifacts (created by M1 installer flow)
tts.db tts.db
tts.db-* tts.db-*
tts.db.* tts.db.*
installed.lock installed.lock
# UI handoff bundle (for external AI redesign work; not deployed) # DSH sandbox ACL repair artifacts (created by diagnose-windows-sandbox-acl skill)
ui-handoff/ .dsh-acl/
# Docs (本地草稿,不入库)
docs/
+84
View File
@@ -2,6 +2,90 @@
本项目的所有重要变更都记录在本文档。版本号遵循 [SemVer](https://semver.org/)。 本项目的所有重要变更都记录在本文档。版本号遵循 [SemVer](https://semver.org/)。
## [0.2.4] - 2026-10-04
### 路由鉴权架构升级 · 健康/指标端点收口 · 管理入口迁移
> ⚠️ **本版本为破坏性变更版本**,升级前请阅读 [Release Notes](docs/RELEASE_NOTES_v0.3.0.md)。
>
> **版本号说明**:本次以 `0.2.4` 发布(按补丁号连续),但**语义上是破坏性变更** ——
> 未配凭证的部署会拒绝启动、匿名监控端点被收口、探针需改指 `/healthz`。
> 之所以不用 minor 号,是为了保持编号连续;后续 `0.2.x` 用于修复本版本暴露的问题。
>
> **阶段编号说明**:初版方案的"阶段 1(收紧鉴权)"与"阶段 2(收口端点)"属于同一次安全改造、
> 必须一起上线才有意义(只做前者是"有风险无收益"),故已**合并为现在的「阶段 1」**;
> 后续阶段依次顺延。下文的「阶段 N」均指合并后的编号。
#### 新增
- **管理入口迁移到 `/dashboard`**:`/dashboard`、`/dashboard/voices`、`/dashboard/settings`
为新入口;详细状态页(原 `/dashboard` 的旧预览页)迁至 **`/dashboard/status`**,
并在侧边栏新增「观测 → 详细状态」入口。`/admin` 及子路径 **301 永久重定向**到对应新路径。
- **API 路径分层**:`/api/admin/voices`、`/api/admin/settings`(含 `api-key` / `auth-key` / `admin-key`)
为正式路径;**旧路径 `/api/voices`、`/api/settings` 保留为别名**(同一个 handler,不做重定向)。
选别名而非 301/308 的原因:301 会把 POST/PUT/PATCH/DELETE 降级为 GET(RFC 7231)且被浏览器永久缓存,
客户端察觉不到路径变化;308 虽保留方法但对脚本仍是行为变化。别名零往返、零破坏,老脚本完全无感。
- **`GET /healthz` 匿名存活探针**:只返回 `200` 与字面量 `ok`,**不含任何字段**。
供 K8s liveness/readiness、Docker HEALTHCHECK、负载均衡健康检查使用 ——
这些探针默认不带 `Authorization`,若继续指向 `/health` 会因鉴权而全部失败。
- **`GET /api/admin/health` 鉴权版详细健康数据**:与 `/api/admin/metrics` 风格一致,
供管理面板与运维使用。
- **`METRICS_ALLOW_CIDR` 内网白名单**(逗号分隔 CIDR,支持裸 IP 自动补掩码):
配置后在根路径注册 `/metrics`,仅放行白名单来源;**非白名单返回 404 而非 403**,
不向扫描者确认端点存在。未配置时根路径 `/metrics` **完全不注册**。
- **独立管理凭证 `admin_key`(可选)**:管理接口凭证与业务调用凭证分离。
取值优先级 `admin_key`(DB) → `auth_key`(DB) → `OPENAI_TTS_API_KEY`(env)。
**不配置时行为与旧版完全一致**(回退用 `auth_key`),配置后业务 key 无法访问管理接口。
新增 `PUT /api/admin/settings/admin-key`(及旧前缀别名)用于配置,
凭证**只写不读**(`GET /api/admin/settings` 仅返回打码值与 `admin_key_set` / `admin_key_source`)。
#### 变更(Breaking Change)
- **`/health`、`/metrics`、`/dashboard` 不再匿名可读**:
- `/health` → 鉴权(原匿名,泄漏版本 / commit / 内存 / goroutine / 配置错误文本)
- `/dashboard` → 鉴权(原匿名,且它同时聚合 `/health` + `/metrics`,是单点泄漏最严重的端点)。
对浏览器 HTML 请求做内容协商:返回页面外壳由前端显示登录视图;非 HTML 请求(脚本、抓取)无凭证一律 401。
- 根路径 `/metrics` → 需要 `METRICS_ALLOW_CIDR`,否则 404
- `/api/setup/status` → 鉴权(原本 normal 模式下仍匿名返回 `{installed, mode}`)
- **`RequireAdmin` 在凭证未配置时不再放行**。此前 `len(keys)==0` 直接放行,导致未配置凭证的
部署上管理接口完全裸奔(也使得给 `/metrics`、`/health` 加鉴权的加固形同虚设)。现在该情况返回 **401**。
- **normal 模式下未配置任何管理凭证时服务拒绝启动**(fail-fast),启动摘要打印管理凭证来源。
> ⚠️ **升级提示**
> 1. 若部署未配置 `auth_key` / `admin_key` / `OPENAI_TTS_API_KEY` 中任何一个,升级后服务将拒绝启动。
> 2. K8s 探针 / Docker HEALTHCHECK 请改指 **`/healthz`**;Prometheus 请改用鉴权版
> `/api/admin/metrics`,或配置 `METRICS_ALLOW_CIDR`。
> 3. `METRICS_ALLOW_CIDR` 在 Docker 中**不要填 `127.0.0.1/32`**(那是容器自身回环),
> 请填容器内网网段(如 `172.16.0.0/12`)。
> 4. 管理入口改为 **`/dashboard`**;旧 `/admin` 路径保留 301 重定向,书签与脚本请尽快迁移。
> 5. 管理 API 正式路径改为 **`/api/admin/*`**;旧 `/api/*` 路径仍可用(别名,非重定向),
> 计划在后续版本移除。
### 修复
- **指标空指针崩溃**: `metrics` 包的全局指标(`UpstreamTotal` 等)默认是 nil,只有 `main` 调过
`metrics.Init()` 后才有值。任何**不经过 main** 的调用路径(直接调 handler、复用为库)都会在
`controller/tts.go` → `adapter/volcano/synthesis.go` → `metrics.AdapterRecorder` 处 nil 解引用 panic。
现在 `telemetry` 的 `Counter.Add` / `Gauge.Set` / `Gauge.Add` / `Histogram.Observe` 一律
**空接收者安全**(nil 静默忽略),并在包注释里写成显式设计约定。
- **上游 client 空指针**: `volcano.(*HTTPClient).PostStream` 在 client 未初始化时 panic。
现在返回错误,由 controller 归一成 5xx 并记日志——装配错误不该拖垮进程。
> 上面两个崩溃是**本地测试暴露出来的**:直接调用 handler 而不经过 `main` 的路径,
> 会跳过 `metrics.Init()` 与 `volcanoClient` 的赋值。生产二进制不受影响。
### 变更
- **测试源码不再入库**: `.gitignore` 恢复整体屏蔽 `*_test.go`。测试用例会暴露内部实现
细节与断言,不作为交付物外流;测试文件保留在本地磁盘,由开发者自行执行
`go test ./... -count=1`。
⚠️ 代价必须明确:仓库内**不提供自动化测试**,也没有自动化质量门。
守行为回归只能靠开发者在本地(测试文件所在处)执行测试;
CI 仅在发版打 tag 时构建 Docker 镜像。
- **文档订正**: `docs/UI_HANDOFF.md` 原为"单文件换皮"外包任务书,其中的
"admin.html ≤ 35KB / setup.html ≤ 15KB / 只改两个 .html" 等约束在 admin 拆分多页后已作废,
现标注为历史文档并补上当前真实文件结构与验收清单。
## [0.2.0] - 2026-08-30 ## [0.2.0] - 2026-08-30
### 新增 ### 新增
+4 -1
View File
@@ -36,7 +36,10 @@ USER appuser
EXPOSE 8080 EXPOSE 8080
# v0.3.0:健康检查必须用 /healthz(匿名存活探针)。
# /health 现在需要管理凭证,而 HEALTHCHECK 不带 Authorization ——
# 继续用 /health 会让镜像一直被判定为 unhealthy。
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- http://localhost:8080/health || exit 1 CMD wget -qO- http://localhost:8080/healthz || exit 1
ENTRYPOINT ["./tts-api"] ENTRYPOINT ["./tts-api"]
+105 -36
View File
@@ -6,11 +6,14 @@
- 完全兼容 OpenAI `/v1/audio/speech` API - 完全兼容 OpenAI `/v1/audio/speech` API
- **引导式安装**: 首次启动自动进入 `/setup` 向导,无需手写 env - **引导式安装**: 首次启动自动进入 `/setup` 向导,无需手写 env
- **WebUI 后台** `/admin`: 音色管理 / 全局设置 / CORS 配置 / 鉴权 - **WebUI 后台** `/dashboard`: 音色管理 / 全局设置 / CORS 配置 / 鉴权
- **音色库**: 多 voice 动态路由,未知 / 禁用 voice 返回明确错误 - **音色库**: 多 voice 动态路由,未知 / 禁用 voice 返回明确错误
- **多格式输出**: mp3 / ogg_opus / pcm / wav (内部转 pcm + 本地拼头) / aac / flac - **多格式输出**: mp3 / ogg_opus / pcm / wav (内部转 pcm + 本地拼头) / aac / flac
- **鉴权 + 限流**: API Key 校验, IP 速率限制, 全局并发限制 - **鉴权 + 限流**: API Key 校验, IP 速率限制, 全局并发限制
- **观测**: Prometheus 文本格式 `/metrics` + 服务状态 `/health`, 零外部依赖 - **凭证分离**(v0.3.0): 管理后台可配独立 `admin_key`,业务调用 Key 无法访问管理接口
- **观测**: Prometheus 文本格式 + 服务状态, **默认无匿名监控端点**(v0.3.0 起)
- `/healthz` 匿名存活探针(不含任何字段)· `/api/admin/metrics` 与 `/api/admin/health` 需鉴权
- 根路径 `/metrics` 默认 404,需 `METRICS_ALLOW_CIDR` 内网白名单才注册
- **跨平台**: Windows / Linux / macOS / Docker - **跨平台**: Windows / Linux / macOS / Docker
## 快速开始 ## 快速开始
@@ -35,7 +38,7 @@ go build -o tts-api .
3. 音色列表: 每个 voice 的对外名 + 火山 speaker ID 3. 音色列表: 每个 voice 的对外名 + 火山 speaker ID
4. 确认提交 4. 确认提交
完成后自动跳转到 `/admin`,从这里登录管理。 完成后自动跳转到 `/dashboard`,从这里登录管理。
### 3. 调用 ### 3. 调用
@@ -56,9 +59,19 @@ curl -X POST http://localhost:8080/v1/audio/speech \
| `TTS_ADMIN_KEY` | ✓ (生产) | `/api/setup` 安装 token,首次安装时校验 | | `TTS_ADMIN_KEY` | ✓ (生产) | `/api/setup` 安装 token,首次安装时校验 |
| `TTS_DB_PATH` | ✗ | DB 路径,默认 `./tts.db` | | `TTS_DB_PATH` | ✗ | DB 路径,默认 `./tts.db` |
| `PORT` | ✗ | 监听端口,默认 `8080` | | `PORT` | ✗ | 监听端口,默认 `8080` |
| `OPENAI_TTS_API_KEY` | ✗ (生产) | OpenAI 端鉴权 key,可不设(内网) | | `OPENAI_TTS_API_KEY` | ✓ (生产) | 业务端鉴权 key;未设 `admin_key` 时**也用于管理后台登录** |
**所有业务配置**(`api_key` / `default_resource_id` / `default_speaker` / `default_format` / `sample_rate` / `model` / `model_type` / `explicit_language` / `enable_subtitle` / `timeout` / `cors_origins` / `cors_allow_all` / `auth_key` / `trusted_proxy_hops`) — 全部在 `/admin` 设置,持久化到 SQLite。 v0.3.0 新增(均可选):
| 变量 | 用途 |
|---|---|
| `METRICS_ALLOW_CIDR` | 根路径 `/metrics` 的内网 CIDR 白名单(逗号分隔)。不配则根路径完全不注册 |
> ⚠️ **v0.3.0 起,normal 模式下未配置任何管理凭证(DB `admin_key` / DB `auth_key` /
> env `OPENAI_TTS_API_KEY` 三者皆空)时服务将拒绝启动** —— 因为管理接口在无凭证时会拒绝
> 所有请求,启动一个进不去后台的实例没有意义。
**所有业务配置**(`api_key` / `default_resource_id` / `default_speaker` / `default_format` / `sample_rate` / `model` / `model_type` / `explicit_language` / `enable_subtitle` / `timeout` / `cors_origins` / `cors_allow_all` / `auth_key` / **`admin_key`** / `trusted_proxy_hops`) — 全部在 `/dashboard` 设置,持久化到 SQLite。
env 仍可作为 fallback 读(老用户兼容),但**新用户应通过 WebUI 配**。 env 仍可作为 fallback 读(老用户兼容),但**新用户应通过 WebUI 配**。
@@ -70,9 +83,9 @@ env 仍可作为 fallback 读(老用户兼容),但**新用户应通过 WebUI 配
1. **凭证**: 火山 API Key (必填) + OpenAI 鉴权 Key (可选) 1. **凭证**: 火山 API Key (必填) + OpenAI 鉴权 Key (可选)
2. **默认路由**: 默认资源 ID (`seed-icl-2.0`) + 默认音色名 + 默认格式 + 采样率 2. **默认路由**: 默认资源 ID (`seed-icl-2.0`) + 默认音色名 + 默认格式 + 采样率
3. **音色列表**: 每个 voice 一行,填对外名 + 火山 speaker ID 3. **音色列表**: 每个 voice 一行,填对外名 + 火山 speaker ID
4. **确认**: 提交写入 DB,自动跳 `/admin` 4. **确认**: 提交写入 DB,自动跳 `/dashboard`
### `/admin` 日常管理 ### `/dashboard` 日常管理
| Tab | 用途 | | Tab | 用途 |
|---|---| |---|---|
@@ -121,17 +134,31 @@ OpenAI 兼容,鉴权 `Authorization: Bearer <OPENAI_TTS_API_KEY>`(若已设)。
| `pcm` | pcm | `audio/pcm` | | `pcm` | pcm | `audio/pcm` |
| `aac` / `flac` | mp3 (降级) | `audio/mpeg` | | `aac` / `flac` | mp3 (降级) | `audio/mpeg` |
### `GET /health` ### `GET /healthz`
无鉴权,返回: **无鉴权**。存活探针,**只返回 `200` 与字面量 `ok`,不含任何字段**。
供 K8s `livenessProbe`/`readinessProbe`、Docker `HEALTHCHECK`、负载均衡健康检查使用 ——
这些探针默认不带 `Authorization`,不能指向需要鉴权的 `/health`。
```bash
curl http://localhost:8080/healthz
# ok
```
### `GET /health`(v0.3.0 起需鉴权)
**需管理凭证**(`Authorization: Bearer <admin_key>`)。返回完整健康数据:
```json ```json
{ {
"status": "ok", // ok | not_installed | configuration_error "status": "ok", // ok | not_installed | configuration_error
"service": "ByteDance TTS to OpenAI API Adapter", "service": "ByteDance TTS to OpenAI API Adapter",
"version": "v0.2.0", "version": "v0.3.0",
"commit": "621f9f8", "commit": "abc1234",
"uptime": "3600 seconds", "uptime": "3600 seconds",
"start_time": "2026-10-04T00:00:00Z",
"memory": { "heap_alloc": 0, "heap_inuse": 0, "goroutines": 0 },
"config_status": { "config_status": {
"all_required_vars_set": true, "all_required_vars_set": true,
"config_error": false, "config_error": false,
@@ -145,16 +172,41 @@ OpenAI 兼容,鉴权 `Authorization: Bearer <OPENAI_TTS_API_KEY>`(若已设)。
- 正常: HTTP 200, `status: "ok"` - 正常: HTTP 200, `status: "ok"`
- 未安装: HTTP 200, `status: "not_installed"` - 未安装: HTTP 200, `status: "not_installed"`
- 配置损坏: HTTP 503, `status: "configuration_error"`, `error` 字段有原因 - 配置损坏: HTTP 503, `status: "configuration_error"`, `error` 字段有原因
- 无凭证: HTTP **401**
> v0.3.0 之前本端点是匿名的,会泄漏版本、commit、运行时长、内存与配置错误文本,现已收口。
> 管理面板内使用鉴权版 **`GET /api/admin/health`**(返回同样的数据)。
### `GET /metrics` ### `GET /metrics`
Prometheus 文本格式,无鉴权。主要指标见 [观测 / Metrics](#观测--metrics)。 **默认不注册**(访问 404)。两种使用方式:
### `GET /admin`, `GET /setup`, `GET /dashboard` 1. **配置 `METRICS_ALLOW_CIDR`**(内网 CIDR 白名单,逗号分隔):
根路径 `/metrics` 仅对白名单来源开放,**非白名单返回 404**(不是 403,避免确认端点存在)。
```bash
METRICS_ALLOW_CIDR=10.0.0.0/8,172.16.0.0/12
```
⚠️ Docker 部署请填**容器内网网段**,不要填 `127.0.0.1/32`(那是容器自身回环)。
2. **使用鉴权版 `GET /api/admin/metrics`**,带 `Authorization: Bearer <管理凭证>`。
Prometheus 侧需配 `authorization: { credentials: <管理凭证> }`。
- `/admin`: 后台 (Vue SPA),需鉴权 Prometheus 文本格式。主要指标见 [观测 / Metrics](#观测--metrics)。
- `/setup`: 引导式安装页 (Vue SPA),无鉴权,装完自动跳转
- `/dashboard`: 服务状态预览页 (无鉴权) ### 管理页面与安装页
| 路径 | 说明 | 鉴权 |
|---|---|---|
| `GET /dashboard` | 管理看板 | 需鉴权(浏览器请求返回页面外壳,数据走 API) |
| `GET /dashboard/voices` | 音色管理 | 同上 |
| `GET /dashboard/settings` | 全局设置 | 同上 |
| `GET /dashboard/status` | 详细服务状态(健康 + 指标看板) | 同上 |
| `GET /dashboard/login` | 登录页 | **公开**(否则无法登录) |
| `GET /setup` | 引导式安装页 | 无鉴权,装完自动跳转 |
| `GET /admin` 等旧路径 | 301 永久重定向到对应 `/dashboard/*` | — |
> **为什么页面是"需鉴权"却又能直接打开?** 浏览器的 `Accept` 含 `text/html` 时返回页面外壳,
> 由前端根据 `sessionStorage` 里的凭证决定显示登录视图;非 HTML 请求(脚本、抓取工具)
> 无凭证一律 401。页面外壳不含任何数据,数据全部来自鉴权后的 `/api/admin/*` 接口。
## 老用户迁移 (从 v0.1.0 → v0.2.0) ## 老用户迁移 (从 v0.1.0 → v0.2.0)
@@ -162,19 +214,19 @@ v0.1.0 用 env 配 11 个 `BYTEDANCE_TTS_*` 变量。v0.2.0 起改 WebUI:
| 旧 env | v0.2.0 配置入口 | | 旧 env | v0.2.0 配置入口 |
|---|---| |---|---|
| `BYTEDANCE_TTS_API_KEY` | `/admin` 设置 → API Key | | `BYTEDANCE_TTS_API_KEY` | `/dashboard` 设置 → API Key |
| `BYTEDANCE_TTS_RESOURCE_ID` | `/admin` 设置 → 默认资源 ID | | `BYTEDANCE_TTS_RESOURCE_ID` | `/dashboard` 设置 → 默认资源 ID |
| `BYTEDANCE_TTS_SPEAKER` | `/admin` 设置 → 默认音色 (填 voice **名字**,不是 speaker ID) | | `BYTEDANCE_TTS_SPEAKER` | `/dashboard` 设置 → 默认音色 (填 voice **名字**,不是 speaker ID) |
| `BYTEDANCE_TTS_FORMAT` | `/admin` 设置 → 默认格式 | | `BYTEDANCE_TTS_FORMAT` | `/dashboard` 设置 → 默认格式 |
| `BYTEDANCE_TTS_SAMPLE_RATE` | `/admin` 设置 → 采样率 | | `BYTEDANCE_TTS_SAMPLE_RATE` | `/dashboard` 设置 → 采样率 |
| `BYTEDANCE_TTS_BIT_RATE` | `/admin` 设置 → MP3 比特率 | | `BYTEDANCE_TTS_BIT_RATE` | `/dashboard` 设置 → MP3 比特率 |
| `BYTEDANCE_TTS_MODEL` | `/admin` 设置 → 子模型 | | `BYTEDANCE_TTS_MODEL` | `/dashboard` 设置 → 子模型 |
| `BYTEDANCE_TTS_MODEL_TYPE` | `/admin` 设置 → 模型类型 | | `BYTEDANCE_TTS_MODEL_TYPE` | `/dashboard` 设置 → 模型类型 |
| `BYTEDANCE_TTS_EXPLICIT_LANGUAGE` | `/admin` 设置 → 显式语言 | | `BYTEDANCE_TTS_EXPLICIT_LANGUAGE` | `/dashboard` 设置 → 显式语言 |
| `BYTEDANCE_TTS_ENABLE_SUBTITLE` | `/admin` 设置 → 启用字级时间戳 | | `BYTEDANCE_TTS_ENABLE_SUBTITLE` | `/dashboard` 设置 → 启用字级时间戳 |
| `BYTEDANCE_TTS_TIMEOUT` | (保留 env 暂未搬 DB) | | `BYTEDANCE_TTS_TIMEOUT` | (保留 env 暂未搬 DB) |
| `ALLOWED_ORIGINS` | `/admin` → CORS → 跨域白名单 | | `ALLOWED_ORIGINS` | `/dashboard` → CORS → 跨域白名单 |
| `OPENAI_TTS_API_KEY` | `/admin` 设置 → OpenAI 端鉴权 key | | `OPENAI_TTS_API_KEY` | `/dashboard` 设置 → OpenAI 端鉴权 key |
**逐步迁移建议**: **逐步迁移建议**:
@@ -251,14 +303,30 @@ Prometheus 文本格式,无鉴权。可直接被 Prometheus 抓取或浏览器
- 反代**仅**把鉴权后的请求转发到 `:8080` - 反代**仅**把鉴权后的请求转发到 `:8080`
- 此时 `OPENAI_TTS_API_KEY` 可不设 - 此时 `OPENAI_TTS_API_KEY` 可不设
**`/metrics` / `/dashboard` / `/health` 均不鉴权**,生产环境务必通过反代保护: ### v0.3.0 起各端点的暴露面
v0.3.0 已把健康与指标端点收口,**默认不再有匿名可读的监控端点**:
| 端点 | 默认状态 | 说明 |
|---|---|---|
| `/healthz` | **匿名可读** | 只回 `200 ok`,**不含任何字段**,专供探针 |
| `/health` | 需鉴权 | 完整健康数据 |
| `/metrics`(根路径) | **默认 404** | 需 `METRICS_ALLOW_CIDR` 才注册 |
| `/dashboard/*` | 需鉴权 | 管理面板(HTML 请求返回外壳,数据走 API) |
| `/api/admin/*` | 需鉴权 | 管理 API(含 `/api/admin/health`、`/api/admin/metrics`) |
| `/api/public/*` | 无鉴权 | 预留空壳,当前无业务 |
因此反代**只需**确保业务与管理接口不被匿名滥用;监控端点已由应用自身把关:
```nginx ```nginx
location /metrics { allow 10.0.0.0/8; deny all; } # 仅 Prometheus 服务器 # 若未配 METRICS_ALLOW_CIDR,根路径 /metrics 本就 404,无需额外规则。
# 若希望 Prometheus 走根路径,建议在应用侧配 METRICS_ALLOW_CIDR,而不是在反代做白名单。
location /dashboard { auth_basic "admin"; auth_basic_user_file /etc/nginx/.htpasswd; } location /dashboard { auth_basic "admin"; auth_basic_user_file /etc/nginx/.htpasswd; }
location /health { allow 10.0.0.0/8; deny all; } # 或 K8s liveness probe 直接访问
``` ```
> ⚠️ **探针必须指向 `/healthz`**。K8s `livenessProbe`/`readinessProbe`、Docker `HEALTHCHECK`
> 默认不带 `Authorization`,若仍指向 `/health` 会一律 401,导致 Pod 反复重启 / 容器被判不健康。
## 部署 ## 部署
### Docker ### Docker
@@ -269,6 +337,7 @@ docker compose up -d
``` ```
`docker-compose.yml` 已配 named volume `tts-api-data` 挂载到容器 `/data`,DB 与 lock 文件持久化,容器重启不丢配置。 `docker-compose.yml` 已配 named volume `tts-api-data` 挂载到容器 `/data`,DB 与 lock 文件持久化,容器重启不丢配置。
其 `healthcheck` 已使用 `/healthz`。
### Linux Systemd ### Linux Systemd
@@ -282,7 +351,7 @@ docker compose up -d
| `installer/` | 启动期模式检测,DB 自愈回退 | | `installer/` | 启动期模式检测,DB 自愈回退 |
| `setting/` | 全局配置 (TTSOptions / Auth / CORS) + 启动汇总 | | `setting/` | 全局配置 (TTSOptions / Auth / CORS) + 启动汇总 |
| `store/` | SQLite 数据访问 (settings / voices) + 自愈 | | `store/` | SQLite 数据访问 (settings / voices) + 自愈 |
| `controller/` | /v1/audio/speech、/health、/setup、/admin、/api/setup、/api/settings、/api/voices | | `controller/` | /v1/audio/speech、/healthz、/health、/setup、/dashboard、/api/setup、/api/admin/* |
| `middleware/` | SecurityHeaders, CORS, 鉴权, 限流, 并发, 日志, 客户端 IP 提取, install 模式守卫 | | `middleware/` | SecurityHeaders, CORS, 鉴权, 限流, 并发, 日志, 客户端 IP 提取, install 模式守卫 |
| `router/` | 路由注册 | | `router/` | 路由注册 |
| `adapter/volcano/` | 火山 v3 HTTP Chunked 客户端 | | `adapter/volcano/` | 火山 v3 HTTP Chunked 客户端 |
@@ -339,12 +408,12 @@ PORT=8081 ./tts-api
如有问题,请检查: 如有问题,请检查:
1. 服务启动后日志第一段 "环境配置汇总" — 火山必填项是否全 ✓ 1. 服务启动后日志第一段 "环境配置汇总" — 火山必填项是否全 ✓
2. `/health` 返回 `status: "ok"` 且 `config_error: false` 2. `/healthz` 返回 `ok`(存活);详细状态用鉴权接口 `/api/admin/health`,应返回 `status: "ok"` 且 `config_error: false`
3. `/admin` → 设置 tab 检查 API Key / 默认资源 ID / 默认音色 3. `/dashboard` → 设置 tab 检查 API Key / 默认资源 ID / 默认音色
4. `/admin` → 音色管理 tab 检查 voice 是否启用 4. `/dashboard` → 音色 tab 检查 voice 是否启用
5. 火山控制台 → 在线体验同一对 resource + speaker 能合成 5. 火山控制台 → 在线体验同一对 resource + speaker 能合成
6. 客户端请求 URL 是否以 https:// 开头 (公网) 6. 客户端请求 URL 是否以 https:// 开头 (公网)
7. `/admin` → CORS tab 检查白名单含前端完整 origin 7. `/dashboard` → 设置 → CORS 检查白名单含前端完整 origin
## 许可证 ## 许可证
+117
View File
@@ -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
}
+33
View File
@@ -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
}
+108
View File
@@ -0,0 +1,108 @@
// Package route 实现多渠道路由分发层(阶段 1)。
//
// 思路参考 QuantumNous/new-api 的 Channel + 路由选择,但按本项目
// "一渠道 = 一上游账号" 简化:
// - Channel: 一个上游账号(Provider 名 + 凭据 + 可选 voice 白名单)
// - Router: 按优先级 + 加权随机从 channels 里挑一个给 Synthesize
// - 不做 HTTP 管理接口(阶段 2 才上),channels 启动期从 store 加载一次
package route
import (
"encoding/json"
"fmt"
"strings"
"github.com/volcano-tts/tts-api/adapter/provider"
"github.com/volcano-tts/tts-api/store"
)
// Channel 是 router 用的渠道视图,从 store.Channel 反序列化凭据而来。
//
// 持有 store.Channel 字符串字段(VoicesCSV / CredentialsJSON)会让 router
// 变成"半 DB 层",所以这里转成 router 友好的强类型:
// - Voices []string: 白名单;空 = 不限
// - Credentials: provider.Credentials 强类型
type Channel struct {
ID int64
Name string
Provider string // 对应 provider.Registry 里的 Provider.Name()
Credentials provider.Credentials
Voices []string // 对外 voice 名白名单;空 = 不限
Priority int
Weight int
Status int // 1=enabled 2=disabled
AutoBan bool
}
// FromStoreChannel 把 store.Channel 转成 router.Channel。
// 失败原因:
// - credentials_json 不是合法 JSON → 返 error(让启动期 / Reload 直接报错,不要静默继续)
func FromStoreChannel(s store.Channel) (Channel, error) {
c := Channel{
ID: s.ID,
Name: s.Name,
Provider: s.Provider,
Priority: s.Priority,
Weight: s.Weight,
Status: s.Status,
AutoBan: s.AutoBan,
Voices: splitCSV(s.Voices),
}
if err := json.Unmarshal([]byte(s.CredentialsJSON), &c.Credentials); err != nil {
return Channel{}, fmt.Errorf("channel %d (%s): credentials_json invalid: %w", s.ID, s.Name, err)
}
return c, nil
}
// BuildRequest 拿一个上游请求模板(零 Channel 兜底用的),填入本渠道的
// 凭据,作为 Synthesize 的入参。
//
// 不复制 Text / Format / Speed 等请求级字段(由调用方按本次请求填),
// 这里只换"账号身份"——保证 router 调 Synthesize 时拿到的是"用本渠道账号调" 的 Request。
func (c Channel) BuildRequest(template provider.Request) provider.Request {
template.Credentials = c.Credentials
return template
}
// AcceptsVoice 检查本渠道是否支持该 voice:
// - Voices 为空 → 不限,接受一切
// - 非空 → 必须在白名单里
func (c Channel) AcceptsVoice(voice string) bool {
if len(c.Voices) == 0 {
return true
}
for _, v := range c.Voices {
if v == voice {
return true
}
}
return false
}
// Enabled 是不是启用状态(Status=1)。
func (c Channel) Enabled() bool { return c.Status == 1 }
// splitCSV 拆 "a,b,c" 为 []string;容忍空 / 重复 / 空白。
func splitCSV(raw string) []string {
if raw == "" {
return nil
}
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
seen := make(map[string]struct{}, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
if _, ok := seen[p]; ok {
continue
}
seen[p] = struct{}{}
out = append(out, p)
}
if len(out) == 0 {
return nil
}
return out
}
+342
View File
@@ -0,0 +1,342 @@
package route
import (
"context"
"errors"
"fmt"
"math/rand"
"sync"
"time"
"github.com/volcano-tts/tts-api/adapter/provider"
"github.com/volcano-tts/tts-api/dto"
)
// Router 持有当前可用的渠道列表;并发安全(读多写少,RWMutex)。
//
// 阶段 1: 启动期从 store 加载一次,运行期不变;阶段 2 管理接口上线后
// 会有 Reload() 触发整体替换。
type Router struct {
mu sync.RWMutex
channels []Channel
}
// NewRouter 从 store 风格的 channel 列表构造 Router;空列表合法(代表"零渠道,走兜底")。
// 这里接受 []Channel(而非 *store.Store)便于单测注入。
func NewRouter(channels []Channel) *Router {
// 拷贝入参,避免外部后续修改影响内部。
cp := make([]Channel, len(channels))
copy(cp, channels)
return &Router{channels: cp}
}
// Snapshot 返回当前 channels 的副本(只读);controller 热路径用它取列表。
func (r *Router) Snapshot() []Channel {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]Channel, len(r.channels))
copy(out, r.channels)
return out
}
// Empty 没有任何启用渠道时返 true(让 controller 走 setting 兜底路径)。
func (r *Router) Empty() bool {
r.mu.RLock()
defer r.mu.RUnlock()
return len(r.channels) == 0
}
// Select 在当前 channels 里按规则挑一个支持该 voice 的渠道。
// 返回值:
// - (*Channel, nil) 找到启用且 voice 匹配的渠道
// - (nil, ErrNoChannels) router 里没有任何 channel(走兜底)
// - (nil, ErrVoiceNotFound) 有 channel 但 voice 不被任一渠道支持(返 400)
// - (nil, 其他 error) 内部错误
//
// 把"零 channel" 和 "voice 不匹配" 区分开,是为了让 controller 决定是兜底还是报 400:
// 零 channel → 旧行为不变;有 channel 但 voice 不支持 → 明确告诉客户端这个 voice 不可用。
func (r *Router) Select(voice string) (*Channel, error) {
r.mu.RLock()
defer r.mu.RUnlock()
if len(r.channels) == 0 {
return nil, ErrNoChannels
}
enabled := make([]Channel, 0, len(r.channels))
for _, c := range r.channels {
if c.Enabled() {
enabled = append(enabled, c)
}
}
if len(enabled) == 0 {
return nil, ErrNoChannels
}
// 优先:被 voice 接受;若无任何匹配 → ErrVoiceNotFound
matched := make([]Channel, 0, len(enabled))
for _, c := range enabled {
if c.AcceptsVoice(voice) {
matched = append(matched, c)
}
}
if len(matched) == 0 {
return nil, ErrVoiceNotFound
}
// 在 matched 内按 priority 降序分桶,选最高档做加权随机。
// 文档 §5:"同档换下一个"和"降档"在 Select 这一层不发生——Select 一次只返回一个 channel。
// 多 channel 失败重试/降档由 SelectAndSynthesize 负责(见下)。
top := topPriority(matched)
ch, _ := pickByWeight(top, time.Now().UnixNano())
return &ch, nil
}
// SelectAndSynthesize 一次请求的完整"选渠道→调 Synthesize"循环:
//
// pool = 全部 enabled 且支持 voice 的 channel,按 priority DESC 排序
// 循环 maxRetry 次:
// 从 pool 当前最高档里按 weight 加权随机选一个
// 从 pool 移除该 channel(防重复)
// 调 Synthesize;成功 → 返回;失败 → 累计错误,继续
// 全失败 → 返聚合错误
//
// 设计要点:
// - "换下一个" = 同档里挑别的;pool 自动收紧
// - "降档" = 最高档被试空 → pool 里只剩低档 → 自然降到低档
// - 失败重试安全(TTS 是只读调用,无扣款副作用)
//
// 调用方契约:
// - voice (第 2 参数) = 对外 voice 名(req.Voice),用于过滤 Channel.Voices 白名单
// - template.VoiceKey 必须是上游 speaker ID,已由 controller 解析 voices 表填好
// - router 不修改 template 任何字段,只换 Credentials(账号身份)
func (r *Router) SelectAndSynthesize(
ctx context.Context,
voice string,
template provider.Request,
maxRetry int,
mtr provider.MetricsRecorder,
) (*dto.SynthesisResult, *Channel, error) {
if maxRetry <= 0 {
maxRetry = 3 // 与文档 §5 默认一致
}
if mtr == nil {
mtr = provider.NoopMetrics()
}
r.mu.RLock()
enabled := r.enabledMatching(voice)
r.mu.RUnlock()
if len(enabled) == 0 {
// 这里复用 Select 的语义区分;controller 已知 router 状态,可以再调一次 Select 拿准确错误。
return nil, nil, ErrNoChannels
}
// 按 priority DESC 稳定排序;同档内按 id 升序,这样"换下一个"确定性
// (但加权随机是按 weight 选,同档里到底选谁仍然是随机的)。
sorted := sortByPriorityDesc(enabled)
var lastErrs []error
tried := make(map[int64]struct{}, maxRetry)
ch := Channel{} // 循环外声明,成功后用得到
for attempt := 0; attempt < maxRetry; attempt++ {
if ctx.Err() != nil {
return nil, nil, ctx.Err()
}
// 在剩余 pool 中取当前最高档
pool := filterUntried(sorted, tried)
if len(pool) == 0 {
break
}
top := topPriority(pool)
var err error
ch, err = pickByWeight(top, time.Now().UnixNano()+int64(attempt))
if err != nil {
// 候选档内 weight 全 ≤0 等异常;理论上不会发生
lastErrs = append(lastErrs, err)
continue
}
tried[ch.ID] = struct{}{}
// 拿 provider;Channel.Provider 未注册(代码级缺失)→ 视作候选失败
prov, ok := provider.Get(ch.Provider)
if !ok {
lastErrs = append(lastErrs, fmt.Errorf("channel %d (%s): provider %q not registered", ch.ID, ch.Name, ch.Provider))
continue
}
req := ch.BuildRequest(template)
// 不动 template.VoiceKey:它由 controller 填好,必须是上游 speaker ID
// (e.g. "BV001_streaming"),不能是 OpenAI 对外 voice 名(否则上游查不到
// 音色 → 55000000)。router 只换"账号身份"(Credentials),其它字段透传。
result, err := prov.Synthesize(ctx, req, mtr)
if err == nil {
return result, &ch, nil
}
lastErrs = append(lastErrs, fmt.Errorf("channel %d (%s): %w", ch.ID, ch.Name, err))
}
if len(lastErrs) == 0 {
// 走到这里说明 maxRetry=0 或 pool 一开始就空;由前面的 early return 处理,
// 留这里防御 future 改动。
return nil, nil, ErrNoChannels
}
return nil, nil, &AggregateError{Errors: lastErrs}
}
// Enable 重新载入渠道列表;阶段 2 管理接口用,阶段 1 不暴露。
func (r *Router) Enable(channels []Channel) {
cp := make([]Channel, len(channels))
copy(cp, channels)
r.mu.Lock()
r.channels = cp
r.mu.Unlock()
}
// enabledMatching 在锁内筛"启用 + voice 匹配"的 channel;供 SelectAndSynthesize 复用。
func (r *Router) enabledMatching(voice string) []Channel {
out := make([]Channel, 0, len(r.channels))
for _, c := range r.channels {
if !c.Enabled() {
continue
}
if !c.AcceptsVoice(voice) {
continue
}
out = append(out, c)
}
return out
}
// sortByPriorityDesc 按 priority 降序,同档按 id 升序;返回新切片。
func sortByPriorityDesc(in []Channel) []Channel {
out := make([]Channel, len(in))
copy(out, in)
// 简单插入排序;渠道数通常 1~10 个,O(n^2) 够用且无额外分配。
for i := 1; i < len(out); i++ {
for j := i; j > 0; j-- {
if out[j].Priority > out[j-1].Priority ||
(out[j].Priority == out[j-1].Priority && out[j].ID < out[j-1].ID) {
out[j], out[j-1] = out[j-1], out[j]
continue
}
break
}
}
return out
}
// topPriority 返回 channels 中 priority 最大的那些(可能有多个,同档内加权随机)。
func topPriority(channels []Channel) []Channel {
if len(channels) == 0 {
return nil
}
maxP := channels[0].Priority
for _, c := range channels[1:] {
if c.Priority > maxP {
maxP = c.Priority
}
}
out := make([]Channel, 0, len(channels))
for _, c := range channels {
if c.Priority == maxP {
out = append(out, c)
}
}
return out
}
// filterUntried 从 sorted 里挑"未在 tried 中的"channel;同档 / 跨档都适用。
func filterUntried(sorted []Channel, tried map[int64]struct{}) []Channel {
out := make([]Channel, 0, len(sorted))
for _, c := range sorted {
if _, ok := tried[c.ID]; ok {
continue
}
out = append(out, c)
}
return out
}
// pickByWeight 加权随机选一个 channel;weights 全 ≤0 时返 error(理论不该发生)。
//
// 实现:total = Σweight;r = rand.Intn(total);线性累加落桶。
// 同权时均匀(每个 channel 概率 = weight/total)。
// 抽成纯函数(接受 seed)便于单测。
func pickByWeight(channels []Channel, seed int64) (Channel, error) {
if len(channels) == 0 {
return Channel{}, errors.New("route: pickByWeight on empty slice")
}
total := 0
for _, c := range channels {
w := c.Weight
if w <= 0 {
w = 1 // 防御:同 Channel.Weight <=0 不应出现,但兜底按 1 处理
}
total += w
}
if total <= 0 {
return Channel{}, errors.New("route: pickByWeight total weight <= 0")
}
r := rand.New(rand.NewSource(seed))
r0 := r.Intn(total)
acc := 0
for _, c := range channels {
w := c.Weight
if w <= 0 {
w = 1
}
acc += w
if r0 < acc {
return c, nil
}
}
// 浮点边界不可达;留 fallback 返最后一个
return channels[len(channels)-1], nil
}
// Errors ---------------------------------------------------------------
// ErrNoChannels router 里没有任何 channel(零 channel 兜底触发条件)。
var ErrNoChannels = errors.New("route: no channels available")
// ErrVoiceNotFound 有 channel 但 voice 不被任一渠道支持(对应客户端 400)。
var ErrVoiceNotFound = errors.New("route: voice not supported by any channel")
// AggregateError 多个渠道都失败时,聚合各渠道错误返回。
// controller 用 errors.As 拿到后,可以在日志里逐个打印。
type AggregateError struct {
Errors []error
}
func (e *AggregateError) Error() string {
if len(e.Errors) == 0 {
return "route: all channels failed (no details)"
}
parts := make([]string, 0, len(e.Errors))
for _, err := range e.Errors {
parts = append(parts, err.Error())
}
return fmt.Sprintf("route: all %d channels failed: %s", len(e.Errors), joinComma(parts))
}
// Unwrap 让 errors.Is/errors.As 仍能透到最里层(便于上层判断特定错误类型)。
func (e *AggregateError) Unwrap() error {
if len(e.Errors) == 0 {
return nil
}
return e.Errors[0]
}
func joinComma(parts []string) string {
out := ""
for i, p := range parts {
if i > 0 {
out += "; "
}
out += p
}
return out
}
+7
View File
@@ -29,7 +29,14 @@ func NewHTTPClient() *HTTPClient {
// PostStream 发送一次流式请求,返回带上下文的 *http.Response。 // PostStream 发送一次流式请求,返回带上下文的 *http.Response。
// 调用方负责关闭 resp.Body。 // 调用方负责关闭 resp.Body。
//
// 空接收者(nil *HTTPClient)安全:返回错误而不是 panic。未初始化的 client 属于
// 装配错误,应当让该次请求以"上游不可用"失败(controller 归一成 5xx 并记日志),
// 而不是让整个进程崩掉。
func (h *HTTPClient) PostStream(ctx context.Context, url string, headers map[string]string, body []byte) (*http.Response, error) { func (h *HTTPClient) PostStream(ctx context.Context, url string, headers map[string]string, body []byte) (*http.Response, error) {
if h == nil || h.client == nil {
return nil, fmt.Errorf("volcano: HTTPClient 未初始化,无法发起上游请求")
}
if ctx == nil { if ctx == nil {
ctx = context.Background() ctx = context.Background()
} }
+6 -24
View File
@@ -1,27 +1,9 @@
package volcano package volcano
import "fmt" import "github.com/volcano-tts/tts-api/adapter/provider"
// UpstreamError 表示火山 v3 返回的 业务错误(code != 0 且 != 20000000)或传输错误。 // UpstreamError 是 provider.UpstreamError 的别名。
// 包含上游错误码,便于 telemetry 把它作为 label。 //
type UpstreamError struct { // 通用错误类型已上移到 provider 包,这样主干(controller)只需 import provider 即可
Code int // 判断上游错误,不必依赖具体适配器。火山包内部沿用旧名,构造处零改动。
Message string type UpstreamError = provider.UpstreamError
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
}
+128
View File
@@ -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())
}
+7 -33
View File
@@ -9,53 +9,27 @@ import (
"strings" "strings"
"time" "time"
"github.com/volcano-tts/tts-api/adapter/provider"
"github.com/volcano-tts/tts-api/common" "github.com/volcano-tts/tts-api/common"
"github.com/volcano-tts/tts-api/dto" "github.com/volcano-tts/tts-api/dto"
"github.com/volcano-tts/tts-api/telemetry" "github.com/volcano-tts/tts-api/telemetry"
) )
// MetricsRecorder 是适配器向上报告埋点的接口。 // synthesize 调用火山 v3 一次,返回组装好的结果(包内私有,由 provider.Provider.Synthesize 调用)。
// 适配器本身不依赖 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 一次,返回组装好的结果。
// //
// 入参: // 入参:
// - ctx:超时控制 // - ctx:超时控制
// - client:复用的 HTTPClient // - client:复用的 HTTPClient
// - opts:从 setting 构造的完整参数(text 字段会被 text 覆盖) // - opts:已还原好的完整参数(Text / SpeechRate 已由调用方填入)
// - text:本次合成的实际文本
// - clientFormat:客户端期望的最终格式,"wav" 内部转 pcm 后本地拼 wav 头 // - clientFormat:客户端期望的最终格式,"wav" 内部转 pcm 后本地拼 wav 头
// - speed:OpenAI 风格的 speed(倍率,0.5~2.0) // - mtr:埋点;调用方负责保证非 nil
// - mtr:可选埋点;传 nil 等价于 nopMetrics func synthesize(
func Synthesis(
ctx context.Context, ctx context.Context,
client *HTTPClient, client *HTTPClient,
opts Options, opts Options,
text string,
clientFormat string, clientFormat string,
speed float64, mtr provider.MetricsRecorder,
mtr MetricsRecorder,
) (*dto.SynthesisResult, error) { ) (*dto.SynthesisResult, error) {
if mtr == nil {
mtr = nopMetrics{}
}
opts.Text = text
opts.SpeechRate = convertSpeedToSpeechRate(speed)
reqID := newRequestID() reqID := newRequestID()
upstreamFormat := resolveUpstreamFormat(clientFormat) 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) 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", 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{ return &dto.SynthesisResult{
AudioData: finalData, AudioData: finalData,
+20
View File
@@ -0,0 +1,20 @@
package controller
import (
"github.com/volcano-tts/tts-api/adapter/route"
)
// routerInstance 持有当前可用的多渠道路由器;main 启动期调 SetRouter 注入。
// 阶段 1:启动期从 store.ChannelList 加载一次,运行期不变;阶段 2 管理接口上线后
// 会有 Reload 触发整体替换(届时 SetRouter 同样负责更新这个指针)。
//
// 注入失败 / 未注入时,GetRouter 返 nil —— 此时 OpenaiTTSHandler 走零渠道兜底路径
// (等价于"零 Channel 走 setting 默认 provider"),保持阶段 0 行为不变。
var routerInstance *route.Router
// SetRouter 注入 router 句柄;main 启动期调一次。
// 传入 nil 表示清空(回退到零渠道兜底),便于管理接口触发"禁用多渠道"操作(阶段 2)。
func SetRouter(r *route.Router) { routerInstance = r }
// GetRouter 拿当前 router 句柄;nil 表示未注入或被清空。
func GetRouter() *route.Router { return routerInstance }
+91 -19
View File
@@ -16,22 +16,27 @@ import (
// SettingsResponse 是 GET /api/settings 的响应。 // SettingsResponse 是 GET /api/settings 的响应。
// API key 永远打码(借用 setting.maskAPIKey 风格,前 4 后 4 中间 ****)。 // API key 永远打码(借用 setting.maskAPIKey 风格,前 4 后 4 中间 ****)。
type SettingsResponse struct { type SettingsResponse struct {
APIKey string `json:"api_key"` // 打码形式,例如 S_G8****naJ1 APIKey string `json:"api_key"` // 打码形式,例如 S_G8****naJ1
APIKeySet bool `json:"api_key_set"` // 是否已设置(用于前端判断要不要提示必填) APIKeySet bool `json:"api_key_set"` // 是否已设置(用于前端判断要不要提示必填)
AuthKey string `json:"auth_key"` // 鉴权 key 打码(客户端访问 + admin 登录用) AuthKey string `json:"auth_key"` // 鉴权 key 打码(客户端访问 + admin 登录用)
AuthKeySet bool `json:"auth_key_set"` AuthKeySet bool `json:"auth_key_set"`
CORSAllowAll bool `json:"cors_allow_all"` // 允许所有来源(*) // AdminKey 是**管理接口专用**凭证(v0.3.0 新增,可选)。
CORSOrigins string `json:"cors_origins"` // 逗号分隔的白名单(原文,含大小写,trim 末尾 /) // 为空表示未单独配置,管理接口回退用 auth_key(向后兼容)。
CORSConfigured bool `json:"cors_configured"` // 是否配了 CORS(给 banner 用) AdminKey string `json:"admin_key"` // 打码形式
AdminKeySet bool `json:"admin_key_set"` // 是否单独配置了 admin_key
AdminKeySource string `json:"admin_key_source"` // admin_key / auth_key / env / ""(未配置)
CORSAllowAll bool `json:"cors_allow_all"` // 允许所有来源(*)
CORSOrigins string `json:"cors_origins"` // 逗号分隔的白名单(原文,含大小写,trim 末尾 /)
CORSConfigured bool `json:"cors_configured"` // 是否配了 CORS(给 banner 用)
DefaultResourceID string `json:"default_resource_id"` DefaultResourceID string `json:"default_resource_id"`
DefaultSpeaker string `json:"default_speaker"` DefaultSpeaker string `json:"default_speaker"`
DefaultFormat string `json:"default_format"` DefaultFormat string `json:"default_format"`
SampleRate int `json:"sample_rate"` SampleRate int `json:"sample_rate"`
Model string `json:"model"` Model string `json:"model"`
ModelType int `json:"model_type"` ModelType int `json:"model_type"`
ExplicitLanguage string `json:"explicit_language"` ExplicitLanguage string `json:"explicit_language"`
EnableSubtitle bool `json:"enable_subtitle"` EnableSubtitle bool `json:"enable_subtitle"`
UpdatedAt string `json:"updated_at"` // RFC3339,来自 settings.installed_at(沿用) UpdatedAt string `json:"updated_at"` // RFC3339,来自 settings.installed_at(沿用)
} }
// SettingsGetHandler GET /api/settings // SettingsGetHandler GET /api/settings
@@ -58,6 +63,9 @@ func SettingsGetHandler(w http.ResponseWriter, r *http.Request) {
APIKeySet: all["api_key"] != "", APIKeySet: all["api_key"] != "",
AuthKey: maskAPIKeyField(all["auth_key"]), AuthKey: maskAPIKeyField(all["auth_key"]),
AuthKeySet: all["auth_key"] != "", AuthKeySet: all["auth_key"] != "",
AdminKey: maskAPIKeyField(all["admin_key"]),
AdminKeySet: all["admin_key"] != "",
AdminKeySource: setting.GetAdminKeySource(),
CORSAllowAll: all["cors_allow_all"] == "1" || all["cors_allow_all"] == "true", CORSAllowAll: all["cors_allow_all"] == "1" || all["cors_allow_all"] == "true",
CORSOrigins: all["cors_origins"], CORSOrigins: all["cors_origins"],
CORSConfigured: all["cors_allow_all"] == "1" || all["cors_allow_all"] == "true" || all["cors_origins"] != "", CORSConfigured: all["cors_allow_all"] == "1" || all["cors_allow_all"] == "true" || all["cors_origins"] != "",
@@ -298,10 +306,74 @@ func SettingsAuthKeyHandler(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true}) _ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
} }
// SettingsAdminKeyRequest 是 PUT /api/admin/settings/admin-key 的 body。
// admin_key 是**管理接口专用**凭证;与 auth_key(业务侧 /v1/audio/speech 鉴权)分离后,
// 业务调用方拿到的 key 不再能访问管理接口。
// 传空串表示"清除独立管理凭证",管理接口回退用 auth_key(即旧行为)。
type SettingsAdminKeyRequest struct {
AdminKey string `json:"admin_key"`
}
// SettingsAdminKeyHandler PUT /api/admin/settings/admin-key
// 鉴权: RequireAdmin(注意:能用当前凭证改,改完下一个请求即用新凭证)。
func SettingsAdminKeyHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
s := GetAdminStore()
if s == nil {
middleware.SendJSONError(w, http.StatusServiceUnavailable, "database not ready", "configuration_error", "db_not_ready")
return
}
r.Body = http.MaxBytesReader(w, r.Body, 1<<10)
var body SettingsAdminKeyRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
middleware.SendJSONError(w, http.StatusBadRequest, "invalid JSON body", "invalid_request_error", "bad_request")
return
}
key := strings.TrimSpace(body.AdminKey)
if key == "" {
// 清除独立管理凭证 → 回退 auth_key。回退后若 auth_key 也为空,
// 管理接口将全部 401(RequireAdmin 不再空凭证放行),这里必须挡住。
authKey, _, _ := s.SettingsGet("auth_key")
if authKey == "" {
middleware.SendJSONError(w, http.StatusBadRequest,
"admin_key cannot be cleared while auth_key is empty (would lock out admin access)",
"invalid_request_error", "missing_field")
return
}
if err := s.SettingsDelete("admin_key"); err != nil {
log.Printf("[settings] admin-key clear: %v", err)
middleware.SendJSONError(w, http.StatusInternalServerError, "clear admin_key failed", "server_error", "db_write_failed")
return
}
setting.SetAdminKeys([]string{authKey}, "auth_key")
log.Printf("[settings] admin_key cleared; admin auth falls back to auth_key")
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "admin_key_set": false, "admin_key_source": "auth_key"})
return
}
if err := s.SettingsSet("admin_key", key); err != nil {
log.Printf("[settings] admin-key set: %v", err)
middleware.SendJSONError(w, http.StatusInternalServerError, "write admin_key failed", "server_error", "db_write_failed")
return
}
// 立即生效:只单独刷新管理凭证列表,不重新 LoadRuntimeConfig(那会覆盖其它字段)
setting.SetAdminKeys([]string{key}, "admin_key")
log.Printf("[settings] admin_key updated, runtime active (next request uses new admin credential)")
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "admin_key_set": true, "admin_key_source": "admin_key"})
}
// SettingsCORSRequest 是 PUT /api/settings/cors 的 body。 // SettingsCORSRequest 是 PUT /api/settings/cors 的 body。
// 两个字段都可选(至少给一个),用指针区分"未传"和"传空串": // 两个字段都可选(至少给一个),用指针区分"未传"和"传空串":
// - allow_all 指针: nil=未传(不动) *true=开 *false=关 // - allow_all 指针: nil=未传(不动) *true=开 *false=关
// - origins 字符串: nil=未传(不动) ""=传空串(清空) "url1\nurl2"=覆盖 // - origins 字符串: nil=未传(不动) ""=传空串(清空) "url1\nurl2"=覆盖
//
// 这样用户能精确表达意图(保留 / 改 / 清空),不会被 0/"" 歧义坑死。 // 这样用户能精确表达意图(保留 / 改 / 清空),不会被 0/"" 歧义坑死。
type SettingsCORSRequest struct { type SettingsCORSRequest struct {
AllowAll *bool `json:"allow_all,omitempty"` AllowAll *bool `json:"allow_all,omitempty"`
@@ -386,10 +458,10 @@ func SettingsCORSHandler(w http.ResponseWriter, r *http.Request) {
log.Printf("[settings] cors updated (allow_all=%v origins=%q), runtime active", setting.GetCORSAllowAll(), originsStr) log.Printf("[settings] cors updated (allow_all=%v origins=%q), runtime active", setting.GetCORSAllowAll(), originsStr)
w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(map[string]any{ _ = json.NewEncoder(w).Encode(map[string]any{
"ok": true, "ok": true,
"allow_all": setting.GetCORSAllowAll(), "allow_all": setting.GetCORSAllowAll(),
"origins": originsStr, "origins": originsStr,
"cors_active": true, "cors_active": true,
}) })
} }
+1 -1
View File
@@ -244,7 +244,7 @@ func SetupSubmitHandler(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{ _ = json.NewEncoder(w).Encode(map[string]any{
"ok": true, "ok": true,
"message": "installed", "message": "installed",
"redirect": "/admin", "redirect": "/dashboard", // v0.3.0: 管理入口由 /admin 迁至 /dashboard
"settings": len(settingsKV), "settings": len(settingsKV),
"voices": inserted, "voices": inserted,
}) })
+122 -25
View File
@@ -3,6 +3,7 @@ package controller
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"log" "log"
@@ -11,7 +12,8 @@ import (
"strings" "strings"
"time" "time"
"github.com/volcano-tts/tts-api/adapter/volcano" "github.com/volcano-tts/tts-api/adapter/provider"
"github.com/volcano-tts/tts-api/adapter/route"
"github.com/volcano-tts/tts-api/common" "github.com/volcano-tts/tts-api/common"
"github.com/volcano-tts/tts-api/dto" "github.com/volcano-tts/tts-api/dto"
"github.com/volcano-tts/tts-api/installer" "github.com/volcano-tts/tts-api/installer"
@@ -24,12 +26,12 @@ import (
) )
var ( var (
volcanoClient *volcano.HTTPClient adapterRec provider.MetricsRecorder = metrics.AdapterRecorder{}
adapterRec volcano.MetricsRecorder = metrics.AdapterRecorder{}
) )
func InitController() { func InitController() {
volcanoClient = volcano.NewHTTPClient() // 上游 provider 在各自包 init() 里已注册(volcano 等),经 provider.Get 取用。
// 本函数保留以维持 main.go 的启动调用序列;不再持有具体 client。
} }
func truncateForLog(b []byte, max int) string { func truncateForLog(b []byte, max int) string {
@@ -49,7 +51,7 @@ func resolveClientFormat(reqFmt string) string {
} }
return strings.ToLower(reqFmt) return strings.ToLower(reqFmt)
} }
return setting.GetTTSOptions().Format return setting.GetDefaultFormat()
} }
// OpenaiTTSHandler 是 /v1/audio/speech 的入口。 // OpenaiTTSHandler 是 /v1/audio/speech 的入口。
@@ -151,15 +153,15 @@ func OpenaiTTSHandler(w http.ResponseWriter, r *http.Request) {
clientFormat := resolveClientFormat(req.ResponseFormat) clientFormat := resolveClientFormat(req.ResponseFormat)
opts := setting.GetTTSOptions() ttsReq := setting.GetTTSRequest()
opts.Text = req.Input ttsReq.Text = req.Input
ttsReq.Format = clientFormat
ttsReq.Speed = speed
// M3: voice 路由 // M3: voice 路由
// - voice 为空 → 走 LoadRuntimeConfig 解析过的 opts.Speaker (已是真 speaker ID, // - voice 为空 → 走 LoadRuntimeConfig 解析过的默认音色(GetTTSRequest 已带)
// default_speaker 是 voice 名,LoadRuntimeConfig 查 voice 表后替换) // - voice 非空 → 查 voices 表,替换 VoiceKey / resource_id / Model
// - voice 非空 → 查 voices 表,替换 opts.Speaker / ResourceID / Model // - 命中但 enabled=0 → 拒绝;未命中 → 400 "unknown voice: <name>"
// - 命中但 enabled=0 → 仍可用(用户显式传 voice 即覆盖 enabled 状态;若想禁用在 admin UI 关掉就行)
// - 未命中 → 400 "unknown voice: <name>"
if req.Voice != "" { if req.Voice != "" {
s := GetAdminStore() s := GetAdminStore()
if s == nil { if s == nil {
@@ -183,7 +185,7 @@ func OpenaiTTSHandler(w http.ResponseWriter, r *http.Request) {
"voice lookup failed", "server_error", "db_read_failed") "voice lookup failed", "server_error", "db_read_failed")
return return
} }
// 覆盖 opts(API key / UID 保留自 setting.GetTTSOptions 快照) // 覆盖音色与厂商凭证(API key / 其它私有参数保留自 GetTTSRequest 快照)
if !v.Enabled { if !v.Enabled {
log.Printf("警告: voice=%q 已禁用 - 客户端=%s", req.Voice, middleware.GetClientIP(r)) log.Printf("警告: voice=%q 已禁用 - 客户端=%s", req.Voice, middleware.GetClientIP(r))
middleware.SendJSONError(w, http.StatusForbidden, middleware.SendJSONError(w, http.StatusForbidden,
@@ -191,10 +193,10 @@ func OpenaiTTSHandler(w http.ResponseWriter, r *http.Request) {
"invalid_request_error", "voice_disabled") "invalid_request_error", "voice_disabled")
return return
} }
opts.Speaker = v.Speaker ttsReq.VoiceKey = v.Speaker
opts.ResourceID = v.ResourceID ttsReq.Credentials.Scope["resource_id"] = v.ResourceID
if v.Model != "" { if v.Model != "" {
opts.Model = v.Model ttsReq.Model = v.Model
} }
log.Printf("[tts] voice=%s 命中 (speaker=%s resource=%s model=%s) - 客户端=%s", 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)) req.Voice, telemetry.MaskSpeaker(v.Speaker), telemetry.MaskResourceID(v.ResourceID), v.Model, middleware.GetClientIP(r))
@@ -203,23 +205,103 @@ func OpenaiTTSHandler(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), setting.GetTTSTimeout()) ctx, cancel := context.WithTimeout(r.Context(), setting.GetTTSTimeout())
defer cancel() defer cancel()
result, err := volcano.Synthesis(ctx, volcanoClient, opts, req.Input, clientFormat, speed, adapterRec) // 阶段 1:多渠道路由分发
duration := time.Since(start) // - router 未注入(等于未配置 channels)→ 走 setting 兜底,行为=阶段 0
// - router 注入了但无任何 channel → 同样走 setting 兜底
// - router 注入了且有 channel → 走 SelectAndSynthesize:按 priority/weight 选
// 渠道,Channel.Credentials 整份覆盖 template.Credentials(意味着
// 上面 voice 路由对 Scope["resource_id"] 的覆盖会被 Channel 接管——
// 这是阶段 1 的设计取舍,Channel 自带完整账号凭据,voice 路由仅用于
// 解析上游 speaker ID → ttsReq.VoiceKey,凭据部分由 Channel 决定)
// - voice 不被任一 channel 接受 → router.Select 返 ErrVoiceNotFound,转 400
// - 所有 channel 都失败 → router 返 *route.AggregateError,转 500(日志逐个打印)
rtr := GetRouter()
if rtr == nil || rtr.Empty() {
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)
finalizeSynth(w, ttsReq, req.Input, clientFormat, result, err, duration, r.URL.Path)
return
}
// 选渠道路径:以"对外 voice 名"(req.Voice,可能为空表示用默认音色)为过滤键。
// 客户端未传 voice 时 req.Voice=="";Channel.Voices 接受一切(空白名单)时也会匹配。
// 这里需要先看 router 是否能接受这个 voice:
if _, selErr := rtr.Select(req.Voice); selErr != nil {
if selErr == route.ErrNoChannels {
// 中途被禁用 / 删空:降级到兜底,避免硬挂
prov, ok := provider.Get(setting.GetDefaultProviderName())
if !ok {
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)
finalizeSynth(w, ttsReq, req.Input, clientFormat, result, err, duration, r.URL.Path)
return
}
if errors.Is(selErr, route.ErrVoiceNotFound) {
log.Printf("警告: voice=%q 不被任何渠道支持 - 路径=%s 客户端=%s",
req.Voice, r.URL.Path, middleware.GetClientIP(r))
middleware.SendJSONError(w, http.StatusBadRequest,
fmt.Sprintf("voice '%s' is not supported by any channel", req.Voice),
"invalid_request_error", "voice_not_found")
return
}
log.Printf("警告: 渠道选择失败 - 错误=%v 路径=%s 客户端=%s",
selErr, r.URL.Path, middleware.GetClientIP(r))
middleware.SendJSONError(w, http.StatusInternalServerError,
"channel selection failed", "server_error", "route_error")
return
}
result, ch, err := rtr.SelectAndSynthesize(ctx, req.Voice, ttsReq, 3, adapterRec)
if err == nil && ch != nil {
log.Printf("[tts] 渠道选择命中 channel_id=%d name=%s provider=%s priority=%d weight=%d - 客户端=%s",
ch.ID, ch.Name, ch.Provider, ch.Priority, ch.Weight, middleware.GetClientIP(r))
}
duration := time.Since(start)
finalizeSynth(w, ttsReq, req.Input, clientFormat, result, err, duration, r.URL.Path)
}
// finalizeSynth 把"合成结果 → 响应 + metrics + 日志"三件套收敛到一处。
// 阶段 1 改造后,这个函数同时被 3 个分支调用:
// 1. 零 channel 兜底(直接调 setting 默认 provider)
// 2. Select 报 ErrNoChannels 降级(同上)
// 3. SelectAndSynthesize 完整路径
//
// 不再每次重复写 metrics/响应/日志,降低后续维护成本。
func finalizeSynth(
w http.ResponseWriter,
ttsReq provider.Request,
inputText string,
clientFormat string,
result *dto.SynthesisResult,
err error,
duration time.Duration,
urlPath string,
) {
finalLabels := telemetry.Labels{ finalLabels := telemetry.Labels{
"format": clientFormat, "format": clientFormat,
// speaker 是火山复刻音色 ID(用户付费资产),不能直接出现在 /metrics label 里 // speaker 是火山复刻音色 ID(用户付费资产),不能直接出现在 /metrics label 里
//(无鉴权可枚举)。用 sha1[:8] 替代:同 speaker 同 label 保留 per-voice 观测, //(无鉴权可枚举)。用 sha1[:8] 替代:同 speaker 同 label 保留 per-voice 观测,
//但反推不出原值。Admin UI 想要看原名通过 /api/voices 拿 name 字段。 //但反推不出原值。Admin UI 想要看原名通过 /api/voices 拿 name 字段。
"speaker": telemetry.SpeakerLabel(opts.Speaker), "speaker": telemetry.SpeakerLabel(ttsReq.VoiceKey),
"model": opts.Model, "model": ttsReq.Model,
} }
if err != nil { if err != nil {
finalLabels["status"] = classifyStatus(err) finalLabels["status"] = classifyStatus(err)
metrics.RequestTotal.Inc(finalLabels) metrics.RequestTotal.Inc(finalLabels)
metrics.RequestDuration.Observe(duration.Seconds(), telemetry.Labels{"status": finalLabels["status"], "format": clientFormat}) metrics.RequestDuration.Observe(duration.Seconds(), telemetry.Labels{"status": finalLabels["status"], "format": clientFormat})
log.Printf("警告: TTS 合成失败 - 路径=%s 客户端=%s 文本长度=%d 耗时=%v 错误=%v", log.Printf("警告: TTS 合成失败 - 路径=%s 文本长度=%d 耗时=%v 错误=%v",
r.URL.Path, middleware.GetClientIP(r), len(req.Input), duration, err) urlPath, len(inputText), duration, err)
middleware.SendJSONError(w, http.StatusInternalServerError, "TTS synthesis failed.", "server_error", "synthesis_failed") middleware.SendJSONError(w, http.StatusInternalServerError, "TTS synthesis failed.", "server_error", "synthesis_failed")
return return
} }
@@ -234,13 +316,13 @@ func OpenaiTTSHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
if n, err := w.Write(result.AudioData); err != nil { if n, err := w.Write(result.AudioData); err != nil {
// header 已发,无法改 status code;只记日志供排查(常见:客户端中途断开 → broken pipe / connection reset) // header 已发,无法改 status code;只记日志供排查(常见:客户端中途断开 → broken pipe / connection reset)
log.Printf("警告: 响应写入失败 - 路径=%s 客户端=%s 已写=%d/%d 错误=%v", log.Printf("警告: 响应写入失败 - 路径=%s 已写=%d/%d 错误=%v",
r.URL.Path, middleware.GetClientIP(r), n, len(result.AudioData), err) urlPath, n, len(result.AudioData), err)
} }
} }
func classifyStatus(err error) string { func classifyStatus(err error) string {
if ue, ok := err.(*volcano.UpstreamError); ok { if ue, ok := err.(*provider.UpstreamError); ok {
switch ue.Stage { switch ue.Stage {
case "request": case "request":
return "request_error" return "request_error"
@@ -274,6 +356,21 @@ func contentTypeFor(format string) string {
} }
// HealthHandler 暴露运行期状态;无鉴权。 // HealthHandler 暴露运行期状态;无鉴权。
// HealthzHandler GET /healthz —— 匿名存活探针,**只回 200 与字面量 "ok"**。
//
// 为什么单独做这个:v0.3.0 把详细健康数据(/health)收口到管理鉴权之后,
// 但 K8s liveness/readiness、Docker HEALTHCHECK、负载均衡健康检查默认都不带 Authorization。
// 若把它们继续指向 /health,加鉴权后会一律 401,导致探针失败、Pod 反复重启。
//
// 因此本端点刻意**不返回任何字段**(无版本、无内存、无配置状态、无模式信息),
// 只用于回答"进程还在不在"。运维要细节请走鉴权后的 /health。
func HealthzHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
}
func HealthHandler(w http.ResponseWriter, r *http.Request) { func HealthHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
+11 -3
View File
@@ -13,15 +13,23 @@ services:
- TTS_DB_PATH=/data/tts.db - TTS_DB_PATH=/data/tts.db
- PORT=8080 - PORT=8080
- OPENAI_TTS_API_KEY=${OPENAI_TTS_API_KEY:-} - OPENAI_TTS_API_KEY=${OPENAI_TTS_API_KEY:-}
# 可选,装好后也可在 /admin 改 # 可选,装好后也可在 /dashboard 改
- TRUSTED_PROXY_HOPS=${TRUSTED_PROXY_HOPS:-} - TRUSTED_PROXY_HOPS=${TRUSTED_PROXY_HOPS:-}
# v0.3.0 新增(可选):根路径 /metrics 的内网 CIDR 白名单,逗号分隔。
# 不配置时根路径 /metrics 完全不注册(Prometheus 请改用鉴权版 /api/admin/metrics)。
# ⚠️ 这里必须填「容器内网网段」,不要填 127.0.0.1/32 ——
# 那是容器自身回环,Prometheus 在宿主机/其他容器里根本进不来。
# 例:METRICS_ALLOW_CIDR=172.16.0.0/12
- METRICS_ALLOW_CIDR=${METRICS_ALLOW_CIDR:-}
volumes: volumes:
# DB + lock 文件必须挂载,否则容器重启后配置全丢 # DB + lock 文件必须挂载,否则容器重启后配置全丢
- tts-data:/data - tts-data:/data
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
# 用 wget 检查,alpine 镜像有 # v0.3.0:必须用 /healthz(匿名存活探针)。
test: ["CMD", "wget", "-qO-", "http://localhost:8080/health"] # /health 现在需要管理凭证,而健康检查不带 Authorization ——
# 继续用 /health 会让容器一直被判定为 unhealthy。
test: ["CMD", "wget", "-qO-", "http://localhost:8080/healthz"]
interval: 30s interval: 30s
timeout: 5s timeout: 5s
retries: 3 retries: 3
+438
View File
@@ -0,0 +1,438 @@
# Volcano-Engine-TTS-UI v0.3.0 阶段性实施步骤书
> **文件名**:`IMPLEMENT_v0.3.0.md`
> **基线**:`develop`
> **版本目标**:v0.3.0 —— 路由鉴权架构升级、健康/指标端点收口、管理入口统一到 `/dashboard`
> **关联文档**:[UPSTREAM_ADAPTER_GUIDE.md](UPSTREAM_ADAPTER_GUIDE.md)(上游适配器重构,与本改造正交,互不干扰)
>
> 本文是 v0.3.0 的**唯一实施依据**,由初版方案按实际代码基线校正而成。
> 校正说明见 [附录 A](#附录-a与初版方案的差异)。
---
## 实施进度
> **编号说明**:初版方案的"阶段 1(收紧鉴权)"与"阶段 2(收口端点)"属于同一次安全改造、
> 必须一起上线才有意义(只做前者是"有风险无收益"),已**合并为现在的「阶段 1」**,
> 后续阶段依次顺延。下表的阶段号均为合并后编号。
| 阶段 | 内容 | 状态 | 提交 |
|---|---|---|---|
| 1 | 收紧 `RequireAdmin` + 独立 `admin_key` + 启动期校验 + 收口健康/指标端点 + `/healthz` + `METRICS_ALLOW_CIDR` | ✅ 已完成 | 见下 |
| 2 | 路由分层 + 旧路径别名 + `/admin`→`/dashboard` | ✅ 已完成 | 见下 |
| 3 | 文档 / 示例配置 / Docker 注释 / CHANGELOG | ✅ 已完成 | 见下 |
| 4 | 全量回归测试(发布前) | ⏳ 待执行 | — |
**阶段 3 额外修复的两处部署陷阱**(阶段 1 给 `/health` 加鉴权引发的连带问题):
- `docker-compose.yml` 的 `healthcheck` 原指向 `/health`;该端点加鉴权后会一律 401,
导致**容器被判定为 unhealthy** → 已改为 `/healthz`
- `Dockerfile` 的 `HEALTHCHECK` 同样指向 `/health` → 已改为 `/healthz`
---
## 0. 改造目标
| # | 目标 | 验收标准 |
|---|---|---|
| 1 | **收口健康/指标端点**:`/metrics`、`/health`、`/dashboard` 不再匿名可读 | 未携带管理凭证时不可读到任何健康数据 |
| 2 | **补前置缺口**:`RequireAdmin` 在凭证未配置时不再静默放行 | 未配置凭证时管理接口拒绝访问,且启动期有明确信号 |
| 3 | **权限隔离**:业务调用凭证不得访问管理接口 | 用 TTS 调用 key 访问 `/api/admin/*` 返回 401 |
| 4 | 管理入口统一:`/admin` → `/dashboard`,旧路径 301 兼容 | 访问 `/admin` 301 到 `/dashboard` |
| 5 | API 路由分层:`/api/public/*`、`/api/user/*`、`/api/admin/*` | 三条前缀各自挂对应中间件,`/api/user/*` 为桩 |
| 6 | 存量管理接口迁移到 `/api/admin/*`,旧路径继续可用 | 新旧路径行为一致,老脚本零改动 |
| 7 | 两套用量查询占位接口 | `/api/user/usage`(管理鉴权)、`/v1/dashboard/billing/usage`(Bearer) |
| 8 | 文档 / 示例配置 / Docker 注释同步更新,标注 Breaking Change | README、`.env.example`、`docker-compose.yml`、CHANGELOG 一致 |
| 9 | **不改动** `adapter/`、`store/` 核心业务模型,**不破坏** `/v1/audio/speech` | TTS 业务接口回归通过 |
### 重要约束
- v0.3.0 **仍是单用户模式**;`/api/user/*` 仅骨架 + 桩实现,完整多用户能力放 v0.4.0。
- **不引入 Cookie 会话**(理由见 [附录 B](#附录-b为什么不做-cookie-会话))。管理鉴权继续使用
`Authorization: Bearer <key>`,前端继续用 `sessionStorage` 保存凭证。
- **每个阶段结束必须可编译、可运行、可本地回归**,拒绝大爆炸式一次性修改。
---
## 1. 现状基线(开工前必须知道的事实)
本项目的真实鉴权现状与初版方案的假设**不同**,照着初版做会返工。以下是核对过代码的结论:
| 事项 | 真实现状 | 代码位置 |
|---|---|---|
| 管理鉴权中间件 | **只有 `RequireAdmin`**,Bearer 请求头校验。**不存在 `AdminSessionMiddleware`** | `middleware/admin_auth.go:20` |
| 凭证存储 | 前端 `sessionStorage['ttsAdminKey']`,**无 Cookie** | `router/admin-shell.js:10-15` |
| 凭证来源 | 数据库 `auth_key` → 回退环境变量 `OPENAI_TTS_API_KEY` | `setting/config.go:384-392` |
| 凭证复用 | **同一个 key 既登录后台、又调 TTS 接口**(本次要拆开) | `middleware/auth.go` + `middleware/admin_auth.go` |
| 指标实现 | 自研 `telemetry` 包,**未引入 `prometheus/client_golang`**,无 `promhttp` | `router/router.go:136` |
| 客户端 IP | 已有 `middleware.GetClientIP`,支持 XFF + `TRUSTED_PROXY_HOPS` | `middleware/ratelimit.go:196` |
| CORS | 非白名单 Origin 在中间件层直接 403 | `middleware/cors.go:138-147` |
### 1.1 匿名可读端点(本次要收口的全部)
| 端点 | 泄漏内容 | 代码位置 |
|---|---|---|
| `GET /dashboard` | **HTML 看板,同时拉 `/health` + `/metrics`**,聚合度最高 | `router/router.go:120` |
| `GET /metrics` | Prometheus 全量指标(流量、模型名、错误分布、计费字符数) | `router/router.go:136` |
| `GET /health` | 版本 / commit / 运行时长 / 内存 / goroutine + 配置错误文本 | `router/router.go:119` |
| `GET /api/setup/status` | normal 模式下仍返回 `{installed, mode}` | `router/router.go:75` |
> `/api/setup/prefill` 在 normal 模式返回 404(handler 内自检),**不可达**,无需处理。
### 1.2 已有但必须保留的机制
- `InstallGuard`:安装模式下按白名单放行(`/setup`、`/api/setup`、`/health`、`/metrics`),
安装阶段无数据库、无凭证,**不得套用 `RequireAdmin`**。`middleware/installguard.go:19`
- 现有 `/api/admin/metrics` 已有 `RequireAdmin`,返回同样的 Prometheus 文本(保留复用)。
- 现有静态资源路由 `/admin/admin.css`、`/admin/admin-shell.js`(`router/router.go:90-97`),
迁移时必须同步处理,否则页面丢样式。
---
## 2. 分阶段实施
### 阶段 1|鉴权收紧 + 健康/指标端点收口(本版本核心)
> 本节由初版方案的「阶段 1(收紧 `RequireAdmin`)」与「阶段 2(收口端点)」**合并**而成。
> 合并原因:两者是同一件事的两半 —— 只做前者(收紧空凭证放行)而不同时收口端点,
> 结果是"有风险无收益"(旧部署可能起不来,而 `/health`、`/dashboard`、`/metrics` 仍匿名可读)。
> 下面 1.1–1.3 对应初版的阶段 1,1.4–1.6 对应初版的阶段 2。
**为什么先做这一步**:`RequireAdmin` 当前在凭证列表为空时**直接放行**(`middleware/admin_auth.go:29-33`)。
不修这个,后面给 `/metrics`、`/health` 套 `RequireAdmin` **等于没套**。
**改动范围**:`middleware/admin_auth.go`、`middleware/auth.go`、`setting/config.go`、`main.go`
#### 1.1 空凭证不再放行
将 `len(keys) == 0 → next.ServeHTTP` 改为拒绝 + 显式日志:
```go
keys := setting.GetAdminKeys()
if len(keys) == 0 {
log.Printf("[admin_auth] 拒绝访问:未配置管理凭证(admin_key / auth_key 均为空) - 路径=%s", r.URL.Path)
denyAdmin(w, r)
return
}
```
#### 1.2 新增独立 `admin_key`(权限隔离)
- `store`:新增可选的设置项 `admin_key`(**无需数据库迁移**,`settings` 是键值表)。
- `setting`:新增 `GetAdminKeys() []string`,取值优先级:
```
admin_key(DB) → 回退 auth_key(DB) → 回退 OPENAI_TTS_API_KEY(env)
```
- `RequireAdmin` 改用 `GetAdminKeys()`;**`middleware.ValidateAPIKey`(`/v1/audio/speech` 用)保持只看 `auth_key`**。
- 效果:配了 `admin_key` 后,业务调用凭证**无法**访问管理接口,隔离成立;不配则行为与旧版完全一致(向后兼容)。
#### 1.3 启动期校验(fail-fast)
在 `main.go` normal 模式分支加入:管理凭证为空时**拒绝启动**并打印清晰原因
(与现有配置损坏 fail-fast 风格一致)。
> ⚠️ **破坏性提示**:若你的部署目前未配置任何凭证,本阶段后服务将无法启动。
> 这是刻意的——此前那种状态下管理接口是完全裸奔的。请先配好 `auth_key`(或 `admin_key`)再升级。
#### 1.4 ✅ 鉴权部分回归
- [ ] `go build ./... && go vet ./...` 通过
- [ ] 配置 `auth_key` 后:`/api/admin/overview` 带正确 key 返回 200,错误 key 返回 401
- [ ] 不配置任何凭证时启动:进程拒绝启动并给出明确日志
- [ ] 配置独立 `admin_key` 后:用 `auth_key` 访问 `/api/admin/*` 返回 401
- [ ] `/v1/audio/speech` 用 `auth_key` 调用**不受影响**
---
#### 1.5 端点收口(本版本核心目标)
**改动范围**:`router/router.go`、`controller/tts.go`、`middleware/`(新增 CIDR 白名单)、`setting/config.go`
#### 1.5.1 `/health` 拆分为两个端点
| 端点 | 鉴权 | 返回 |
|---|---|---|
| `GET /healthz` | **无鉴权** | 仅 `200` + 字面量 `ok`,**不含任何字段** |
| `GET /health` | `RequireAdmin` | 现有完整 JSON(版本/内存/配置状态等) |
> ⚠️ **`/healthz` 必须与 `/health` 鉴权在同一次改动内落地**。分开部署会出现探针空窗,
> 导致 K8s liveness 失败、Pod 反复重启。
> 📌 探针迁移提醒:若现有 K8s `livenessProbe` / `readinessProbe` / Docker `HEALTHCHECK`
> 指向 `/health`,需同步改指 `/healthz`。README 中要写明。
#### 1.5.2 `/metrics` 收口 + 可选内网白名单
新环境变量 `METRICS_ALLOW_CIDR`(逗号分隔多个 CIDR):
- **已配置**:在根路径注册 `/metrics`,包裹 `MetricsIPAllowListMiddleware`;
非白名单 IP 返回 **404**(不返回 403,避免向扫描者确认端点存在)。
- **未配置**:根路径 `/metrics` **完全不注册**,访问 404。
- 两种情况都**始终**提供鉴权版 `GET /api/admin/metrics`(已存在,供面板使用)。
中间件实现要点:复用 `middleware.GetClientIP(r)`(已处理 XFF / `TRUSTED_PROXY_HOPS`),
用标准库 `net/netip` 解析 CIDR。
> ⚠️ **Docker 环境**:`METRICS_ALLOW_CIDR` 必须填**容器内网网段**(如 `172.16.0.0/12`),
> **不要填 `127.0.0.1/32`** —— 那是容器自身的回环,Prometheus 在宿主机或其他容器里进不来。
#### 1.5.3 `/dashboard` 与 `/api/setup/status` 收口
- `/dashboard`:套 `RequireAdmin`。它是本版本**单点泄漏最严重**的端点(一个 URL 暴露全部健康数据)。
- `/api/setup/status`:套 `RequireAdmin`。
- **前端状态提示**:`/dashboard` 的 HTML 外壳本身不敏感,前端登录判断逻辑保持现状
(`sessionStorage` 无 key → 显示登录视图)。**不在服务端做页面级跳转**,原因见 [附录 B](#附录-b为什么不做-cookie-会话)。
#### 1.6 ✅ 端点收口回归
- [ ] 未配置 `METRICS_ALLOW_CIDR`:根 `/metrics`、`/health` 返回 404
- [ ] 配置 `METRICS_ALLOW_CIDR=127.0.0.1/32`:本机可访问根 `/metrics`,其他 IP 返回 404
- [ ] `/healthz` 无鉴权可访问且**不含任何字段**
- [ ] `/health` 无凭证返回 401,带管理凭证返回完整 JSON
- [ ] `/dashboard` 无凭证返回 401
- [ ] `/api/setup/status` 无凭证返回 401
- [ ] 面板内 `/api/admin/metrics`、`/api/admin/health` 正常返回
---
### 阶段 2|路由分层与路径迁移
**改动范围**:`router/router.go`、`controller/`(新增两个桩 handler + 页面路由别名)
#### 2.1 分层路由注册
```go
apiPublic := r.PathPrefix("/api/public").Subrouter()
// 预留空壳,暂不注册业务
apiUser := r.PathPrefix("/api/user").Subrouter()
apiUser.Use(middleware.RequireAdmin) // 单用户模式下与 admin 等价,v0.4.0 再分化
apiAdmin := r.PathPrefix("/api/admin").Subrouter()
apiAdmin.Use(middleware.RequireAdmin)
```
将现有管理 handler 挂到 `apiAdmin`(**复用 handler 本体,不改业务逻辑**):
| 旧路径 | 新路径 |
|---|---|
| `GET/POST /api/voices` | `GET/POST /api/admin/voices` |
| `DELETE /api/voices/{name}` | `DELETE /api/admin/voices/{name}` |
| `PATCH /api/voices/{name}/toggle` | `PATCH /api/admin/voices/{name}/toggle` |
| `GET/PUT /api/settings` | `GET/PUT /api/admin/settings` |
| `PUT /api/settings/api-key` | `PUT /api/admin/settings/api-key` |
| `PUT /api/settings/auth-key` | `PUT /api/admin/settings/auth-key` |
| `PUT /api/settings/cors` | `PUT /api/admin/settings/cors` |
| `GET /api/admin/overview` | 位置不变 |
| `GET /api/admin/metrics` | 位置不变 |
#### 2.2 旧路径兼容:用**别名**,不要重定向
**旧路径指向同一个 handler,不做 301/308 跳转。**
理由(三者对比):
| 方案 | 问题 |
|---|---|
| 301 | 会把 POST/PUT/PATCH/DELETE **降级成 GET**(RFC 7231),且浏览器**永久缓存**,客户端察觉不到路径变化 |
| 308 | 保留方法和请求体,比 301 好;但多一次往返,对非浏览器脚本仍是行为变化 |
| **别名(采用)** | 两个路径直接可用,零往返、零破坏,老脚本完全无感 |
旧路径在当前版本**保留**,并在代码注释标注"过渡期别名,未来版本移除"。
#### 2.3 页面路由迁移
```go
// 新入口
r.HandleFunc("/dashboard", middleware.RequireAdmin(serveAdmin(dashboardHTML))).Methods("GET")
r.HandleFunc("/dashboard/login", serveAdmin(adminLoginHTML)).Methods("GET")
r.HandleFunc("/dashboard/voices", middleware.RequireAdmin(serveAdmin(adminVoicesHTML))).Methods("GET")
r.HandleFunc("/dashboard/settings", middleware.RequireAdmin(serveAdmin(adminSettingsHTML))).Methods("GET")
// 静态资源:必须同步迁移,否则面板丢样式/脚本
r.HandleFunc("/dashboard/admin.css", ...) // 原 /admin/admin.css
r.HandleFunc("/dashboard/admin-shell.js", ...) // 原 /admin/admin-shell.js
// 旧入口 301 永久重定向(页面用 301 正确)
r.HandleFunc("/admin", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/dashboard", http.StatusMovedPermanently)
}).Methods("GET")
```
前端同步改动(`router/*.html`、`admin-shell.js`):
- 页面间跳转与链接:`/admin` → `/dashboard`,`/admin/login` → `/dashboard/login` 等
- API 调用地址迁移到 `/api/admin/*`
- 静态资源引用 `/admin/admin.css` → `/dashboard/admin.css`(同理 `admin-shell.js`)
- `admin-shell.js` 的 401 拦截逻辑改为跳 `/dashboard/login`
> 📌 注意:`/dashboard` 原本是**服务状态预览页**(`router/health.html`)。迁入后该页面的定位
> 变成"管理后台首页",健康/指标数据改由 `/api/admin/health`、`/api/admin/metrics` 提供。
> 若仍希望保留独立的只读状态页,请单独确认(本版本不提供匿名版本)。
#### 2.4 桩接口
| 端点 | 鉴权 | 行为 |
|---|---|---|
| `GET /api/user/usage` | `RequireAdmin` | 单用户模式返回全局用量统计(桩,v0.4.0 改为按用户过滤) |
| `GET /v1/dashboard/billing/usage` | Bearer 业务 key | 返回用量统计,对齐 OpenAI 返回格式 |
#### 2.5 ✅ 阶段 2 回归
- [ ] `/admin` 301 跳 `/dashboard`;`/dashboard` 各页面与静态资源正常加载(无 404、无样式丢失)
- [ ] 面板内音色增删改、全局设置、CORS 配置全部正常
- [ ] 旧路径 `/api/voices`、`/api/settings` **直接可用**(不是重定向),行为与新路径一致
- [ ] `GET /api/user/usage`、`GET /v1/dashboard/billing/usage` 正常返回
- [ ] 用业务 Bearer key 访问 `/api/admin/*` 返回 401
- [ ] 完整安装流程跑通,装完跳转 `/dashboard`
---
### 阶段 3|文档、配置、发布
**改动范围**:`README.md`、`.env.example`、`docker-compose.yml`、`CHANGELOG.md`
1. **README.md**
- WebUI 入口改为 `/dashboard`;注明 `/admin` 为旧兼容重定向
- 端点表更新:`/metrics`、`/health` 默认 404;新增 `/healthz`、`METRICS_ALLOW_CIDR`、`admin_key` 说明
- 公网部署安全清单更新(整节重写,明确"哪些端点不鉴权、必须靠什么挡")
- 探针迁移提醒:K8s / Docker HEALTHCHECK 改指 `/healthz`
2. **`.env.example`**:新增 `METRICS_ALLOW_CIDR` 注释(含 Docker 网段警告)
3. **`docker-compose.yml`**:`METRICS_ALLOW_CIDR` 注释示例
4. **`CHANGELOG.md`**:v0.3.0 条目,明确列出 Breaking Change
#### Release Note · Breaking Change 清单
1. **`/metrics`、`/health`、`/dashboard` 不再匿名可读**。Prometheus 抓取请用
`/api/admin/metrics`(带 Bearer),或配置 `METRICS_ALLOW_CIDR`;
探针请改用 `/healthz`。
2. **未配置任何凭证时服务拒绝启动**(此前管理接口完全裸奔)。
3. **管理凭证与业务凭证可分离**:新增 `admin_key`;配了之后业务 key 无法访问管理接口。
4. **管理接口迁移到 `/api/admin/*`**;旧路径保留为**别名(非重定向)**,未来版本移除。
5. **管理入口迁移到 `/dashboard`**;`/admin` 301 永久重定向。
---
#### 阶段 4|全量回归测试(发布前必须全部通过)
> ⚠️ **本项目测试文件不入库**(`.gitignore` 的 `*_test.go`),CI 只能守住"编译 + vet",
> **守不住行为回归**。因此下面的手工回归是发布前的唯一防线,必须逐条执行并记录结果。
### ✨ 安装流程
1. 不设 `TTS_ADMIN_KEY` 启动 → 终端打印一次性密钥(带醒目警告)
2. 不带密钥 `POST /api/setup` → 401;带密钥 → 安装成功
3. 安装完成跳转 `/dashboard`;进程重启后旧一次性密钥失效
4. 已安装状态访问 `/setup` → 自动跳 `/dashboard`
### ✨ 管理面板
5. 未登录访问 `/dashboard` → 显示登录视图;登录后完整可用
6. 访问 `/admin` → 301 跳 `/dashboard`
7. 音色 CRUD、全局设置、CORS 修改全部正常
8. 仪表盘通过 `/api/admin/health`、`/api/admin/metrics` 渲染正常
9. 用量面板读取 `/api/user/usage` 正常
### ✨ 指标与健康端点
10. 不配 `METRICS_ALLOW_CIDR` → 根 `/metrics`、`/health` 返回 404
11. 配 `METRICS_ALLOW_CIDR=127.0.0.1/32` → 本机根路径可访问,其他 IP 404
12. `/healthz` 匿名可访问且**响应体不含任何业务字段**
### ✨ 业务接口
13. `/v1/audio/speech` OpenAI TTS 合成完全正常
14. `GET /v1/dashboard/billing/usage` 合法 key → 200;非法 key → 401
15. 业务 Bearer key 访问 `/api/admin/*` → 401(隔离生效)
### ✨ 存量兼容
16. 旧管理接口路径`/api/voices`、`/api/settings` 直接可用,行为与新路径一致
### ✨ 安全场景
17. 未配置凭证时服务拒绝启动
18. 匿名无法获取任何健康 / 指标数据
19. 旧 `tts.db` 直接复用升级,**无需数据库迁移**
### ✨ 本地自动化
20. `go build ./... && go vet ./...` 通过
21. `go test ./... -count=1` 全绿
(含抓出过 `VoiceList` 语义 bug 的那批集成测试,是本次改造最重要的安全网)
---
## 3. 发布 v0.3.0
1. 阶段 4 全部通过;Docker 镜像构建测试通过
2. `git tag v0.3.0` 并推送
3. 创建 Release,粘贴 Release Notes,**明确列出 5 条 Breaking Change**
4. 创建维护分支 `v0.3.x`,用于后续 bugfix 与安全补丁
5. `develop` 继续迭代 v0.4.0;上游适配器重构可并行
---
## 4. v0.4.0 后续规划(仅规划,不在 v0.3.0 实现)
v0.3.0 已把**路由分层**和**权限隔离**打好;v0.4.0 只需填充业务逻辑。
1. `store` 新增 `users` 表,区分普通用户 / 管理员角色
2. 引入真正的会话机制(此时才需要 Cookie + CSRF,或采用 Bearer-per-user)
3. `/api/user/*` 从桩转真实业务:个人用量、个人 API 密钥管理、个人调用日志
4. `/api/admin/*` 实现用户 CRUD、配额管理;音色归属绑定用户
5. SPA 扩展个人中心、密钥管理页面
6. `/v1/dashboard/billing/usage` 按 API-Key 归属过滤统计
---
## 5. 风险注意事项
| # | 风险 | 对策 |
|---|---|---|
| 1 | **`/healthz` 与 `/health` 加鉴权分批上线** → 探针空窗、Pod 重启循环 | 必须同一批改动内落地 |
| 2 | **未配凭证导致服务启动失败**(阶段 1 的破坏性结果) | 升级前先配 `auth_key`;README 与 Release Note 明确提示 |
| 3 | Docker 内 `METRICS_ALLOW_CIDR` 填 `127.0.0.1/32` | 必须填容器内网网段 |
| 4 | **禁止把 `RequireAdmin` 套到 `/setup`** | 安装阶段无数据库、无凭证,保留 `InstallGuard` |
| 5 | 旧路径若用重定向会导致 POST 降级 / 客户端无感 | 采用**别名**,不重定向 |
| 6 | 静态资源路由遗漏导致面板丢样式 | 迁移时同步 `/dashboard/admin.css`、`/dashboard/admin-shell.js` |
| 7 | **CI 跑不到测试**(测试文件不入库) | 每阶段结束本地执行 `go test ./... -count=1` |
| 8 | 误改 `adapter/`、`store/` 业务模型 | 本改造不涉及;改动仅限 router / middleware / controller / 前端 / 文档 |
---
## 附录 A|与初版方案的差异
| 初版方案 | 本版 | 原因 |
|---|---|---|
| 阶段 0 "重命名 `AdminSessionMiddleware`" | **删除该阶段** | 该中间件**不存在**;实为新建 Cookie 会话层,会把"低风险重构"变成破坏性变更 |
| 新增 `UserSessionMiddleware` | **不做** | 没有会话机制;`/dashboard` 页面请求不带 `sessionStorage`,服务端无从判断登录态 |
| 新增 `CSRFMiddleware` | **不做** | Bearer 认证免疫 CSRF(浏览器不会自动附带 `Authorization`);CORS 已在中间件层 403 非白名单 Origin |
| Cookie `HttpOnly` / `SameSite=Strict` | **不做** | 不使用 Cookie |
| `promhttp.Handler()` | `metrics.Meter.Handler()` | 项目未引入 `prometheus/client_golang` |
| `controller.AdminSPAPageHandler` / `AdminLoginPageHandler` | `serveAdmin(adminHTML)` 等 | 这两个 handler 不存在 |
| 漏点只列 `/metrics`、`/health` | 补入 **`/dashboard`**、`/api/setup/status` | `/dashboard` 聚合度最高;实测确认这两个也匿名可达 |
| 旧 API 路径 301 重定向 | **别名**,不重定向 | 301 会把 POST 降级为 GET 且被永久缓存 |
| 未提静态资源路由 | 明确 `/dashboard/admin.css` 等 | 遗漏会导致面板丢样式 |
| `RequireAdmin` 空凭证放行未处理 | **阶段 1 专门修复 + fail-fast** | 不修则后续所有鉴权改动形同虚设 |
### 初版保留不变的部分
分阶段 + 每阶段可编译可回归的原则、`InstallGuard` 保持不动、`/v1/dashboard/billing/usage`
走 Bearer、`/api/public` 与 `/api/user` 留桩、Docker CIDR 网段警告、v0.4.0 规划、发布流程。
---
## 附录 B|为什么不做 Cookie 会话
1. **不解决问题**:目标是"健康数据不外泄",用现有 `RequireAdmin` 即可 100% 达成,
不需要会话层。
2. **服务端拿不到 SPA 登录态**:前端凭证存 `sessionStorage`,**不随请求发送**,
所以服务端无法判断 `/dashboard` 页面请求是否已登录。初版要的"未登录访问 `/dashboard`
自动跳登录页"在服务端**做不到**——除非引入 Cookie,而这正是初版的前提缺失之处。
3. **CSRF 需求随之消失**:Cookie 才需要 CSRF 防护;Bearer + `sessionStorage` 天然免疫。
不做 Cookie 就不必实现 token 发放与轮换,也避开同源 SPA 下 `SameSite=Strict` 的坑。
4. **破坏现有客户端**:现有用 `Authorization: Bearer` 调管理 API 的脚本会全部 401,
与"给外部脚本过渡期"的目标冲突。
5. **真正的权限问题另有其解**:当前"一个 key 既是管理密码又是业务密钥"才是实质缺陷,
用新增可选 `admin_key` 即可分离,成本约 20 行,无需引入会话。
---
*文档版本:v2 · 校正基线:`develop` @ `49f57e4`(2026-10-04)*
*初版:v1(外部产出)· 校正原因见附录 A*
+140
View File
@@ -0,0 +1,140 @@
# v0.3.0 Release Notes
> 发布日期:待定 · 基线:`develop`
> 主题:**路由鉴权架构升级 · 健康与指标端点收口 · 管理入口统一**
---
## 一句话概括
v0.3.0 把**默认对外的监控面收窄到只有一个匿名存活探针**,
并把**管理凭证与业务调用凭证拆开**,同时把管理入口统一到 `/dashboard`。
业务接口 `/v1/audio/speech` 行为不变。
---
## ⚠️ Breaking Changes(升级前必读)
### 1. 监控端点不再匿名可读
| 端点 | v0.2.x | v0.3.0 |
|---|---|---|
| `/healthz` | 不存在 | **匿名可读**,只回 `200 ok`(不含任何字段) |
| `/health` | 匿名,返回完整健康数据 | **需管理凭证**,否则 401 |
| `/metrics`(根路径) | 匿名 | **默认 404**;需配置 `METRICS_ALLOW_CIDR` 才注册 |
| `/dashboard` | 匿名(服务状态预览页) | **需鉴权**(且已成为管理看板,见第 3 条) |
| `/api/setup/status` | 匿名 | **需管理凭证** |
**你需要做的**:
- **K8s 探针 / Docker HEALTHCHECK** 改指 **`/healthz`**。
继续指向 `/health` 会一律 401 → Pod 反复重启 / 容器被判 unhealthy。
(本仓库的 `docker-compose.yml` 与 `Dockerfile` 已同步修改。)
- **Prometheus** 二选一:
- 改用鉴权版 `/api/admin/metrics`,并在 `scrape_configs` 配
`authorization: { credentials: <管理凭证> }`;
- 或配置 `METRICS_ALLOW_CIDR` 走根路径白名单。
⚠️ Docker 中**不要填 `127.0.0.1/32`**(那是容器自身回环),
请填容器内网网段,例如 `172.16.0.0/12`。
### 2. 未配置管理凭证时服务拒绝启动
`RequireAdmin` 此前在凭证列表为空时**直接放行**(等于管理接口完全裸奔)。
现在该情况返回 401;并且 **normal 模式下若 `admin_key` / `auth_key` /
`OPENAI_TTS_API_KEY` 三者皆为空,服务将 fail-fast 拒绝启动**。
**你需要做的**:升级前确认三者至少配置一个,否则服务起不来。
### 3. 管理入口从 `/admin` 迁至 `/dashboard`
- `/dashboard`、`/dashboard/voices`、`/dashboard/settings` 为新入口
- 详细服务状态页迁至 **`/dashboard/status`**(原 `/dashboard` 是状态预览页)
- `/admin` 及子路径 **301 永久重定向**到对应新路径,书签与脚本请尽快迁移
### 4. 管理 API 正式路径改为 `/api/admin/*`
- 新:`/api/admin/voices`、`/api/admin/settings`(含 `api-key` / `auth-key` / `admin-key`)
- 旧:`/api/voices`、`/api/settings` **仍可用**(**别名**,不是重定向),计划后续版本移除
> 为什么用别名而不是 308:301 会把 `POST`/`PUT`/`PATCH`/`DELETE` 降级成 `GET`
> 且被浏览器永久缓存;308 虽保留方法,但对非浏览器脚本仍是行为变化。
> 别名零往返、零破坏,老脚本完全无感。
---
## ✨ 新特性
### 独立管理凭证 `admin_key`
管理后台登录凭证与业务调用凭证分离。取值优先级:
```
admin_key(DB) → auth_key(DB) → OPENAI_TTS_API_KEY(env)
```
- **不配置**:回退使用 `auth_key`,**行为与 v0.2.x 完全一致**
- **配置后**:业务调用方持有的 `auth_key` **无法访问管理接口**(返回 401)
配置方式:`PUT /api/admin/settings/admin-key`,或在 `/dashboard → 设置` 中操作。
凭证**只写不读** —— `GET /api/admin/settings` 只返回打码值与来源标记。
### 内网 CIDR 白名单 `METRICS_ALLOW_CIDR`
逗号分隔,支持裸 IP 自动补掩码。非白名单来源返回 **404**(而非 403,
避免向扫描者确认端点存在)。未配置时根路径 `/metrics` **完全不注册**。
### 匿名存活探针 `/healthz`
刻意**不返回任何字段**(无版本、无内存、无配置状态、无模式),只回答"进程还在不在"。
运维要细节请走鉴权后的 `/health` 或 `/api/admin/health`。
---
## 🔧 修复
- `RequireAdmin` 空凭证放行(安全缺口,见 Breaking Change 第 2 条)
- `/api/setup/status` 在 normal 模式下仍匿名暴露 `{installed, mode}`
- `docker-compose.yml` / `Dockerfile` 的健康检查端点(随鉴权改动失效)
- 指标写入对空接收者安全(`nil *Counter` / `*Gauge` / `*Histogram` 不再 panic)
- 上游 `HTTPClient` 未初始化时返回错误而非 panic
---
## ✅ 验证方式
```bash
go build ./...
go vet ./...
go test ./... -count=1
```
> 注意:本仓库测试源码**不入库**(`.gitignore` 的 `*_test.go`),
> 干净克隆上 `go test` 只会打印 `no test files`。
> 自动化测试需在开发机(测试文件所在处)执行。
> 发布前请按 [IMPLEMENT_v0.3.0.md](IMPLEMENT_v0.3.0.md) 第 3 节逐条手工回归。
端到端实测结果(真实服务器):
| 检查 | 结果 |
|---|---|
| `/healthz` 匿名 | `200` + 字面量 `ok` |
| `/health` 无凭证 / 带凭证 | `401` / `200` |
| 根 `/metrics` 未配 CIDR | `404`(不注册) |
| `/admin`、`/admin/login`、`/admin/voices`、`/admin/settings` | 全部 `301` 至对应 `/dashboard/*` |
| `/dashboard*` 浏览器请求 / 脚本请求 | `200` 页面外壳 / `401` |
| `/api/admin/*` 与 `/api/*` 别名 | 均 `200`(无 301/308) |
| 配独立 `admin_key` 后业务 key 访问管理接口 | `401` |
| 业务接口 `/v1/audio/speech` | 行为不变 |
---
## 📦 升级步骤
1. 确认已配置 `admin_key` / `auth_key` / `OPENAI_TTS_API_KEY` 至少一个
2. 更新探针指向 `/healthz`
3. 更新 Prometheus 抓取配置(鉴权版或 `METRICS_ALLOW_CIDR`)
4. 替换镜像 / 二进制并重启
5. 访问 `/dashboard` 登录验证;确认旧 `/admin` 书签仍能自动跳转
6. 按需在 `/dashboard → 设置` 配置独立 `admin_key`
**无需数据库迁移** —— 旧的 `tts.db` 可直接复用。
+343
View File
@@ -0,0 +1,343 @@
# UI 外包 Handoff · 火山 TTS 管理后台
> 历史文档。本文档描述的是**外包给 Codex 做视觉改造**那一次任务,目标是"现有功能完全不动, 只换皮"。
>
> ⚠️ **状态: 任务已结束, 且路线已变更。** 该任务之后, admin 后台由提交 `9245c13`
> 自行重构成**多页 SPA**(见下面 §0.1),本文档正文里的"单文件 / 大小上限 / 改这两个 .html"
> 等约束**均已失效**,保留仅为记录当时的交接内容与设计讨论。
> 若要再做视觉改造,请以 §0.1 的当前文件结构为准。
---
## 0.1 当前真实结构(以此为准)
admin 后台已从单文件拆分为多页面,共享一套 CSS/JS,由 `//go:embed` 逐条嵌入:
| 文件 | 作用 | 大小 |
|---|---|---|
| `router/admin.html` | 后台外壳(导航 / 布局) | 5.5KB |
| `router/admin-login.html` | 登录页 | 2.1KB |
| `router/admin-voices.html` | 音色管理页 | 13.5KB |
| `router/admin-settings.html` | 设置页 | 14.4KB |
| `router/admin.css` | 共享样式(全部页面) | 20.2KB |
| `router/admin-shell.js` | 共享脚本: axios 实例 / toast / 外壳挂载 / 401 拦截 | 7.1KB |
| `router/setup.html` | 安装引导页(仍是单文件) | 24.0KB |
- URL 路由(非 hash):`/admin` · `/admin/login` · `/admin/voices` · `/admin/settings`
- 公共逻辑集中在 `admin-shell.js`,通过 `window.admin` 暴露
`{ http, getKey, setKey, clearKey, toast, mountShell, formatUptime, … }`
- **已无"单文件 ≤ 35KB"这类约束**;新增页面请复用 `admin.css` + `admin-shell.js`,不要各写一套。
---
## 0. 任务概览(历史原文)
| 项 | 说明 |
|---|---|
| 改的文件 | `router/admin.html`、`router/setup.html` |
| 改的部分 | 视觉、布局、组件、交互模式(loading/empty/error/反馈)|
| 不改的部分 | API 调用、状态管理逻辑、字段名、文案语义 |
| 期望产物 | 改完后的两个 .html, 总大小 ≤ 64KB(admin ≤ 35KB, setup ≤ 15KB)|
| 验收 | dev 起服, 浏览器走完所有流程无回归 + 视觉明显更好 |
---
## 1. 技术栈约束(历史原文;"单文件"部分已失效)
| 项 | 现状 | 改动空间 |
|---|---|---|
| 构建 | **无构建步骤**(`//go:embed` 进 binary)| 不可改 |
| Vue | 3.4 via `cdn.bootcdn.net` | 可换版本(保持 3.x 即可)|
| HTTP | axios 1.6(集中在 `admin-shell.js`)| 可换(fetch / ky / 留 axios 都行)|
| CSS | 手写, 走 CSS 变量, 集中在 `admin.css` | 可换 **Tailwind CDN / DaisyUI / Pico / 手写** 都行 |
| 图标 | 暂无 | 可加 lucide / heroicons inline SVG / emoji |
| 字体 | 系统默认 (`-apple-system, "PingFang SC"...`) | 可加 `Noto Sans SC` via Google Fonts, 但要评估大小 |
| 路由 | 拆分后已改为 **URL 路由**(`/admin/voices` 等),不再是 hash 三 tab | 保持 URL 路由 |
**注意(历史约束,已不适用)**: 当时二进制大小敏感 —— admin.html 31.19KB / setup.html 13.01KB。拆分后 admin 侧总量约 63KB(分散在 6 个文件里,浏览器可分页只加载所需部分),embed 进 binary 后大小影响可接受。**仍然建议**: 不要无脑加 UI 库(Tailwind 全量 + DaisyUI 会显著增大 embed 体积);要加就加在 `admin.css` 一处,所有页面共享。
---
## 2. 路由 & 模式
### 2.1 三个 URL
- `GET /setup` → `router/setup.html`(安装模式才进得去)
- `GET /admin` → `router/admin.html`(任何模式都返回 HTML, 鉴权由前端 JS 拦截)
- `GET /` → 重定向到 `/setup` 或 `/admin`
### 2.2 状态机(admin.html)
```
未登录 (无 sessionStorage 'ttsAdminKey')
├─ 输入 key + 调 GET /api/admin/overview 验证
└─ 验证通过 → 写入 sessionStorage + 切到登录后视图
已登录
├─ 三个 tab: dashboard / voices / settings(hash 路由)
├─ 调任意 API 返 401 → 清 sessionStorage + 回到登录页
└─ 改完自己 auth_key → 调 PUT /api/settings/auth-key 200 后清 sessionStorage
```
### 2.3 状态机(setup.html)
```
未提交: 表单 4 必填 + voices 至少 1 条
└─ onMounted 调 GET /api/setup/prefill 自动填默认值
└─ 提交 POST /api/setup, 成功后 location.href = '/admin'
```
---
## 3. API 契约(前端用的全部)
### 3.1 鉴权
| 头 | 说明 |
|---|---|
| `Authorization: Bearer <key>` | 所有 /api/* 请求必带(除 /api/setup/*)|
| 401 响应 | 清 sessionStorage `ttsAdminKey`, 回到登录页 |
**axios 实例已配置**:
```js
const http = axios.create({ baseURL: '/api' });
http.interceptors.request.use(c => {
if (apiKey.value) c.headers.Authorization = 'Bearer ' + apiKey.value;
return c;
});
http.interceptors.response.use(r => r, err => {
if (err.response && err.response.status === 401) {
sessionStorage.removeItem('ttsAdminKey');
apiKey.value = '';
}
return Promise.reject(err);
});
```
### 3.2 端点
| Method | Path | 请求 body | 响应 | 鉴权 |
|---|---|---|---|---|
| GET | `/api/setup/prefill` | — | `{ settings: {default_resource_id, default_speaker, default_format, sample_rate} }` | 否(仅 install 模式)|
| POST | `/api/setup` | `{token, settings: {auth_key, api_key, default_resource_id, default_speaker, default_format, sample_rate}, voices: [{name, speaker, resource_id, model, language}]}` | `{ok, redirect, settings, voices}` | 否(仅 install 模式)|
| GET | `/api/admin/overview` | — | `{mode, installed, db_path, lock_path, version, commit, uptime_seconds, start_time, voice_count, voice_enabled_count, memory: {goroutines, heap_alloc, heap_inuse}}` | 是 |
| GET | `/api/admin/metrics` | — | Prometheus text | 是 |
| GET | `/api/voices` | — | `{voices: [{ID, Name, Speaker, ResourceID, Model, Language, Description, Enabled, CreatedAt, UpdatedAt}]}` | 是 |
| POST | `/api/voices` | `{name, speaker, resource_id, model, language?, description?}` | 创建的 voice 对象 | 是 |
| DELETE | `/api/voices/{name}` | — | `{deleted, ok}` | 是 |
| PATCH | `/api/voices/{name}/toggle` | `{enabled: bool}` | 更新后的 voice | 是 |
| GET | `/api/settings` | — | 完整 settings 对象(见 3.3)| 是 |
| PUT | `/api/settings` | 部分字段(`default_resource_id` / `default_speaker` / `default_format` / `sample_rate` / `model`)| `{ok, updated, updated_at}` | 是 |
| PUT | `/api/settings/api-key` | `{api_key: string}` | `{ok}` | 是 |
| PUT | `/api/settings/auth-key` | `{auth_key: string}` | `{ok}` | 是 |
| PUT | `/api/settings/cors` | `{allow_all?: bool, origins?: string}` | `{ok, allow_all, origins, cors_active}` | 是 |
### 3.3 GET /api/settings 完整响应
```json
{
"api_key": "volc****etup", // 打码
"api_key_set": true,
"auth_key": "my-o****etup", // 打码
"auth_key_set": true,
"cors_allow_all": false,
"cors_origins": "https://app.example.com\nhttps://other.com",
"cors_configured": true, // banner 显示控制
"default_resource_id": "volc.megatts.icl",
"default_speaker": "qian",
"default_format": "mp3",
"sample_rate": 24000,
"model": "seed-tts-2.0-standard",
"model_type": 0,
"explicit_language": "",
"enable_subtitle": false,
"updated_at": "2026-08-29T16:07:49Z"
}
```
### 3.4 错误响应格式
所有错误(除 OpenAI 兼容的 /v1/audio/speech)用同一格式:
```json
{ "error": { "code": "bad_request", "message": "...", "type": "invalid_request_error" } }
```
前端用 `e.response?.data?.error?.message` 提取.
---
## 4. 字段语义(写文案 / placeholder 用)
### 4.1 auth_key
"OpenAI 鉴权 Key"
- 客户端用这个 Key 调 `/v1/audio/speech`
- 登录 `/admin` 也用这个 Key
- 自定字符串, 不是火山给的, **完全自己造**
- 例: `my-secret-key-2024`
### 4.2 api_key
"火山 TTS API Key" / "上游 API Key"
- 调火山 v3 上游用的凭证
- 从 https://console.volcengine.com/ 获取
- 例: `5b4d7c2a-...`
### 4.3 default_resource_id
- 音色所属的计费资源 ID
- `volc.megatts.default` 默认音色 / `volc.megatts.icl` 复刻音色
### 4.4 default_speaker
- 未传 `voice` 字段时用
- 必须是"音色" tab 里已添加的 voice name
### 4.5 voices 字段
- `name`: 短英文 id, 唯一, 不可改
- `speaker`: 火山给的 speaker id
- `resource_id`: 同上 default_resource_id
- `model`: `seed-tts-2.0-standard` (默认) / `seed-tts-2.0-expressive` / `seed-tts-2.0-clone`
- `language`: ISO 639-1 (`zh` / `en` / `ja` 等)
### 4.6 CORS origins
- 一行一个 origin
- 必须 `http://` 或 `https://` 开头
- 末尾 `/` 自动 trim
---
## 5. 当前 UI 的具体问题(按痛感排序)
### 5.1 admin.html
| # | 问题 | 现状 | 期望 |
|---|---|---|---|
| 1 | **登录页太空** | 一个输入框 + 一个按钮 | 加 logo / 提示 / 服务名 / 安装状态提示 |
| 2 | **dashboard 卡片平** | 4 个数字 + 路径文本, 无图标无图表 | 加 icon, 用 chart mini sparkline (uptime / memory)|
| 3 | **voices 表格简陋** | 5 列 + 启停 switch + 删除 | 加排序/搜索/批量操作/分页/拖拽改顺序 |
| 4 | **新增 voice 弹窗原始** | 6 个 input 堆叠 | 分组, 加 help text, 加示例值, 加实时校验 |
| 5 | **设置页 1 长页** | 4 段串成 1 长 list | 分 tab/分卡片, 加描述/帮助链接/危险操作分离 |
| 6 | **改 key 后无 feedback** | 改了直接清 session | 加 toast 通知 "Key 已更新, 请用新 Key 重新登录" |
| 7 | **删除无 undo** | confirm 弹原生 confirm() | 加 inline 二次确认 / undo snackbar |
| 8 | **loading 无** | 调 API 时按钮不变 | 加 spinner / disabled 状态 / 进度条 |
| 9 | **error 无专门显示区** | 用 `actionErr.value` 全局串 | 用 toast / inline error / 错误码对照 |
| 10 | **响应式差** | desktop only | 至少平板能用, 关键操作 (tab 切换) 移动端可点 |
| 11 | **配色单调** | 1 个 cyan 强调色 | 加二级色, success/warning/danger 用得不够明显 |
| 12 | **字体单薄** | 14px 全局 | 大字标题 24-32px, 重要数字 monospace 突出 |
### 5.2 setup.html
| # | 问题 | 现状 | 期望 |
|---|---|---|---|
| 1 | **4 字段加 voice 列表一锅烩** | 一长页 | 分步骤 (1.凭证 2.默认 3.音色 4.确认) |
| 2 | **voice 行无复用 UI** | 自己手写的 row | drag-to-reorder + 复制/粘贴/从预置模板加 |
| 3 | **无进度感** | 一个 "安装" 按钮 | 安装中显示进度 (写 settings → 写 voices → 写 lock) |
| 4 | **无"安装后会发生什么"** | 提交后只跳 /admin | 加结果页: 装了什么 / 接下来去 admin 干啥 |
| 5 | **无 reset** | 改了一锅填错没法批量重置 | "恢复默认值" 按钮 |
| 6 | **移动端糟糕** | 1 长页 | 至少 step indicator 在窄屏可见 |
### 5.3 共性问题
| # | 问题 | 期望 |
|---|---|---|
| 1 | 无 dark/light toggle | 加 toggle, 默认跟随系统 |
| 2 | 无键盘快捷键 | `/` 聚焦搜索, `Esc` 关弹窗, `Ctrl+S` 保存当前表单 |
| 3 | 无国际化 | 默认 zh-CN, 可加 i18n 但不强制 |
| 4 | 无 a11y 标签 | aria-label, focus ring, 键盘可达 |
---
## 6. 设计参考(建议风格, 不是强制)
| 风格 | 适配场景 | 推荐度 |
|---|---|---|
| Linear / Vercel dashboard | 后台工具, dark mode 友好 | ⭐⭐⭐⭐⭐ |
| Tailscale admin | 简洁, 卡片化, 信息密度高 | ⭐⭐⭐⭐ |
| Coolify / CasaOS | 自托管, "家用服务器"感 | ⭐⭐⭐ |
| shadcn/ui | 现代 React 风, 移植到 Vue 略费劲 | ⭐⭐⭐ (参考设计, 不直接用) |
| Tailadmin | 通用 admin 模板, 选段借鉴 | ⭐⭐ |
**主调色建议** (如果重做):
- 背景: 深色 (`#0a0e1a`) 或浅色 (`#f8fafc`), 配 system pref
- 强调色: cyan / indigo / emerald 之一
- 字体: 标题用 Inter, 数字用 JetBrains Mono
---
## 7. 不要做的事
- ❌ 改 API 路径 / 改字段名 / 改字段语义
- ❌ 加任何状态管理库 (Pinia) — 单文件 setup() 够用
- ❌ 加 webpack/vite — 必须保持单文件
- ❌ 引大 UI 库 (Element Plus / Naive UI) — 单文件塞不下
- ❌ 把 admin.html 和 setup.html 合并 — 它们是独立页面
- ❌ 改 axios 实例的 baseURL 或 401 拦截逻辑
- ❌ 改 hash 路由的 key 命名(`#dashboard` / `#voices` / `#settings`)
- ❌ **不要把单文件架构当最终形态** — 这是最小可工作版本(MVP),看板有 `ui-arch-debt` 卡片记录拆分计划。Codex 改造时保持单文件以便本次走通,但**不要引入新依赖让单文件更难拆**。例如:不要把 axios 拦截器写得依赖 10 个全局 ref;不要让 CSS 选择器深嵌套到无法抽离。
---
## 8. 验收清单(已按拆分后的结构更新)
改完回来, 我会逐项过:
- [ ] `go build ./...` 通过
- [ ] `go vet ./...` 通过
- [ ] `go test ./... -count=1` 全绿
- [ ] dev 起服后浏览器:
- [ ] `/setup` 流程能跑完
- [ ] `/admin` 登录 → dashboard → voices → settings → 增改删全通
- [ ] 改 auth_key 后自踢 → 重新登录 → 能用新 key
- [ ] CORS 未配时顶部 banner 出现, 配后消失
- [ ] 401 错误时自动跳回登录
- [ ] 视觉: 跟现在的 admin 比有明显提升 (logo / 排版 / 颜色 / 反馈)
- [ ] 至少在 1024px 宽度下好看
- [ ] 没有 console error / 404
- [ ] 新增页面复用了 `admin.css` + `admin-shell.js`,没有复制粘贴出第二套公共逻辑
> 旧的"`admin.html` ≤ 35KB / `setup.html` ≤ 15KB"两条**已作废**:admin 已拆成多页,
> 单文件大小不再是约束。
---
## 9. 回填方式(历史原文) / 当前做法
历史原文:
```
1. 拿回 router/admin.html + router/setup.html
2. 放到 D:\项目\Volcano-Engine-TTS-UI\router\
3. 我 build + 测, 通过后 commit
4. push, 服务器自动部署
```
拆分之后的做法:改动落在 §0.1 表格里对应的**具体文件**上(外壳/登录/音色/设置,以及共享的
`admin.css` / `admin-shell.js`),不再有"两个 .html"这一说。改完在**项目里**跑:
```
go build ./...
go vet ./...
go test ./... -count=1
```
三条全过再 commit 到 `develop`。
---
## 10. 关联文件(不需要改, 但可读懂逻辑)
- `controller/admin.go` — admin API 实现
- `controller/settings.go` — settings API 实现
- `controller/setup.go` — setup API 实现
- `router/router.go` — Go 路由 + 挂载点(看 `//go:embed` 用法)
- `router/admin-shell.js` — 共享 axios 实例 / toast / 外壳挂载(改交互先看这里)
- `middleware/admin_auth.go` — RequireAdmin 实现(理解 401 怎么来)
---
## 11. 当前文件位置(已更新)
| 文件 | 大小 |
|---|---|
| `router/admin.html` | 5.5KB |
| `router/admin-login.html` | 2.1KB |
| `router/admin-voices.html` | 13.5KB |
| `router/admin-settings.html` | 14.4KB |
| `router/admin.css` | 20.2KB |
| `router/admin-shell.js` | 7.1KB |
| `router/setup.html` | 24.0KB |
`ui-handoff/` 目录里保留的是**改造前**的单文件副本与离线 mock 预览,仅供对照,不要回填。
+508
View File
@@ -0,0 +1,508 @@
# 上游适配器开发指南
> 面向对象:要为本项目接入**新上游 TTS 服务**的开发者(也包括未来的你)。
> 阅读前提:知道 OpenAI `/v1/audio/speech` 的请求/响应形状,能读 Go。
>
> ⚠️ **本文档分两部分,请先看清区别**:
> **第一部分「现状」描述的是代码里已经存在的东西**,可以直接照着读代码。
> **第二部分「目标架构」分阶段落地**。§3 的 Provider 抽象与 §4 的**阶段 0 已完成落地**
> (2026-10-11),代码里**已有** `adapter/provider` 包;§4 的**阶段 1~5**(库表、配置命名空间、
> metrics 加 provider 维度、管理界面、第二个适配器)仍是**待实施**的提案。
> 读接口时以 `adapter/provider/*.go` 为准,本文 §3 是它的文字说明。
---
## 1. 愿景与现状
### 1.1 愿景
把本服务做成**多渠道多上游聚合层**:对外始终是一个 OpenAI 兼容接口,
对内可以挂火山、OpenAI、Azure、阿里云、自建 GPT-SoVITS / IndexTTS……等任意上游。
调用方不需要知道音频是哪个厂商合成的 —— 换上游对客户端应当**透明**。
### 1.2 现状(第一部分)
目前**只有火山一个适配器**,且不是"插件式"的,而是**硬编码进主干**的。
这一点必须说清楚,因为它决定了新上游不能"只写一个文件就完事"。
```
入口 controller/tts.go OpenaiTTSHandler
│
│ opts := setting.GetTTSOptions() ← 类型是 volcano.Options
│ volcano.Synthesis(ctx, volcanoClient, opts, ...) ← 直接调实现,无接口
▼
适配器 adapter/volcano/ Options / Synthesis / ParseStream / UpstreamError
▲
│ setting.LoadRuntimeConfig 直接构造 volcano.Options / volcano.Additions
配置 setting/config.go
```
**关键事实**:主干里有一处 `import "…/adapter/volcano"`,不是注解式的,
而是**类型级耦合**。所以"多上游"不是加文件,而是一次重构(见 §4)。
当前只有火山适配器时,以下代码是合理的;接第二个上游时必须逐个处理,
否则会出现"配置是火山的、路由是 OpenAI 的"这种错配。
---
## 2. 现有火山适配器契约(照此实现即可)
新适配器要能替代火山适配器,就必须满足下面这些**已经被主干依赖**的契约。
先读这一节,再读 §3 的接口设计。
### 2.1 包的职责划分
| 文件 | 职责 | 新适配器对应物 |
|---|---|---|
| `adapter/volcano/options.go` | 调用参数集合(`Options`)+ 上游扩展参数(`Additions`)+ `IsZero()` | provider 私有 opts |
| `adapter/volcano/request.go` | `Options` → 上游请求体;`buildRequest` 做必填校验;`convertSpeedToSpeechRate` 做 OpenAI speed 换算 | 请求构造 |
| `adapter/volcano/client.go` | 复用连接的 `*HTTPClient`(keep-alive 调优);`PostStream` 发流式请求 | 传输层 |
| `adapter/volcano/synthesis.go` | **唯一入口** `Synthesis(...)`;编排 埋点 → 构造 → 发送 → 解析 → 收尾 | `Synthesize(...)` |
| `adapter/volcano/response.go` | `ParseStream` 解析 NDJSON 流;`ParsedStream` 累计结果 | 响应解析 |
| `adapter/volcano/errors.go` | `UpstreamError{Code,Message,Stage,Wrapped}` + `IsAuth()` | 错误类型 |
| `adapter/volcano/audio.go` | `WrapWAVHeader` —— 上游只给 PCM 时本地拼标准 wav 头 | 格式收尾 |
### 2.2 主干对适配器的硬性依赖(必须逐条满足)
| # | 主干行为 | 位置 | 新适配器必须 |
|---|---|---|---|
| 1 | 用包级 `*HTTPClient` 发请求,连接复用 | `controller/tts.go:27,32` | 提供可复用的 client,不要每次 new |
| 2 | 可用 `ctx` 控制超时 | `controller/tts.go:203` | `Synthesize` 必须接受 `context.Context` |
| 3 | 埋点接口 **3 个方法** | `adapter/volcano/synthesis.go:20-24` | 见 §2.3 |
| 4 | 错误按 `Stage` 分类成 status label | `controller/tts.go:242-256` | 错误必须携带阶段:`request`/`http`/`stream`/`wrap` |
| 5 | 错误码进 metrics label | `metrics/metrics.go:160-172` | 非零 code 会被聚合成 `client`/`server`/`upstream` |
| 6 | 返回结果结构 | `dto.SynthesisResult` | 见 §2.4 |
| 7 | 上游不支持的输出格式要**降级** | `synthesis.go:63-65`、`README` 格式表 | 见 §2.5 |
### 2.3 埋点接口(MetricsRecorder)
适配器**不 import telemetry 包**,而是由主干注入一个接口实现
(`controller/tts.go:28` 注入 `metrics.AdapterRecorder{}`)。这是刻意的解耦,新适配器请沿用。
```go
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)
}
```
现状实现里 `speaker` 是**语料无关的上游音色 ID**,会被 `telemetry.SpeakerLabel`
做 `sha1[:8]` 哈希后才当 label(隐私保护,见 `metrics/metrics.go:127`)。
新适配器传自己的音色标识即可,主干不解释它的含义。
`status` 的取值约定(沿用即可,聚合看板依赖它们):
`ok` / `request_error` / `transport_error` / `http_<code>` / `stream_error` / `wrap_error`。
### 2.4 返回结果(dto.SynthesisResult)
```go
type SynthesisResult struct {
AudioData []byte // 最终可直接回给客户端的音频字节(已收尾,如 wav 拼好头)
Format string // 真实输出格式,决定 Content-Type
SampleRate int
ReqID string // 回写 X-Request-Id,排障用
TextWords int // 上游计费字符数(取不到填 0)
Chunks int
AudioBytes int
TTFB time.Duration // 首字节耗时
Duration time.Duration
}
```
`Format` 必须是主干认识的取值(见 `controller/tts.go:258` `contentTypeFor`):
`mp3` / `wav` / `pcm` / `ogg_opus`(别名 `opus`)。填别的会导致 Content-Type 错。
### 2.5 格式降级规则(容易踩)
客户端要的 `response_format` 和上游能给的格式**往往不一致**,必须显式降级:
| 客户端要 | 火山做法 | 为什么 |
|---|---|---|
| `wav` | 上游请求 `pcm`,本地 `WrapWAVHeader` 拼头 | 流式 wav 每 chunk 带独立头,拼接即坏 |
| `aac` / `flac` | **降级成 mp3**,Content-Type 报 `audio/mpeg` | 火山不支持 |
| `opus` | 上游 `ogg_opus` | 命名差异 |
新适配器同样要回答这个问题:**你要的格式上游给不了时,降级到什么?**
并且必须在 `Format` 字段里如实回报真实格式,否则客户端拿到的是"声明 mp3 实际 pcm"。
### 2.6 实测踩过的坑(上游协议层面)
这些是火山适配器真实修过的 bug,接新上游时同类问题大概率复现:
1. **事件分发必须白名单**:`ParseStream` 只把 `event == "sentence"` 或事件为空的帧
当音频,未知事件只记日志**绝不拼进音频**(`response.go:71-113`)。
早期实现把 `TTSSubtitle` 也当音频,导致音频流被污染。
2. **结束帧要提前 break**:上游用 `code == 20000000` 表示流正常结束,收到后要
把剩余行读完再 break,否则连接不释放。
3. **单行可能很长**:`scanner.Buffer` 上限要放大(火山设到 8MB),否则大 chunk 会 EOF。
4. **所有音频帧 base64 解码失败要**返回错误,**不能跳过** —— 跳过等于静默产出半截音频。
5. **一个字节音频都没收到要报错**,不能返回空切片当成功(`response.go:125-131`)。
6. **错误响应体可能含换行**:直接拼进错误消息会伪造日志行,火山做了
`\n`/`\r` 转义(`synthesis.go:99-101`),新适配器照做。
---
## 3. 目标架构:Provider 抽象(阶段 0 已落地)
以下代码已随阶段 0 落地,见 `adapter/provider/provider.go` 与 `adapter/provider/registry.go`。
新增上游(阶段 5)将严格按此接口实现。
### 3.1 接口定义
落地于 `adapter/provider/provider.go`(独立包,**不放在** `adapter/volcano` 里,
避免主干继续依赖具体实现)。**注**:阶段 0 还顺带把通用上游错误 `UpstreamError`
一并迁入 `provider` 包,火山适配器通过类型别名 `type UpstreamError = provider.UpstreamError`
沿用旧名(见 `adapter/volcano/errors.go`),controller 判断上游错误不再依赖具体实现。
```go
// 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)
}
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
}
type Credentials struct {
APIKey string
// 厂商额外凭证/路由维度。火山: resource_id;Azure: region;自建: base_url。
Scope map[string]string
}
```
### 3.2 注册表
```go
// adapter/provider/registry.go
func Register(p Provider) // 通常在包的 init() 里调用
func Get(name string) (Provider, bool)
func Names() []string // admin UI 下拉框用
```
**要点**:注册要放在**主干之外**。建议 `main.go` 显式空导入:
```go
import (
_ "github.com/volcano-tts/tts-api/adapter/volcano" // 注册 volcano
_ "github.com/volcano-tts/tts-api/adapter/openai" // 注册 openai
)
```
这样主干只依赖 `provider` 包,**新增上游不需要改主干任何一行** —— 这是本架构的核心收益,
请务必守住。`controller/tts.go` 里不应再出现 `adapter/volcano`。
---
## 4. 分阶段实施计划
**强烈建议按阶段做,每阶段结束都能编译、能跑、行为不变。**
一次性重构"配置 + 路由 + 库表 + 前端 + metrics"五个面,几乎必然做出半成品。
### 阶段 0 · 抽出接口,零行为变更(纯重构) — ✅ 已完成 2026-10-11
目标:主干不再依赖具体实现,但**行为完全不变**,回归风险最低。
- [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)
> 本阶段**未**动库表、未动前端。验收目标达成:`controller` 里那行
> `import adapter/volcano` 已消失;仅 `setting`(阶段 2 消除)与 `main.go` 空导入仍提及。
### 阶段 1 · 数据结构:provider 维度入库
> ⚠️ **先纠正一个常见误解:本项目的迁移框架其实没有接线。**
> `store/migrate.go` 里定义了 `Migration` 结构体和 `migrations` 切片,但那个切片是
> **空的**,`store/db.go` 的 `migrate()` 也从**不**读取它 —— 注释里写得很明白:
> "本期 M0 阶段 schemaVersion=1,migrate() 在 db.go 内做基础建表,**未触发 migrations 调度**"。
> 所以 `migrate()` 目前**只做 `CREATE TABLE IF NOT EXISTS`**,没有任何版本比较,
> 对已存在的表**完全不做变更**。
>
> 还有一个既有瑕疵:`db.go:148-150` 的 `INSERT OR IGNORE INTO schema_version (version) VALUES (?)`
> 语句里既没有字面量也没传参,绑定到 `?` 的是**零值**,所以表里实际写入的版本号是 **0**,
> 不是常量 `schemaVersion`(=1)。做 v2 迁移时请一并修掉,否则版本判断从一开始就是错的。
>
> **结论**:给 `voices` 加列**不会**自动发生,你必须自己写 `ALTER TABLE`,
> 并且在启动时能被重复执行而不报错(老库、新库都要能跑)。
本阶段要做:
- [ ] 在 `migrate()`(或新启用的迁移调度)里实现**幂等**加列:
先 `PRAGMA table_info(voices)` 查现有列,缺哪个 `ALTER TABLE ... ADD COLUMN` 哪个
(SQLite 的 `ADD COLUMN` 不支持 `IF NOT EXISTS`,重复执行会报错,必须先查)
- [ ] `voices` 表加列(见 §5.1),老库回填 `provider='volcano'`
- [ ] 修正 `schema_version` 写入零值的瑕疵,并写出真实版本号
- [ ] 若确实要启用 `migrate.go` 的调度框架,则把 `schemaVersion`(db.go,`=1`)
与 `schemaVersionRequested`(migrate.go,`=1`)一并提升到 2;
注意 `installer/lock.go` 里的 `schemaVersion = "1"`(字符串)**是另一套东西**——
它写在 `installed.lock` 里做安装标记,与库表结构版本是两回事,
两者要一起提升就一起提升,不要只改一边造成语义分裂
- [ ] `store.Voice` 结构体加字段 + JSON tag
- [ ] `store.GetVoiceForTTS` 返回值扩展(见 §5.2),`setting.Store` 接口同步改
- [ ] `cmd/dumpdb` 输出新列
- [ ] 验收:**用一个装了老数据的 `tts.db`** 启动服务(不要用全新库测),
确认自动加列成功、数据不丢、`/v1/audio/speech` 仍可用;
再重启一次确认迁移幂等(第二次不报错)
### 阶段 2 · 配置:按 provider 命名空间化
现状是 `settings` 表用**扁平键**,且键名全是火山语义(`default_resource_id`、
`default_speaker`、`model_type`…)。多上游后必须命名空间化。
- [ ] 保留全局键:`auth_key` / `cors_*` / `timeout` / `default_provider`
- [ ] 厂商专属键改为 `provider.<name>.<key>`,例如
`provider.volcano.api_key`、`provider.volcano.resource_id`、
`provider.openai.api_key`、`provider.openai.base_url`
- [ ] `LoadRuntimeConfig` 只装配**当前 `default_provider`** 的 `Request`,
不再构造 `volcano.Options`
- [ ] `setting.CheckEnvironmentVariables` / `LogStartupSummary` 去掉
`BYTEDANCE_TTS_*` 硬编码名,改成遍历 provider 的必填项(否则 `/health` 会误导运维)
- [ ] 迁移:老键 `api_key` → `provider.volcano.api_key` 等,一次性搬完并保留旧键只读兜底一轮
- [ ] 验收:除火山外没有任何 provider 时,服务行为仍与阶段 1 相同
### 阶段 3 · 观测:metrics 加 provider 维度
- [ ] `tts_upstream_total` / `tts_upstream_duration_seconds` / `tts_upstream_errors_total`
等**全部上游指标加 `provider` label**
- [ ] `metrics.AdapterRecorder` 增加 provider 字段(构造时注入 name)
- [ ] 仪表盘 PromQL 补 `by (provider)`
- [ ] ⚠️ **破坏性变更**:加 label 会让现有告警规则里的
`tts_upstream_total{...}` 聚合口径变化,发布说明里要写明
- [ ] 验收:`/metrics` 上能按 provider 拆分成功率与 P95
### 阶段 4 · 管理界面:provider 选择与音色归属
- [ ] `/setup` 向导第 1 步加"上游类型"选择,后续步骤按所选 provider 动态渲染字段
(火山显示 resource_id,**OpenAI 显示 base_url 且不显示 resource_id**)
- [ ] `router/admin-voices.html` 新增/编辑音色时选 provider 与 model
- [ ] `/api/voices` 的请求/响应加 `provider` 字段,`controller/admin.go` 校验
"provider 必须已注册",否则 400(不要留到合成时才 500)
- [ ] 删除 provider 最后一个音色时的保护逻辑(现在的 `ErrInUse` 只管 `default_speaker`)
- [ ] 验收:UI 上能并存火山与 OpenAI 音色,各自路由正确
### 阶段 5 · 第二个真实适配器(OpenAI 兼容)
**这一步才是对抽象的真正验收**。选 OpenAI 兼容上游(或其代理)最省事,因为
本项目对外就是 OpenAI 形状 —— 可以直接验证"请求/响应穿透"是否正确。
- [ ] 新建 `adapter/openai/`,只实现 §3.1 的接口
- [ ] 全程**不改** `controller/` `setting/` `store/` 任何一行
- [ ] 如果为了实现它而必须改主干,说明 §3.1 的抽象漏了维度 —— **回头修接口,而不是打补丁**
- [ ] 验收:同一份 `voices` 配置里两个 provider 可同时服务,
客户端只看到 `voice` 名不同
---
## 5. 数据模型改动细节
### 5.1 voices 表
现状(真实 schema,`store/db.go:129-142`):
```sql
CREATE TABLE IF NOT EXISTS voices (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
speaker TEXT NOT NULL, -- 上游音色 ID
resource_id TEXT NOT NULL, -- ← 火山专属概念,却写成 NOT NULL 通用列
model TEXT DEFAULT '',
language TEXT DEFAULT '',
description TEXT DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
```
**问题**:`resource_id` 是火山独有概念(决定模型版本与计费),却成了全表必填列。
OpenAI 上游根本没有这个字段 —— 这正是"单上游假设"渗进数据模型的典型症状。
建议改为:
```sql
ALTER TABLE voices ADD COLUMN provider TEXT NOT NULL DEFAULT 'volcano';
ALTER TABLE voices ADD COLUMN vendor_params TEXT NOT NULL DEFAULT '{}'; -- JSON,厂商私有
-- resource_id 保留但放宽为可空(或整体搬进 vendor_params,看迁移成本)
```
- `provider` 建索引:`CREATE INDEX idx_voices_provider ON voices(provider)`
- `vendor_params` 存厂商私有键值(火山:`{"resource_id":"seed-icl-2.0","model_type":4}`),
主干**不解释**它的内容,原样交给对应 provider
- 迁移:`UPDATE voices SET provider='volcano' WHERE provider IS NULL OR provider=''`
### 5.2 GetVoiceForTTS 的签名
现状(`setting/config.go:426-436`,刻意用 5 个返回值避免循环 import):
```go
GetVoiceForTTS(name string) (speaker, resourceID, model string, found bool, err error)
```
多上游后要扩成:
```go
// 返回该 voice 的 provider 名 + 厂商私有参数,主干据此把请求路由给对应适配器。
GetVoiceForTTS(name string) (providerName, speakerID, model string, vendorParams map[string]string, found bool, err error)
```
**注意**:当年之所以不用 `(VoiceRef, error)`,是为了让 `store` 不 import `setting`
(避免循环 import)。加字段时**别再引入新的结构体依赖**,继续用扁平返回值或
定义在 `store` 包里的中性结构体。
---
## 6. 新增一个适配器的完整清单
假设要加 `foo` 上游:
**代码**
1. `adapter/foo/foo.go` —— 实现 `provider.Provider`(`Name()` 返回 `"foo"`)
2. `adapter/foo/request.go` —— `provider.Request` → foo 的 HTTP 请求体
3. `adapter/foo/response.go` —— 解析 foo 的响应;严格遵守 §2.6 的 6 条协议纪律
4. `adapter/foo/errors.go` —— 错误要带阶段,`Stage` 用统一枚举
5. `adapter/foo/foo_test.go` —— 至少覆盖:正常流、未知事件不污染音频、空音频报错、
base64 解码失败报错、格式降级回报正确 `Format`、`ctx` 取消能及时返回
(测试文件按项目策略不入库,本地保留并在合并前手动跑)
6. 在 `foo.go` 的 `init()` 里 `provider.Register(...)`
7. `main.go` 加一行空导入
**配置**
8. 定义必填项清单(供 `/health` 与启动摘要显示),不要写死在前端
9. 在 `/setup` 向导里加该 provider 的字段定义(阶段 4 之后是数据驱动,只需加配置)
**验收**
10. `go build ./... && go vet ./... && go test ./... -count=1` 全绿
11. 起服 → `/setup` 选 foo → 填凭证 → 加一个音色 → `curl /v1/audio/speech` 出声
12. 故意填错凭证 → 客户端应拿到明确的 4xx/5xx 与 `code`,日志里凭证是**打码**的
13. 不支持的 `response_format` → 按 `Capabilities` 降级,且 Content-Type 与真实字节一致
---
## 7. 测试策略(注意本项目的特殊约束)
**本项目测试文件不入库**(`.gitignore` 里的 `*_test.go`),
测试保留在本地磁盘。这带来两个直接后果,必须知道:
1. **没有任何自动化会跑你的测试**。仓库里不提供 CI 质量门:
GitHub 上只有一个 workflow,且它只在**发版打 tag 时构建 Docker 镜像**,
既不编译校验也不跑测试。所以适配器测试**必须在本地跑过再合并**:
```bash
go test ./... -count=1
```
2. **不要依赖测试文件来传递知识**。协议细节、踩坑记录要写进**代码注释**和本文档,
否则对下一个人等于不存在。
适配器测试的**最小可用集合**(按 §2.6 的坑逐条覆盖):
| 用例 | 断言 |
|---|---|
| 正常流 | 音频字节非空;`Chunks` / `TTFB` / `Format` 合理 |
| 未知事件帧 | 不进入音频;不报错;不改变 `Chunks` |
| 字幕帧 | 进 `Subtitles`,**不进** `AudioData` |
| 全流无音频 | 返回错误,不是"成功 + 空字节" |
| base64 损坏 | 返回错误,不是跳过继续 |
| 超长单行(>1MB) | 不 EOF |
| 上游 HTTP 4xx/5xx | 错误 `Stage == "http"`,携带 code |
| `ctx` 取消 | 及时返回,不泄漏 goroutine |
| 格式降级 | `Result.Format` 是真实格式,不是客户端要的格式 |
---
## 8. 反模式清单(明确不要做)
- ❌ **不要在 `controller/` 里写 `switch providerName` 分发**。
分发只能走注册表,否则每加一个上游都要改主干,抽象就白做了。
- ❌ **不要让 `provider` 包 import 任何具体适配器**。依赖方向永远是
`具体适配器 → provider 包 ← 主干`。
- ❌ **不要在 `provider.Request` 里加厂商专属字段**(如 `ResourceID`)。
厂商私有参数一律走 `Credentials.Scope` / `Request.Extra` / `voices.vendor_params`。
- ❌ **不要把上游返回的原始音频直接当结果返回而不收尾**。
PCM 要拼头,降级要如实回报 `Format`(见 §2.5)。
- ❌ **不要静默跳过解析失败的帧**(§2.6 第 4 条)。
- ❌ **不要在日志里打印明文凭证或音色 ID**。沿用
`telemetry.MaskSpeaker` / `MaskResourceID`,新适配器的私有敏感字段也要加等价打码。
- ❌ **不要用一个 `settings` 键名承载多个 provider 的配置**。
- ❌ **不要为了省事把 provider 名写进 `settings` 的扁平键里当"事实上的路由"**,
路由的唯一权威是 `voices.provider` 与 `default_provider`。
---
## 9. 关键文件索引
| 关注点 | 文件 | 说明 |
|---|---|---|
| 请求入口 / 校验 / 路由 | `controller/tts.go` | `resolveClientFormat`、voice 查库、错误归一 |
| 合成编排参考实现 | `adapter/volcano/synthesis.go` | 埋点顺序、格式降级、错误分阶段 |
| 流解析参考实现 | `adapter/volcano/response.go` | 事件白名单、结束帧、base64 |
| 传输层参考实现 | `adapter/volcano/client.go` | 连接复用、nil 安全 |
| 配置装配 | `setting/config.go` | `LoadRuntimeConfig`、`setting.Store` 接口 |
| 库表与迁移 | `store/db.go`、`store/migrate.go` | `schemaVersion`、`migrate()`(**迁移框架未接线,见阶段 1 警示**);`installer/lock.go` 的 `schemaVersion` 是另一套 |
| 音色模型 | `store/voices.go` | `Voice`、`GetVoiceForTTS` |
| 指标 | `metrics/metrics.go` | `AdapterRecorder`、`codeLabel` |
| 结果类型 | `dto/tts.go` | `SynthesisResult`、`SubtitleEntry` |
| 上游抽象(阶段 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.1 · 对应代码基线:`develop` @ `ad342ec`(2026-10-03)*
*v1.1(2026-10-11):§3 Provider 抽象与 §4 阶段 0 已落地,更新为现状;阶段 1~5 仍为提案。*
*本文「现状」部分与代码逐行核对过;「目标架构」为提案,实施时请同步更新本文档。*
+63 -2
View File
@@ -10,6 +10,9 @@ import (
"time" "time"
"github.com/volcano-tts/tts-api/controller" "github.com/volcano-tts/tts-api/controller"
// 空导入注册上游适配器:新增上游只需在此加一行,主干零改动。
_ "github.com/volcano-tts/tts-api/adapter/volcano"
"github.com/volcano-tts/tts-api/adapter/route"
"github.com/volcano-tts/tts-api/installer" "github.com/volcano-tts/tts-api/installer"
"github.com/volcano-tts/tts-api/metrics" "github.com/volcano-tts/tts-api/metrics"
"github.com/volcano-tts/tts-api/middleware" "github.com/volcano-tts/tts-api/middleware"
@@ -26,6 +29,17 @@ func ttsDBPath() string {
return "tts.db" return "tts.db"
} }
// countEnabled 统计启用 channel 数;main 启动摘要用。
func countEnabled(chs []route.Channel) int {
n := 0
for _, c := range chs {
if c.Enabled() {
n++
}
}
return n
}
func main() { func main() {
log.SetFlags(log.LstdFlags | log.Lshortfile) log.SetFlags(log.LstdFlags | log.Lshortfile)
log.SetPrefix("[TTS-Server] ") log.SetPrefix("[TTS-Server] ")
@@ -35,6 +49,8 @@ func main() {
setting.InitAllConfigs() setting.InitAllConfigs()
metrics.Init() metrics.Init()
middleware.InitRateLimiter() middleware.InitRateLimiter()
// v0.3.0:根路径 /metrics 的内网白名单(未配置则根路径完全不注册)
middleware.InitMetricsAllowList()
// 2) 启动期关键步骤:打开/建库 → 检测 lock → 判定模式 // 2) 启动期关键步骤:打开/建库 → 检测 lock → 判定模式
dbPath := ttsDBPath() dbPath := ttsDBPath()
@@ -57,6 +73,31 @@ func main() {
return nil return nil
}) })
// 3.1) 阶段 1:多渠道路由器注入。
// - 从 store 加载全部 channels(含 disabled,便于阶段 2 管理接口看全量);
// router 内部筛 enabled
// - channels 加载失败(如 credentials_json 损坏)→ 启动期 fail-fast,
// 避免一个错凭据把每次请求搞崩
// - 加载成功但列表为空 → 仍注入 router,但 router.Empty()=true,
// controller 走"零渠道兜底",行为=阶段 0(向后兼容验收硬要求)
if st != nil {
chs, err := st.ChannelList(true)
if err != nil {
log.Fatalf("FATAL: load channels failed: %v", err)
}
routerChs := make([]route.Channel, 0, len(chs))
for _, sc := range chs {
rc, convErr := route.FromStoreChannel(sc)
if convErr != nil {
log.Fatalf("FATAL: convert channel id=%d name=%s failed: %v", sc.ID, sc.Name, convErr)
}
routerChs = append(routerChs, rc)
}
controller.SetRouter(route.NewRouter(routerChs))
log.Printf("[main] 多渠道路由已注入: 共 %d 条 (enabled=%d)",
len(routerChs), countEnabled(routerChs))
}
// 4) M3: 从 store 加载运行时 TTS 配置(替代原来的 env-based InitTTSConfig) // 4) M3: 从 store 加载运行时 TTS 配置(替代原来的 env-based InitTTSConfig)
// 必须在 LogStartupSummary 之前,这样日志显示的是真实状态(API key 已从 DB 加载,不再读 env) // 必须在 LogStartupSummary 之前,这样日志显示的是真实状态(API key 已从 DB 加载,不再读 env)
// //
@@ -85,6 +126,21 @@ func main() {
// 5) 启动摘要日志(此时 Auth.APIKeys 已是 DB 值,日志反映真实状态) // 5) 启动摘要日志(此时 Auth.APIKeys 已是 DB 值,日志反映真实状态)
setting.LogStartupSummary() setting.LogStartupSummary()
// 5.1) v0.3.0 前置校验:normal 模式下必须有管理凭证。
// 为什么 fail-fast 而不是警告:mware.RequireAdmin 在凭证为空时**拒绝**访问
// (不再像 v0.3.0 之前那样放行)。若此处不拦住,服务会正常起来,
// 但 /dashboard 与所有 /api/admin/* 全部 401 —— 相当于把自己锁在门外。
// 宁可启动失败并打印明确原因,也不要起来一个进不去后台的实例。
if installer.GetMode() == installer.ModeNormal && len(setting.GetAdminKeys()) == 0 {
log.Printf("[main][FATAL] normal 模式未配置任何管理凭证:admin_key / auth_key / OPENAI_TTS_API_KEY 均为空。")
log.Printf("[main][FATAL] 管理接口(含 /dashboard)将全部返回 401,服务拒绝启动。")
log.Fatalf("no admin credential configured; set admin_key (or auth_key) before starting in normal mode")
}
if installer.GetMode() == installer.ModeNormal {
log.Printf("[main] 管理凭证来源: %s", setting.GetAdminKeySource())
}
log.Printf("[main] 当前模式: %s (db=%s lock=%s)", res.Mode, dbPath, res.LockPath) log.Printf("[main] 当前模式: %s (db=%s lock=%s)", res.Mode, dbPath, res.LockPath)
controller.InitController() controller.InitController()
@@ -113,8 +169,13 @@ func main() {
log.Printf("OpenAI TTS endpoint: http://localhost:%s/v1/audio/speech", setting.Server.Port) log.Printf("OpenAI TTS endpoint: http://localhost:%s/v1/audio/speech", setting.Server.Port)
log.Printf("Admin WebUI: http://localhost:%s/admin", setting.Server.Port) log.Printf("Admin WebUI: http://localhost:%s/admin", setting.Server.Port)
} }
log.Printf("Health check: http://localhost:%s/health", setting.Server.Port) log.Printf("Health check(匿名存活探针): http://localhost:%s/healthz", setting.Server.Port)
log.Printf("Metrics: http://localhost:%s/metrics", setting.Server.Port) if middleware.MetricsAllowListConfigured() {
log.Printf("Metrics(内网白名单): http://localhost:%s/metrics", setting.Server.Port)
} else {
log.Printf("Metrics: 根路径 /metrics 未注册(未配置 METRICS_ALLOW_CIDR);请用鉴权版 /api/admin/metrics")
}
log.Printf("详细健康数据(鉴权): http://localhost:%s/api/admin/health", setting.Server.Port)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Server failed to start: %v", err) log.Fatalf("Server failed to start: %v", err)
+9 -4
View File
@@ -9,6 +9,7 @@ package metrics
import ( import (
"time" "time"
"github.com/volcano-tts/tts-api/adapter/provider"
"github.com/volcano-tts/tts-api/telemetry" "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{} type AdapterRecorder struct{}
// UpstreamStarted 满足 volcano.MetricsRecorder 接口。 // 编译期断言:AdapterRecorder 必须实现 provider.MetricsRecorder,防止接口漂移。
var _ provider.MetricsRecorder = AdapterRecorder{}
// UpstreamStarted 满足 provider.MetricsRecorder 接口。
func (AdapterRecorder) UpstreamStarted(speaker, model, format string) { func (AdapterRecorder) UpstreamStarted(speaker, model, format string) {
// speaker 用 sha1[:8] 替代,保护火山复刻音色 ID // speaker 用 sha1[:8] 替代,保护火山复刻音色 ID
// (无鉴权 /metrics 端点可枚举,这是 P0 隐私问题) // (无鉴权 /metrics 端点可枚举,这是 P0 隐私问题)
UpstreamTotal.Inc(telemetry.Labels{"status": "started", "format": format, "model": model, "speaker": telemetry.SpeakerLabel(speaker)}) 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) { 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)} labels := telemetry.Labels{"status": status, "format": format, "model": model, "speaker": telemetry.SpeakerLabel(speaker)}
UpstreamTotal.Inc(labels) 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) { func (AdapterRecorder) UpstreamUsage(model string, textWords int) {
if textWords <= 0 { if textWords <= 0 {
return return
+17 -10
View File
@@ -9,14 +9,19 @@ import (
"github.com/volcano-tts/tts-api/setting" "github.com/volcano-tts/tts-api/setting"
) )
// RequireAdmin 是 /admin 路由的鉴权中间件,复用 OPENAI_TTS_API_KEY。 // RequireAdmin 是管理接口(/api/admin/*、/api/voices*、/api/settings*)的鉴权中间件。
// 行为:
// - Auth.APIKeys 为空 → 所有请求放行(等同无鉴权)
// - Authorization 头 Bearer token 在列表中 → 放行
// - 其它 → 401 + JSON {error: 'unauthorized', code: 'admin_auth_failed'}
// //
// 设计: 与现有 /v1/audio/speech 用的鉴权 key 列表(setting.GetAuthAPIKeys)共享同一份 keys, // 凭证来源:setting.GetAdminKeys(),优先级 admin_key(DB) > auth_key(DB) > OPENAI_TTS_API_KEY(env)。
// 用户只用管一个 env 变量(OPENAI_TTS_API_KEY)。 // 与业务侧鉴权(middleware.ValidateAPIKey,只看 auth_key)分离,实现权限隔离:
// 配置了独立 admin_key 后,业务调用方持有的 key 无法访问管理接口。
//
// 行为:
// - OPTIONS 预检 → 放行(浏览器预检不带 Authorization)
// - 未配置任何凭证 → **拒绝**(401)。这是刻意设计:v0.3.0 之前这里直接放行,
// 导致管理接口在"没配 key"的部署上完全裸奔,并让后续给 /metrics、/health
// 套本中间件的加固形同虚设。启动期已由 main.go 做 fail-fast 校验。
// - Authorization 头 Bearer token 命中凭证列表 → 放行
// - 其它 → 401 + JSON {error: {code: 'admin_auth_failed'}}
func RequireAdmin(next http.Handler) http.Handler { func RequireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 预检: 跨域/OPTIONS 直接放行(让浏览器能发 preflight) // 预检: 跨域/OPTIONS 直接放行(让浏览器能发 preflight)
@@ -25,10 +30,12 @@ func RequireAdmin(next http.Handler) http.Handler {
return return
} }
keys := setting.GetAuthAPIKeys() keys := setting.GetAdminKeys()
if len(keys) == 0 { if len(keys) == 0 {
// 没配 admin key,等同无鉴权 // 没配管理凭证 → 拒绝(旧行为是放行,见上方注释说明为何改掉)
next.ServeHTTP(w, r) log.Printf("[admin_auth] 拒绝:未配置管理凭证(admin_key/auth_key/OPENAI_TTS_API_KEY 均为空) - 路径=%s 客户端=%s",
r.URL.Path, GetClientIP(r))
denyAdmin(w, r)
return return
} }
+103
View File
@@ -0,0 +1,103 @@
package middleware
import (
"log"
"net"
"net/http"
"os"
"strings"
)
// metricsAllowList 是 METRICS_ALLOW_CIDR 解析出的内网白名单。
// 启动期由 InitMetricsAllowList 填充;运行期只读,无并发写。
var metricsAllowList []*net.IPNet
// metricsAllowConfigured 表示是否配置了非空的 METRICS_ALLOW_CIDR。
// 决定根路径 /metrics、/health 是否注册(未配置则完全不注册,访问 404)。
var metricsAllowConfigured bool
// InitMetricsAllowList 解析 METRICS_ALLOW_CIDR,启动期调用一次。
//
// 格式:逗号分隔的 CIDR 列表,例如 "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"。
// 也接受裸 IP(自动补 /32 或 /128),方便写 "127.0.0.1"。
//
// 为什么需要它:根路径 /metrics、/health 是给 Prometheus 抓取 / K8s 探针用的机器端点,
// 让它们带 Bearer 会强制改抓取配置;用内网白名单更省事,且默认不暴露。
//
// 【重要】Docker 环境不要填 127.0.0.1/32 —— 那是容器自身的回环,
// Prometheus 在宿主机或另一个容器里根本进不来。请填容器内网网段(如 172.16.0.0/12)。
func InitMetricsAllowList() {
raw := strings.TrimSpace(os.Getenv("METRICS_ALLOW_CIDR"))
metricsAllowList = nil
metricsAllowConfigured = false
if raw == "" {
return
}
for _, part := range strings.Split(raw, ",") {
entry := strings.TrimSpace(part)
if entry == "" {
continue
}
// 裸 IP 自动补全掩码
if !strings.Contains(entry, "/") {
if ip := net.ParseIP(entry); ip != nil {
if ip.To4() != nil {
entry += "/32"
} else {
entry += "/128"
}
}
}
_, ipnet, err := net.ParseCIDR(entry)
if err != nil {
log.Printf("[metrics-ip] 忽略非法 CIDR 条目 %q: %v", part, err)
continue
}
metricsAllowList = append(metricsAllowList, ipnet)
}
metricsAllowConfigured = len(metricsAllowList) > 0
if metricsAllowConfigured {
log.Printf("[metrics-ip] METRICS_ALLOW_CIDR 已启用,根路径 /metrics 仅对 %d 个网段开放", len(metricsAllowList))
} else {
log.Printf("[metrics-ip] METRICS_ALLOW_CIDR 无有效条目,根路径 /metrics 将不注册")
}
}
// MetricsAllowListConfigured 报告是否配置了有效的白名单网段。
// router 据此决定是否注册根路径 /metrics / /health。
func MetricsAllowListConfigured() bool { return metricsAllowConfigured }
// MetricsIPAllowList 只放行来源 IP 命中白名单的请求。
//
// 未命中返回 **404**(而不是 403):不向扫描者确认"这里存在一个只是你没权限的端点"。
// 客户端 IP 取自 GetClientIP,它已处理 X-Forwarded-For 与 TRUSTED_PROXY_HOPS,
// 所以反代后面的真实来源也能正确判定。
func MetricsIPAllowList(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !ipAllowed(GetClientIP(r)) {
// 不打印每个被拒请求,避免扫描流量刷爆日志;只按需在 debug 下输出
http.NotFound(w, r)
return
}
next.ServeHTTP(w, r)
})
}
// ipAllowed 判定客户端 IP 是否命中任一白名单网段。
// 空 IP(解析失败)一律拒绝 —— 宁可拒绝也不能误放行。
func ipAllowed(clientIP string) bool {
if clientIP == "" {
return false
}
ip := net.ParseIP(strings.TrimSpace(clientIP))
if ip == nil {
return false
}
for _, n := range metricsAllowList {
if n.Contains(ip) {
return true
}
}
return false
}
+4 -4
View File
@@ -6,7 +6,7 @@
<title>管理后台 · 登录</title> <title>管理后台 · 登录</title>
<script src="https://cdn.bootcdn.net/ajax/libs/vue/3.4.21/vue.global.prod.js"></script> <script src="https://cdn.bootcdn.net/ajax/libs/vue/3.4.21/vue.global.prod.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/axios/1.6.7/axios.min.js"></script> <script src="https://cdn.bootcdn.net/ajax/libs/axios/1.6.7/axios.min.js"></script>
<link rel="stylesheet" href="/admin/admin.css"> <link rel="stylesheet" href="/dashboard/admin.css">
</head> </head>
<body> <body>
<div id="app"> <div id="app">
@@ -22,7 +22,7 @@
</div> </div>
</div> </div>
<script src="/admin/admin-shell.js"></script> <script src="/dashboard/admin-shell.js"></script>
<script> <script>
const { createApp, ref } = Vue; const { createApp, ref } = Vue;
const { http, setKey, clearKey, getKey } = window.admin; const { http, setKey, clearKey, getKey } = window.admin;
@@ -34,7 +34,7 @@
// 已登录则直接跳 dashboard // 已登录则直接跳 dashboard
if (getKey()) { if (getKey()) {
http.get('/admin/overview').then(() => { http.get('/admin/overview').then(() => {
location.href = '/admin'; location.href = '/dashboard';
}).catch(() => { clearKey(); }); }).catch(() => { clearKey(); });
} }
@@ -42,7 +42,7 @@
loginErr.value = ''; loginErr.value = '';
try { try {
await window.admin.login(keyInput.value); await window.admin.login(keyInput.value);
location.href = '/admin'; location.href = '/dashboard';
} catch (e) { } catch (e) {
loginErr.value = '鉴权失败: ' + (e.response?.data?.error?.message || e.message); loginErr.value = '鉴权失败: ' + (e.response?.data?.error?.message || e.message);
} }
+5 -5
View File
@@ -6,7 +6,7 @@
<title>火山 TTS · 系统设置</title> <title>火山 TTS · 系统设置</title>
<script src="https://cdn.bootcdn.net/ajax/libs/vue/3.4.21/vue.global.prod.js"></script> <script src="https://cdn.bootcdn.net/ajax/libs/vue/3.4.21/vue.global.prod.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/axios/1.6.7/axios.min.js"></script> <script src="https://cdn.bootcdn.net/ajax/libs/axios/1.6.7/axios.min.js"></script>
<link rel="stylesheet" href="/admin/admin.css"> <link rel="stylesheet" href="/dashboard/admin.css">
</head> </head>
<body> <body>
<div id="app"> <div id="app">
@@ -34,7 +34,7 @@
<div class="card-head"> <div class="card-head">
<div> <div>
<div class="card-title">鉴权 Key</div> <div class="card-title">鉴权 Key</div>
<div class="card-sub">客户端调用 <code>/v1/audio/speech</code> 与登录 /admin 共用</div> <div class="card-sub">客户端调用 <code>/v1/audio/speech</code>。管理后台登录改用 <code>admin_key</code>(未配置时回退用本 Key)</div>
</div> </div>
<span class="pill pill-accent">敏感</span> <span class="pill pill-accent">敏感</span>
</div> </div>
@@ -143,7 +143,7 @@
</div> </div>
</div> </div>
<script src="/admin/admin-shell.js"></script> <script src="/dashboard/admin-shell.js"></script>
<script> <script>
const { createApp, ref, computed, onMounted } = Vue; const { createApp, ref, computed, onMounted } = Vue;
const { http, getKey, setKey, clearKey, toast, mountShell } = window.admin; const { http, getKey, setKey, clearKey, toast, mountShell } = window.admin;
@@ -236,7 +236,7 @@
authKeyInput.value = ''; authKeyInput.value = '';
setTimeout(() => { setTimeout(() => {
clearKey(); clearKey();
location.href = '/admin/login'; location.href = '/dashboard/login';
}, 200); }, 200);
toast('ok', 'Auth Key 已更新,请用新 Key 重新登录'); toast('ok', 'Auth Key 已更新,请用新 Key 重新登录');
} catch (e) { } catch (e) {
@@ -246,7 +246,7 @@
const resetSettingsForm = () => { loadSettings(); }; const resetSettingsForm = () => { loadSettings(); };
onMounted(() => { onMounted(() => {
if (!getKey()) { location.href = '/admin/login'; return; } if (!getKey()) { location.href = '/dashboard/login'; return; }
mountShell({ mountShell({
activeNav: 'settings', activeNav: 'settings',
overview: overview.value, overview: overview.value,
+10 -4
View File
@@ -112,21 +112,27 @@
<nav class="nav"> <nav class="nav">
<div class="nav-group"> <div class="nav-group">
<div class="nav-label">概览</div> <div class="nav-label">概览</div>
<a class="nav-item${activeNav === 'dashboard' ? ' active' : ''}" href="/admin"> <a class="nav-item${activeNav === 'dashboard' ? ' active' : ''}" href="/dashboard">
<span class="nav-icon">▦</span><span>仪表盘</span> <span class="nav-icon">▦</span><span>仪表盘</span>
</a> </a>
</div> </div>
<div class="nav-group"> <div class="nav-group">
<div class="nav-label">管理</div> <div class="nav-label">管理</div>
<a class="nav-item${activeNav === 'voices' ? ' active' : ''}" href="/admin/voices"> <a class="nav-item${activeNav === 'voices' ? ' active' : ''}" href="/dashboard/voices">
<span class="nav-icon">♪</span><span>音色</span> <span class="nav-icon">♪</span><span>音色</span>
<span class="nav-badge">${voiceCount}</span> <span class="nav-badge">${voiceCount}</span>
</a> </a>
<a class="nav-item${activeNav === 'settings' ? ' active' : ''}" href="/admin/settings"> <a class="nav-item${activeNav === 'settings' ? ' active' : ''}" href="/dashboard/settings">
<span class="nav-icon">⚙</span><span>设置</span> <span class="nav-icon">⚙</span><span>设置</span>
${corsConfigured ? '' : '<span class="nav-dot" title="CORS 未配置"></span>'} ${corsConfigured ? '' : '<span class="nav-dot" title="CORS 未配置"></span>'}
</a> </a>
</div> </div>
<div class="nav-group">
<div class="nav-label">观测</div>
<a class="nav-item${activeNav === 'status' ? ' active' : ''}" href="/dashboard/status">
<span class="nav-icon">♥</span><span>详细状态</span>
</a>
</div>
</nav> </nav>
<div class="sidebar-foot"> <div class="sidebar-foot">
<button class="btn-icon" id="btn-reload" title="刷新">↻ 刷新</button> <button class="btn-icon" id="btn-reload" title="刷新">↻ 刷新</button>
@@ -142,7 +148,7 @@
if (btnLogout) btnLogout.addEventListener('click', () => { if (btnLogout) btnLogout.addEventListener('click', () => {
clearKey(); clearKey();
if (typeof options.onLogout === 'function') options.onLogout(); if (typeof options.onLogout === 'function') options.onLogout();
else location.href = '/admin/login'; else location.href = '/dashboard/login';
}); });
return {}; return {};
}; };
+3 -3
View File
@@ -6,7 +6,7 @@
<title>火山 TTS · 音色管理</title> <title>火山 TTS · 音色管理</title>
<script src="https://cdn.bootcdn.net/ajax/libs/vue/3.4.21/vue.global.prod.js"></script> <script src="https://cdn.bootcdn.net/ajax/libs/vue/3.4.21/vue.global.prod.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/axios/1.6.7/axios.min.js"></script> <script src="https://cdn.bootcdn.net/ajax/libs/axios/1.6.7/axios.min.js"></script>
<link rel="stylesheet" href="/admin/admin.css"> <link rel="stylesheet" href="/dashboard/admin.css">
</head> </head>
<body> <body>
<div id="app"> <div id="app">
@@ -159,7 +159,7 @@
</teleport> </teleport>
</div> </div>
<script src="/admin/admin-shell.js"></script> <script src="/dashboard/admin-shell.js"></script>
<script> <script>
const { createApp, ref, computed, onMounted } = Vue; const { createApp, ref, computed, onMounted } = Vue;
const { http, getKey, toast, mountShell } = window.admin; const { http, getKey, toast, mountShell } = window.admin;
@@ -237,7 +237,7 @@
}; };
onMounted(() => { onMounted(() => {
if (!getKey()) { location.href = '/admin/login'; return; } if (!getKey()) { location.href = '/dashboard/login'; return; }
mountShell({ mountShell({
activeNav: 'voices', activeNav: 'voices',
overview: overview.value, overview: overview.value,
+3 -3
View File
@@ -6,7 +6,7 @@
<title>火山 TTS · 仪表盘</title> <title>火山 TTS · 仪表盘</title>
<script src="https://cdn.bootcdn.net/ajax/libs/vue/3.4.21/vue.global.prod.js"></script> <script src="https://cdn.bootcdn.net/ajax/libs/vue/3.4.21/vue.global.prod.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/axios/1.6.7/axios.min.js"></script> <script src="https://cdn.bootcdn.net/ajax/libs/axios/1.6.7/axios.min.js"></script>
<link rel="stylesheet" href="/admin/admin.css"> <link rel="stylesheet" href="/dashboard/admin.css">
</head> </head>
<body> <body>
<div id="app"> <div id="app">
@@ -77,7 +77,7 @@
</div> </div>
</div> </div>
<script src="/admin/admin-shell.js"></script> <script src="/dashboard/admin-shell.js"></script>
<script> <script>
const { createApp, ref, onMounted } = Vue; const { createApp, ref, onMounted } = Vue;
const { http, getKey, formatUptime, shortPath, formatBytes, formatStart, mountShell } = window.admin; const { http, getKey, formatUptime, shortPath, formatBytes, formatStart, mountShell } = window.admin;
@@ -93,7 +93,7 @@
}; };
onMounted(() => { onMounted(() => {
if (!getKey()) { location.href = '/admin/login'; return; } if (!getKey()) { location.href = '/dashboard/login'; return; }
mountShell({ activeNav: 'dashboard', overview: overview.value, voiceCount: overview.value.voice_count, onReload: loadOverview }); mountShell({ activeNav: 'dashboard', overview: overview.value, voiceCount: overview.value.voice_count, onReload: loadOverview });
loadOverview(); loadOverview();
}); });
+10 -3
View File
@@ -6,6 +6,7 @@
<title>TTS 服务监控</title> <title>TTS 服务监控</title>
<script src="https://cdn.bootcdn.net/ajax/libs/vue/3.4.21/vue.global.prod.js"></script> <script src="https://cdn.bootcdn.net/ajax/libs/vue/3.4.21/vue.global.prod.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/axios/1.6.7/axios.min.js"></script> <script src="https://cdn.bootcdn.net/ajax/libs/axios/1.6.7/axios.min.js"></script>
<link rel="stylesheet" href="/dashboard/admin.css">
<style> <style>
:root { :root {
--bg: #0b0f1a; --bg: #0b0f1a;
@@ -148,6 +149,7 @@
</div> </div>
</div> </div>
<div class="row-flex"> <div class="row-flex">
<a class="btn" href="/dashboard" style="text-decoration:none;">← 返回控制台</a>
<span class="status-badge" :class="health.status === 'ok' ? 'ok' : 'error'"> <span class="status-badge" :class="health.status === 'ok' ? 'ok' : 'error'">
<span class="status-dot" :class="{ live: health.status === 'ok' }"></span> <span class="status-dot" :class="{ live: health.status === 'ok' }"></span>
{{ health.status === 'ok' ? '运行中' : (health.status || '加载中') }} {{ health.status === 'ok' ? '运行中' : (health.status || '加载中') }}
@@ -377,8 +379,11 @@
</div> </div>
</div> </div>
<script src="/dashboard/admin-shell.js"></script>
<script> <script>
const { createApp, ref, computed, onMounted } = Vue; const { createApp, ref, computed, onMounted } = Vue;
// v0.3.0:/health 与 /metrics 已收口到鉴权接口,统一走带凭证头的 /api/admin/*
const { http, getKey } = window.admin;
function parseProm(text) { function parseProm(text) {
const out = {}; const out = {};
@@ -597,16 +602,16 @@
const fetchHealth = async () => { const fetchHealth = async () => {
try { try {
const r = await axios.get("/health"); const r = await http.get("/admin/health");
health.value = r.data; health.value = r.data;
} catch (e) { } catch (e) {
error.value = "无法获取健康状态: " + (e.message || "未知错误"); error.value = "无法获取健康状态: " + (e.response?.data?.error?.message || e.message || "未知错误");
} }
}; };
const fetchMetrics = async () => { const fetchMetrics = async () => {
try { try {
const r = await axios.get("/metrics"); const r = await http.get("/admin/metrics");
metrics.value = parseProm(r.data); metrics.value = parseProm(r.data);
} catch (e) { } catch (e) {
// 不覆盖 error,让 health 错误更显眼 // 不覆盖 error,让 health 错误更显眼
@@ -650,6 +655,8 @@
}; };
onMounted(() => { onMounted(() => {
// v0.3.0:本页已收口到管理鉴权,未登录先回登录页
if (!getKey()) { location.href = '/dashboard/login'; return; }
refresh(); refresh();
setInterval(refresh, 10000); setInterval(refresh, 10000);
}); });
+107 -25
View File
@@ -66,78 +66,160 @@ func Setup() *mux.Router {
// /setup 页面本身:装完后必须不可用,否则用户敲 /setup 还会看到安装表单,容易误以为要重装。 // /setup 页面本身:装完后必须不可用,否则用户敲 /setup 还会看到安装表单,容易误以为要重装。
r.HandleFunc("/setup", func(w http.ResponseWriter, req *http.Request) { r.HandleFunc("/setup", func(w http.ResponseWriter, req *http.Request) {
if installer.GetMode() == installer.ModeNormal { if installer.GetMode() == installer.ModeNormal {
http.Redirect(w, req, "/admin", http.StatusFound) http.Redirect(w, req, "/dashboard", http.StatusFound) // v0.3.0: 管理入口迁至 /dashboard
return return
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(setupHTML) _, _ = w.Write(setupHTML)
}).Methods("GET") }).Methods("GET")
r.HandleFunc("/api/setup/status", controller.SetupStatusHandler).Methods("GET") // v0.3.0:安装状态不再匿名暴露(原本 normal 模式下仍返回 {installed, mode})
r.Handle("/api/setup/status", middleware.RequireAdmin(http.HandlerFunc(controller.SetupStatusHandler))).Methods("GET")
r.HandleFunc("/api/setup/prefill", controller.SetupPrefillHandler).Methods("GET") r.HandleFunc("/api/setup/prefill", controller.SetupPrefillHandler).Methods("GET")
r.HandleFunc("/api/setup", controller.SetupSubmitHandler).Methods("POST") r.HandleFunc("/api/setup", controller.SetupSubmitHandler).Methods("POST")
// /admin 管理后台(M2);HTML 本身公开,鉴权由前端 JS 拦截 // ===== v0.3.0 阶段 3:管理面板入口从 /admin 迁至 /dashboard =====
// (sessionStorage 没 key 就显示登录页;有 key 调 /api/admin/overview 触发 401 跳登录)
// API 端点(/api/admin/* /api/voices*)才需要 RequireAdmin。
// //
// 拆分后 admin.html / admin-login.html / admin-voices.html / admin-settings.html // 页面本身走 htmlAwareAdmin:浏览器请求(Accept: text/html)返回页面外壳,
// 各自独立,URL 路由切换;共享 admin.css + admin-shell.js 由 adminStatic 提供。 // 前端依据 sessionStorage 里的凭证显示登录视图;非 HTML 请求(脚本/抓取)强制鉴权。
r.HandleFunc("/admin", serveAdmin(adminHTML)).Methods("GET") // 原因:SPA 的登录态存在 sessionStorage、不随请求发送,服务端无从判断是否已登录,
r.HandleFunc("/admin/login", serveAdmin(adminLoginHTML)).Methods("GET") // 一律 401 会把"打开 /dashboard 看到登录页"变成"打开 /dashboard 直接报错"。
r.HandleFunc("/admin/voices", serveAdmin(adminVoicesHTML)).Methods("GET") // 页面外壳不含任何数据,数据全部来自鉴权后的 /api/admin/* 接口。
r.HandleFunc("/admin/settings", serveAdmin(adminSettingsHTML)).Methods("GET") // /dashboard 是管理看板(admin.html,与 /dashboard/voices、/dashboard/settings 同属一套 SPA)。
// admin 静态资源 // 注意:拆分前的 /dashboard 曾是"服务状态预览页"(health.html),v0.3.0 起
r.HandleFunc("/admin/admin.css", func(w http.ResponseWriter, _ *http.Request) { // 管理入口统一到 /dashboard,该预览页迁到 /dashboard/status 作为"详细状态"视图。
r.Handle("/dashboard", htmlAwareAdmin(serveAdmin(adminHTML))).Methods("GET")
r.Handle("/dashboard/voices", htmlAwareAdmin(serveAdmin(adminVoicesHTML))).Methods("GET")
r.Handle("/dashboard/settings", htmlAwareAdmin(serveAdmin(adminSettingsHTML))).Methods("GET")
r.Handle("/dashboard/status", htmlAwareAdmin(serveAdmin(dashboardHTML))).Methods("GET")
// 登录页无数据,保持公开(否则无法登录)
r.HandleFunc("/dashboard/login", serveAdmin(adminLoginHTML)).Methods("GET")
// 静态资源:迁到 /dashboard/ 下,同时**保留 /admin/ 旧路径为别名**,
// 这样过渡期内新旧页面都能正确加载样式与脚本,不会出现"页面能开但丢样式"。
serveCSS := func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/css; charset=utf-8") w.Header().Set("Content-Type", "text/css; charset=utf-8")
_, _ = w.Write(adminCSS) _, _ = w.Write(adminCSS)
}).Methods("GET") }
r.HandleFunc("/admin/admin-shell.js", func(w http.ResponseWriter, _ *http.Request) { serveShellJS := func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/javascript; charset=utf-8") w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
_, _ = w.Write(adminShellJS) _, _ = w.Write(adminShellJS)
}
r.HandleFunc("/dashboard/admin.css", serveCSS).Methods("GET")
r.HandleFunc("/dashboard/admin-shell.js", serveShellJS).Methods("GET")
r.HandleFunc("/admin/admin.css", serveCSS).Methods("GET")
r.HandleFunc("/admin/admin-shell.js", serveShellJS).Methods("GET")
// 旧入口永久重定向(页面用 301 是正确的;API 路径**不用**重定向,见下方别名说明)
r.HandleFunc("/admin", func(w http.ResponseWriter, req *http.Request) {
http.Redirect(w, req, "/dashboard", http.StatusMovedPermanently)
}).Methods("GET")
r.HandleFunc("/admin/login", func(w http.ResponseWriter, req *http.Request) {
http.Redirect(w, req, "/dashboard/login", http.StatusMovedPermanently)
}).Methods("GET")
r.HandleFunc("/admin/voices", func(w http.ResponseWriter, req *http.Request) {
http.Redirect(w, req, "/dashboard/voices", http.StatusMovedPermanently)
}).Methods("GET")
r.HandleFunc("/admin/settings", func(w http.ResponseWriter, req *http.Request) {
http.Redirect(w, req, "/dashboard/settings", http.StatusMovedPermanently)
}).Methods("GET") }).Methods("GET")
// /api/admin/overview (鉴权) // /api/admin/overview (鉴权)
r.Handle("/api/admin/overview", middleware.RequireAdmin(http.HandlerFunc(controller.AdminOverviewHandler))).Methods("GET") r.Handle("/api/admin/overview", middleware.RequireAdmin(http.HandlerFunc(controller.AdminOverviewHandler))).Methods("GET")
// /api/admin/metrics (鉴权);返 Prometheus 文本 // /api/admin/metrics (鉴权);返 Prometheus 文本
r.Handle("/api/admin/metrics", middleware.RequireAdmin(http.HandlerFunc(controller.AdminMetricsHandler))).Methods("GET") r.Handle("/api/admin/metrics", middleware.RequireAdmin(http.HandlerFunc(controller.AdminMetricsHandler))).Methods("GET")
// v0.3.0:详细健康数据的鉴权版(面板与运维用)。
// 与根路径 /health 的区别:这里始终注册且强制鉴权;根路径 /health 默认 404。
r.Handle("/api/admin/health", middleware.RequireAdmin(http.HandlerFunc(controller.HealthHandler))).Methods("GET")
// /api/voices 音色 CRUD (鉴权) // /api/voices 音色 CRUD (鉴权)
//
// ⚠️ v0.3.0 阶段 3 路径迁移:/api/admin/voices 是**正式路径**,
// /api/voices 保留为**过渡期别名**(同一个 handler,不做重定向)。
//
// 为什么用别名而不是 301/308:
// - 301 会把 POST/PUT/PATCH/DELETE 降级成 GET(RFC 7231),且被浏览器永久缓存,
// 客户端根本察觉不到路径变化,"过渡期"名存实亡
// - 308 能保留方法,但对非浏览器脚本仍是行为变化,且多一次往返
// - 别名让新旧路径都能直接用,零往返、零破坏,老脚本完全无感
// 旧别名计划在后续版本移除。
r.Handle("/api/admin/voices", middleware.RequireAdmin(http.HandlerFunc(controller.AdminVoicesListHandler))).Methods("GET")
r.Handle("/api/admin/voices", middleware.RequireAdmin(http.HandlerFunc(controller.AdminVoiceCreateHandler))).Methods("POST")
r.Handle("/api/admin/voices/{name}", middleware.RequireAdmin(http.HandlerFunc(controller.AdminVoiceDeleteHandler))).Methods("DELETE")
r.Handle("/api/admin/voices/{name}/toggle", middleware.RequireAdmin(http.HandlerFunc(controller.AdminVoiceToggleHandler))).Methods("PATCH")
r.Handle("/api/voices", middleware.RequireAdmin(http.HandlerFunc(controller.AdminVoicesListHandler))).Methods("GET") r.Handle("/api/voices", middleware.RequireAdmin(http.HandlerFunc(controller.AdminVoicesListHandler))).Methods("GET")
r.Handle("/api/voices", middleware.RequireAdmin(http.HandlerFunc(controller.AdminVoiceCreateHandler))).Methods("POST") r.Handle("/api/voices", middleware.RequireAdmin(http.HandlerFunc(controller.AdminVoiceCreateHandler))).Methods("POST")
r.Handle("/api/voices/{name}", middleware.RequireAdmin(http.HandlerFunc(controller.AdminVoiceDeleteHandler))).Methods("DELETE") r.Handle("/api/voices/{name}", middleware.RequireAdmin(http.HandlerFunc(controller.AdminVoiceDeleteHandler))).Methods("DELETE")
r.Handle("/api/voices/{name}/toggle", middleware.RequireAdmin(http.HandlerFunc(controller.AdminVoiceToggleHandler))).Methods("PATCH") r.Handle("/api/voices/{name}/toggle", middleware.RequireAdmin(http.HandlerFunc(controller.AdminVoiceToggleHandler))).Methods("PATCH")
// /api/settings 全局设置 (鉴权) — M3 // /api/settings 全局设置 (鉴权) — M3
// 同样:/api/admin/settings 为正式路径,/api/settings 为过渡期别名。
// 注意更长的 /api/admin/settings/admin-key 必须**先注册**(mux 按注册顺序取首个匹配)。
r.Handle("/api/admin/settings/admin-key", middleware.RequireAdmin(http.HandlerFunc(controller.SettingsAdminKeyHandler))).Methods("PUT")
r.Handle("/api/admin/settings", middleware.RequireAdmin(http.HandlerFunc(controller.SettingsGetHandler))).Methods("GET")
r.Handle("/api/admin/settings", middleware.RequireAdmin(http.HandlerFunc(controller.SettingsUpdateHandler))).Methods("PUT")
r.Handle("/api/admin/settings/api-key", middleware.RequireAdmin(http.HandlerFunc(controller.SettingsAPIKeyHandler))).Methods("PUT")
r.Handle("/api/admin/settings/auth-key", middleware.RequireAdmin(http.HandlerFunc(controller.SettingsAuthKeyHandler))).Methods("PUT")
r.Handle("/api/settings", middleware.RequireAdmin(http.HandlerFunc(controller.SettingsGetHandler))).Methods("GET") r.Handle("/api/settings", middleware.RequireAdmin(http.HandlerFunc(controller.SettingsGetHandler))).Methods("GET")
r.Handle("/api/settings", middleware.RequireAdmin(http.HandlerFunc(controller.SettingsUpdateHandler))).Methods("PUT") r.Handle("/api/settings", middleware.RequireAdmin(http.HandlerFunc(controller.SettingsUpdateHandler))).Methods("PUT")
r.Handle("/api/settings/api-key", middleware.RequireAdmin(http.HandlerFunc(controller.SettingsAPIKeyHandler))).Methods("PUT") r.Handle("/api/settings/api-key", middleware.RequireAdmin(http.HandlerFunc(controller.SettingsAPIKeyHandler))).Methods("PUT")
r.Handle("/api/settings/auth-key", middleware.RequireAdmin(http.HandlerFunc(controller.SettingsAuthKeyHandler))).Methods("PUT") r.Handle("/api/settings/auth-key", middleware.RequireAdmin(http.HandlerFunc(controller.SettingsAuthKeyHandler))).Methods("PUT")
r.Handle("/api/settings/admin-key", middleware.RequireAdmin(http.HandlerFunc(controller.SettingsAdminKeyHandler))).Methods("PUT")
r.Handle("/api/settings/cors", middleware.RequireAdmin(http.HandlerFunc(controller.SettingsCORSHandler))).Methods("PUT") r.Handle("/api/settings/cors", middleware.RequireAdmin(http.HandlerFunc(controller.SettingsCORSHandler))).Methods("PUT")
// 业务路由 // 业务路由
r.HandleFunc("/v1/audio/speech", controller.OpenaiTTSHandler).Methods("POST", "OPTIONS") r.HandleFunc("/v1/audio/speech", controller.OpenaiTTSHandler).Methods("POST", "OPTIONS")
r.HandleFunc("/health", controller.HealthHandler).Methods("GET")
r.HandleFunc("/dashboard", func(w http.ResponseWriter, req *http.Request) { // v0.3.0 端点收口:
w.Header().Set("Content-Type", "text/html; charset=utf-8") // /healthz 匿名存活探针,只回 "ok"(K8s probe / Docker HEALTHCHECK 用)
_, _ = w.Write(dashboardHTML) // /health 详细健康数据,**鉴权**(原本匿名,会泄漏版本/内存/配置错误文本)
}).Methods("GET") // /dashboard 管理看板(**鉴权**,见上方阶段 3 的注册处)
// /api/admin/health 鉴权版详细健康数据(面板拉取用,风格与 /api/admin/* 一致)
r.HandleFunc("/healthz", controller.HealthzHandler).Methods("GET")
r.Handle("/health", middleware.RequireAdmin(http.HandlerFunc(controller.HealthHandler))).Methods("GET")
r.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) { r.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
// 安装模式下,根路径跳 /setup // 安装模式下,根路径跳 /setup
if installer.GetMode() == installer.ModeSetup { if installer.GetMode() == installer.ModeSetup {
http.Redirect(w, req, "/setup", http.StatusFound) http.Redirect(w, req, "/setup", http.StatusFound)
return return
} }
// 正常模式:跳 /admin(M2 之后优先于 /dashboard) // 正常模式:跳 /dashboard(v0.3.0 管理入口)
http.Redirect(w, req, "/admin", http.StatusFound) http.Redirect(w, req, "/dashboard", http.StatusFound)
}).Methods("GET") }).Methods("GET")
// /metrics 不做鉴权(对齐 /health 策略),但仍然走 RateLimit / ConcurrencyLimit。 // 根路径 /metrics 与 /health 的机器访问:
// Prometheus 抓取不带 Origin,因此经过 CORS 中间件时会直接 pass-through。 // - 配置了 METRICS_ALLOW_CIDR → 注册 /metrics,包裹内网白名单(非白名单返回 404)
r.Handle("/metrics", metrics.Meter.Handler()).Methods("GET") // - 未配置 → 根路径 /metrics **完全不注册**(访问 404),避免默认对外暴露
// /health 已在上面按鉴权注册,不再提供匿名版本。
if middleware.MetricsAllowListConfigured() {
r.Handle("/metrics", middleware.MetricsIPAllowList(metrics.Meter.Handler())).Methods("GET")
}
return r return r
} }
// htmlAwareAdmin 给**浏览器直接访问的管理页面**套鉴权,但对 HTML 请求做内容协商:
//
// - Accept 含 text/html(地址栏打开、点链接)→ 照常返回页面外壳。
// 此时不做 401:因为前端登录态存在 sessionStorage,**不会随请求发送**,
// 服务端无从判断是否已登录;强行 401 会让"打开 /dashboard 看到登录页"
// 这一现有体验变成"打开 /dashboard 直接报错"。
// 页面外壳本身不含任何数据,数据全部来自鉴权后的 /api/admin/* 接口。
// - 其它(脚本、curl、抓取工具)→ 强制 RequireAdmin,无凭证 401。
//
// 这样既堵住了"匿名抓取健康数据",又不破坏 SPA 的登录流程。
func htmlAwareAdmin(html http.Handler) http.Handler {
guarded := middleware.RequireAdmin(html)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if acceptsHTML(r.Header.Get("Accept")) {
html.ServeHTTP(w, r)
return
}
guarded.ServeHTTP(w, r)
})
}
// acceptsHTML 在 router 包内复刻,middleware 包的版本未导出。 // acceptsHTML 在 router 包内复刻,middleware 包的版本未导出。
// 用途:NotFoundHandler 判断浏览器 Accept。 // 用途:NotFoundHandler 判断浏览器 Accept。
func acceptsHTML(accept string) bool { func acceptsHTML(accept string) bool {
+5 -5
View File
@@ -202,7 +202,7 @@
<div class="field"> <div class="field">
<label>OpenAI 鉴权 Key <span class="req">*</span></label> <label>OpenAI 鉴权 Key <span class="req">*</span></label>
<input type="password" v-model="form.auth_key" placeholder="自定,例如 my-secret-key-2024" autocomplete="off"> <input type="password" v-model="form.auth_key" placeholder="自定,例如 my-secret-key-2024" autocomplete="off">
<div class="hint">客户端用这个 Key 调 <code>/v1/audio/speech</code>,也是登录 <code>/admin</code> 的密码。装完可在 <code>/admin → 设置</code> 改。</div> <div class="hint">客户端用这个 Key 调 <code>/v1/audio/speech</code>,也是登录 <code>/dashboard</code> 的密码。装完可在 <code>/dashboard → 设置</code> 改(可另配独立 <code>admin_key</code> 用于后台登录,避免与业务 Key 共用)。</div>
</div> </div>
<div class="field"> <div class="field">
<label>火山 TTS API Key <span class="req">*</span></label> <label>火山 TTS API Key <span class="req">*</span></label>
@@ -216,7 +216,7 @@
<div class="card-head"> <div class="card-head">
<div> <div>
<div class="card-title">默认路由与格式</div> <div class="card-title">默认路由与格式</div>
<div class="card-sub">未传 voice/format 时使用。装完可在 <code>/admin → 设置</code> 改。</div> <div class="card-sub">未传 voice/format 时使用。装完可在 <code>/dashboard → 设置</code> 改。</div>
</div> </div>
<span class="step-badge">第 2 步</span> <span class="step-badge">第 2 步</span>
</div> </div>
@@ -261,7 +261,7 @@
<div class="card-head"> <div class="card-head">
<div> <div>
<div class="card-title">音色列表 <span style="font-weight: 400; color: var(--text-dim); font-size: 12px;">(至少 1 条)</span></div> <div class="card-title">音色列表 <span style="font-weight: 400; color: var(--text-dim); font-size: 12px;">(至少 1 条)</span></div>
<div class="card-sub">填入火山 TTS 音色 ID(S_ 开头的为复刻音色)。可后续在 /admin 中增删。</div> <div class="card-sub">填入火山 TTS 音色 ID(S_ 开头的为复刻音色)。可后续在 /dashboard 中增删。</div>
</div> </div>
<span class="step-badge">第 3 步</span> <span class="step-badge">第 3 步</span>
</div> </div>
@@ -322,7 +322,7 @@
<!-- Nav --> <!-- Nav -->
<div class="actions"> <div class="actions">
<button class="btn" @click="prev" :disabled="step===1 || submitting">← 上一步</button> <button class="btn" @click="prev" :disabled="step===1 || submitting">← 上一步</button>
<div class="hint-text">提交后立即进入正常模式;后续管理请访问 <code>/admin</code>。</div> <div class="hint-text">提交后立即进入正常模式;后续管理请访问 <code>/dashboard</code>。</div>
<div style="display: flex; gap: 8px;"> <div style="display: flex; gap: 8px;">
<button v-if="step < 4" class="btn btn-primary" @click="next">下一步 →</button> <button v-if="step < 4" class="btn btn-primary" @click="next">下一步 →</button>
<button v-else class="btn btn-primary" @click="submit" :disabled="submitting"> <button v-else class="btn btn-primary" @click="submit" :disabled="submitting">
@@ -412,7 +412,7 @@
}, },
voices: form.value.voices, voices: form.value.voices,
}); });
window.location.href = r.data.redirect || '/admin'; window.location.href = r.data.redirect || '/dashboard';
} catch (e) { } catch (e) {
const msg = e.response?.data?.error?.message || e.message; const msg = e.response?.data?.error?.message || e.message;
errorMsg.value = '安装失败: ' + msg; errorMsg.value = '安装失败: ' + msg;
+86
View File
@@ -11,6 +11,7 @@ import (
"sync" "sync"
"time" "time"
"github.com/volcano-tts/tts-api/adapter/provider"
"github.com/volcano-tts/tts-api/adapter/volcano" "github.com/volcano-tts/tts-api/adapter/volcano"
"github.com/volcano-tts/tts-api/common" "github.com/volcano-tts/tts-api/common"
"github.com/volcano-tts/tts-api/telemetry" "github.com/volcano-tts/tts-api/telemetry"
@@ -32,6 +33,14 @@ var (
ttsTimeout time.Duration = common.DefaultTimeout ttsTimeout time.Duration = common.DefaultTimeout
ttsConfigErr error ttsConfigErr error
authAPIKeys []string authAPIKeys []string
// adminAPIKeys 是**管理接口专用**凭证(admin_key)。
// 与 authAPIKeys(业务侧,/v1/audio/speech 用)分离:
// - admin_key 为空时回退到 auth_key/env,老部署行为完全不变
// - admin_key 配了之后,业务 key 无法访问 /api/admin/*,权限隔离成立
// 详见 docs/IMPLEMENT_v0.3.0.md 阶段 1。
adminAPIKeys []string
// adminKeySource 记录管理凭证的来源,仅用于启动摘要与排障。
adminKeySource string
// corsAllowAll / corsOrigins 拆成两个独立字段,各自在 RLock 下读取, // corsAllowAll / corsOrigins 拆成两个独立字段,各自在 RLock 下读取,
// 避免 CORSConfig 整体读时被 Lock 阻塞热路径。 // 避免 CORSConfig 整体读时被 Lock 阻塞热路径。
corsAllowAll bool corsAllowAll bool
@@ -47,6 +56,27 @@ func GetTTSOptions() volcano.Options {
return ttsOptions 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 写路径专用。 // SetTTSOptions 整体替换 TTSOptions。LoadRuntimeConfig 写路径专用。
func SetTTSOptions(o volcano.Options) { func SetTTSOptions(o volcano.Options) {
ttsMu.Lock() ttsMu.Lock()
@@ -108,6 +138,40 @@ func SetAuthAPIKeys(keys []string) {
authAPIKeys = out authAPIKeys = out
} }
// GetAdminKeys 读**管理接口专用**凭证列表;返回拷贝防止业务侧持有底层 slice。
// 供 middleware.RequireAdmin 使用;业务侧鉴权请用 GetAuthAPIKeys。
func GetAdminKeys() []string {
ttsMu.RLock()
defer ttsMu.RUnlock()
if len(adminAPIKeys) == 0 {
return nil
}
out := make([]string, len(adminAPIKeys))
copy(out, adminAPIKeys)
return out
}
// SetAdminKeys 整体替换管理凭证;入参被复制。source 仅用于启动摘要展示。
func SetAdminKeys(keys []string, source string) {
ttsMu.Lock()
defer ttsMu.Unlock()
adminKeySource = source
if len(keys) == 0 {
adminAPIKeys = nil
return
}
out := make([]string, len(keys))
copy(out, keys)
adminAPIKeys = out
}
// GetAdminKeySource 返回管理凭证来源:admin_key / auth_key / env / ""(未配置)。
func GetAdminKeySource() string {
ttsMu.RLock()
defer ttsMu.RUnlock()
return adminKeySource
}
// GetCORSAllowAll 读 CORS 是否放行所有来源。 // GetCORSAllowAll 读 CORS 是否放行所有来源。
func GetCORSAllowAll() bool { func GetCORSAllowAll() bool {
ttsMu.RLock() ttsMu.RLock()
@@ -391,6 +455,18 @@ func LoadRuntimeConfig(s Store) error {
SetAuthAPIKeys(nil) SetAuthAPIKeys(nil)
} }
// 管理凭证:admin_key(DB) > auth_key(DB) > OPENAI_TTS_API_KEY(env)
// 分离的目的:业务调用方拿到的 key 不应同时拥有管理后台权限。
// 不配 admin_key 时行为与旧版完全一致(回退用 auth_key),保证向后兼容。
switch {
case all["admin_key"] != "":
SetAdminKeys([]string{all["admin_key"]}, "admin_key")
case authKey != "":
SetAdminKeys([]string{authKey}, "auth_key")
default:
SetAdminKeys(nil, "")
}
// CORS 配置:DB > env // CORS 配置:DB > env
// cors_allow_all (bool): 允许所有来源(*) // cors_allow_all (bool): 允许所有来源(*)
// cors_origins (string): 逗号分隔白名单 // cors_origins (string): 逗号分隔白名单
@@ -540,6 +616,16 @@ func LogStartupSummary() {
log.Printf("OPENAI_TTS_API_KEY: 已设置 %d 个有效密钥", len(authKeys)) log.Printf("OPENAI_TTS_API_KEY: 已设置 %d 个有效密钥", len(authKeys))
} }
// v0.3.0:管理凭证独立于业务凭证(admin_key > auth_key > env)
switch GetAdminKeySource() {
case "admin_key":
log.Printf("管理凭证: 使用独立 admin_key(业务 key 无法访问管理接口)")
case "auth_key":
log.Printf("管理凭证: 未单独配置 admin_key,回退使用 auth_key(业务 key 同时拥有管理权限)")
default:
log.Printf("管理凭证: ✗ 未配置(normal 模式下服务将拒绝启动)")
}
allowAll := GetCORSAllowAll() allowAll := GetCORSAllowAll()
origins := GetCORSOrigins() origins := GetCORSOrigins()
if allowAll { if allowAll {
+207
View File
@@ -0,0 +1,207 @@
package store
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
)
// Channel 是一行渠道记录:
// - 一个上游账号(Provider=adapter 名 + 该账号的凭据)
// - 可限定支持的对外音色(Voices);空 = 不限(接受一切)
// - Priority 分档(大者优先),Weight 同档加权
//
// JSON tag 跟前台/admin.html 直接读 Go 字段对齐(参见 store/voices.go 顶部说明)。
type Channel struct {
ID int64 `json:"id"`
Name string `json:"name"`
Provider string `json:"provider"`
// CredentialsJSON 是序列化后的 provider.Credentials:
// {"api_key": "...", "scope": {"resource_id": "...", ...}}
// 我们落 JSON 列而非拆字段,因为不同 provider 需要的 scope 维度差异很大
// (火山要 resource_id,Azure 要 region,自建要 base_url ...),
// 拆字段会被某一家绑死。读时反序列化为 provider.Credentials。
CredentialsJSON string `json:"credentials_json"`
Voices string `json:"voices"` // 逗号分隔的对外 voice 名;空 = 不限
Priority int `json:"priority"`
Weight int `json:"weight"`
// Status: 1 = enabled, 2 = disabled(对齐 voices.enabled 的 0/1 风格,
// 但本阶段管理接口未上线,只用 int 留扩展位,后续可加 "维护中" 之类状态)。
Status int `json:"status"`
AutoBan bool `json:"auto_ban"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// ChannelList 列出所有渠道(按 priority DESC, id ASC),includeDisabled=false 时只返回 enabled。
// 排序规则固定,便于 router 启动期一次性加载后做 priority 分桶。
func (s *Store) ChannelList(includeDisabled bool) ([]Channel, error) {
q := `SELECT id, name, provider, credentials_json, voices, priority, weight, status, auto_ban, created_at, updated_at
FROM channels`
if !includeDisabled {
q += ` WHERE status = 1`
}
q += ` ORDER BY priority DESC, id ASC`
rows, err := s.db.Query(q)
if err != nil {
return nil, fmt.Errorf("store: channel list: %w", err)
}
defer rows.Close()
out := make([]Channel, 0, 4)
for rows.Next() {
c, err := scanChannel(rows)
if err != nil {
return nil, err
}
out = append(out, c)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: channel list rows: %w", err)
}
return out, nil
}
// ChannelGet 按 id 查;未命中返回 ErrNotFound。
func (s *Store) ChannelGet(id int64) (*Channel, error) {
row := s.db.QueryRow(`SELECT id, name, provider, credentials_json, voices, priority, weight, status, auto_ban, created_at, updated_at
FROM channels WHERE id = ?`, id)
c, err := scanChannel(row)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("store: channel get id=%d: %w", id, err)
}
return &c, nil
}
// ChannelInsert 新增一条渠道;name 冲突返回 ErrDuplicate。
// 输入校验错误(必填项缺失)返回 wrap ErrInvalid;服务端错误(DB 失败等)不被 wrap。
func (s *Store) ChannelInsert(c Channel) (int64, error) {
c.Name = strings.TrimSpace(c.Name)
c.Provider = strings.TrimSpace(c.Provider)
c.CredentialsJSON = strings.TrimSpace(c.CredentialsJSON)
c.Voices = normalizeVoicesCSV(c.Voices)
if c.Name == "" {
return 0, fmt.Errorf("%w: name is required", ErrInvalid)
}
if c.Provider == "" {
return 0, fmt.Errorf("%w: provider is required", ErrInvalid)
}
if c.CredentialsJSON == "" {
return 0, fmt.Errorf("%w: credentials_json is required", ErrInvalid)
}
// credentials_json 必须是合法 JSON;否则后续 router 加载会崩,这里挡掉。
if !json.Valid([]byte(c.CredentialsJSON)) {
return 0, fmt.Errorf("%w: credentials_json is not valid JSON", ErrInvalid)
}
if c.Weight <= 0 {
c.Weight = 1 // 默认权重 1
}
if c.Status != 1 && c.Status != 2 {
c.Status = 1 // 默认启用
}
res, err := s.db.Exec(`
INSERT INTO channels (name, provider, credentials_json, voices, priority, weight, status, auto_ban, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))`,
c.Name, c.Provider, c.CredentialsJSON, c.Voices, c.Priority, c.Weight, c.Status, boolToInt(c.AutoBan))
if err != nil {
if isUniqueViolation(err) {
return 0, ErrChannelDuplicate
}
return 0, fmt.Errorf("store: channel insert: %w", err)
}
id, err := res.LastInsertId()
if err != nil {
return 0, fmt.Errorf("store: channel insert lastid: %w", err)
}
return id, nil
}
// ChannelSetStatus 改状态(1=enabled, 2=disabled);未命中返回 ErrNotFound。
// 阶段 1 没有 admin 接口,但 router 启动期之后可被 auto_ban 流程调用,
// 所以这个方法必须先有,管理接口只是包装。
func (s *Store) ChannelSetStatus(id int64, status int) error {
if status != 1 && status != 2 {
return fmt.Errorf("%w: status must be 1 (enabled) or 2 (disabled)", ErrInvalid)
}
res, err := s.db.Exec(`UPDATE channels SET status=?, updated_at=datetime('now') WHERE id = ?`, status, id)
if err != nil {
return fmt.Errorf("store: channel set status id=%d: %w", id, err)
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrNotFound
}
return nil
}
// ChannelDelete 按 id 删;未命中返回 ErrNotFound。
func (s *Store) ChannelDelete(id int64) error {
res, err := s.db.Exec(`DELETE FROM channels WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("store: channel delete id=%d: %w", id, err)
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrNotFound
}
return nil
}
// ChannelCount 统计行数;阶段 1 暂未使用,保留为未来 admin 仪表盘用。
func (s *Store) ChannelCount() (int, error) {
var n int
err := s.db.QueryRow(`SELECT COUNT(*) FROM channels`).Scan(&n)
if err != nil {
return 0, fmt.Errorf("store: channel count: %w", err)
}
return n, nil
}
// ErrChannelDuplicate 表示 channel name 唯一冲突;与 voices.ErrDuplicate 同名但消息不同,
// 避免 channel 重名被翻译成"voice name already exists"。
var ErrChannelDuplicate = errors.New("store: channel name already exists")
// scanChannel 把 row 扫描成 Channel;接受 *sql.Row 或 *sql.Rows(都实现 Scan)。
func scanChannel(r scanner) (Channel, error) {
var c Channel
var autoBan int
err := r.Scan(&c.ID, &c.Name, &c.Provider, &c.CredentialsJSON, &c.Voices,
&c.Priority, &c.Weight, &c.Status, &autoBan, &c.CreatedAt, &c.UpdatedAt)
if err != nil {
return c, err
}
c.AutoBan = autoBan != 0
return c, nil
}
// normalizeVoicesCSV 把 "a,,b, a ,c" 规整成 "a,b,c" —— 重复项去重,
// 顺序保留(便于 hash 比较和稳定展示);空字符串返 ""(语义=不限)。
func normalizeVoicesCSV(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
parts := strings.Split(raw, ",")
seen := make(map[string]struct{}, len(parts))
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
if _, ok := seen[p]; ok {
continue
}
seen[p] = struct{}{}
out = append(out, p)
}
return strings.Join(out, ",")
}
+28 -1
View File
@@ -18,7 +18,7 @@ import (
// schemaVersion 是当前 schema 版本号;每次结构性变更 +1。 // schemaVersion 是当前 schema 版本号;每次结构性变更 +1。
// migrate.go 负责在 Open 时按版本号增量应用。 // migrate.go 负责在 Open 时按版本号增量应用。
const schemaVersion = 1 const schemaVersion = 2
// Store 是 SQLite 访问层的统一入口;所有 settings/voices 操作都通过它。 // Store 是 SQLite 访问层的统一入口;所有 settings/voices 操作都通过它。
type Store struct { type Store struct {
@@ -149,5 +149,32 @@ func (s *Store) migrate() error {
INSERT OR IGNORE INTO schema_version (version) VALUES (?)`, schemaVersion); err != nil { INSERT OR IGNORE INTO schema_version (version) VALUES (?)`, schemaVersion); err != nil {
return fmt.Errorf("insert schema_version: %w", err) return fmt.Errorf("insert schema_version: %w", err)
} }
// channels 表(多渠道路由,阶段 1 引入)。
// - name 唯一:管理侧标识,不允许重名
// - credentials_json: 序列化后的 provider.Credentials(见 store/channels.go 注释)
// - voices CSV: 限定该渠道支持的对外音色;空 = 不限
// - priority/weight: 大者优先 + 同档加权随机(由 adapter/route 实现)
// - status: 1=enabled 2=disabled(留扩展位,管理接口放阶段 2 上)
// - auto_ban: new-api 风格连续失败自动禁用标记,阶段 1 仅存,未接自动逻辑
if _, err := s.db.Exec(`
CREATE TABLE IF NOT EXISTS channels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
provider TEXT NOT NULL,
credentials_json TEXT NOT NULL,
voices TEXT NOT NULL DEFAULT '',
priority INTEGER NOT NULL DEFAULT 0,
weight INTEGER NOT NULL DEFAULT 1,
status INTEGER NOT NULL DEFAULT 1,
auto_ban INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)`); err != nil {
return fmt.Errorf("create channels: %w", err)
}
if _, err := s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_channels_status_priority ON channels(status, priority DESC)`); err != nil {
return fmt.Errorf("create idx_channels_status_priority: %w", err)
}
return nil return nil
} }
+5 -1
View File
@@ -36,8 +36,12 @@ func newCounter(name, help string, labelNames []string) *Counter {
func (c *Counter) Inc(labels Labels) { c.Add(1, labels) } func (c *Counter) Inc(labels Labels) { c.Add(1, labels) }
// Add 累加 v(v 必须 >= 0)。 // Add 累加 v(v 必须 >= 0)。
//
// 空接收者(nil *Counter)安全:未初始化的全局指标(如 metrics.Init() 尚未调用时
// 的 metrics.UpstreamTotal)会被静默忽略,而不是 panic。
// 这让"直接调用 handler"的测试/复用场景不会因为漏掉指标初始化而崩溃。
func (c *Counter) Add(v float64, labels Labels) { func (c *Counter) Add(v float64, labels Labels) {
if v < 0 { if c == nil || v < 0 {
return return
} }
child := c.getOrCreate(labels) child := c.getOrCreate(labels)
+8
View File
@@ -33,7 +33,11 @@ func newGauge(name, help string, labelNames []string) *Gauge {
} }
// Set 直接设置当前值。 // Set 直接设置当前值。
// 空接收者(nil *Gauge)安全,见 Counter.Add 的说明。
func (g *Gauge) Set(v float64, labels Labels) { func (g *Gauge) Set(v float64, labels Labels) {
if g == nil {
return
}
child := g.getOrCreate(labels) child := g.getOrCreate(labels)
child.bits.Store(float64bits(v)) child.bits.Store(float64bits(v))
} }
@@ -45,7 +49,11 @@ func (g *Gauge) Inc(labels Labels) { g.Add(1, labels) }
func (g *Gauge) Dec(labels Labels) { g.Add(-1, labels) } func (g *Gauge) Dec(labels Labels) { g.Add(-1, labels) }
// Add 累加 v(可负)。 // Add 累加 v(可负)。
// 空接收者(nil *Gauge)安全,见 Counter.Add 的说明。
func (g *Gauge) Add(v float64, labels Labels) { func (g *Gauge) Add(v float64, labels Labels) {
if g == nil {
return
}
child := g.getOrCreate(labels) child := g.getOrCreate(labels)
for { for {
bits := child.bits.Load() bits := child.bits.Load()
+4
View File
@@ -47,7 +47,11 @@ func newHistogram(name, help string, buckets []float64, labelNames []string) *Hi
} }
// Observe 记录一个观测值。 // Observe 记录一个观测值。
// 空接收者(nil *Histogram)安全,见 Counter.Add 的说明。
func (h *Histogram) Observe(v float64, labels Labels) { func (h *Histogram) Observe(v float64, labels Labels) {
if h == nil {
return
}
child := h.getOrCreate(labels) child := h.getOrCreate(labels)
for { for {
bits := child.sumBits.Load() bits := child.sumBits.Load()
+6 -1
View File
@@ -5,7 +5,12 @@
// - 零外部依赖,只使用标准库; // - 零外部依赖,只使用标准库;
// - label key 在指标注册时锁定,运行期不可新增(避免 cardinality 爆炸); // - label key 在指标注册时锁定,运行期不可新增(避免 cardinality 爆炸);
// - 所有并发安全由实现保证,调用方无需加锁; // - 所有并发安全由实现保证,调用方无需加锁;
// - Meter 是高层入口,NoopMeter 用于测试。 // - Meter 是高层入口,NoopMeter 用于测试;
// - **空接收者安全**: 未初始化的 *Counter / *Gauge / *Histogram(即 nil 指针)
// 上的 Inc/Add/Set/Observe 一律静默忽略,不 panic。
// 这条约定是为 metrics 包的全局指标变量服务的 —— 它们默认为 nil,只有 main
// 调用过 metrics.Init() 之后才有值;直接复用 handler 的场景(集成测试、
// 未来做成库)不应该因为漏掉指标初始化而崩掉整个请求处理。
package telemetry package telemetry
import ( import (