68 Commits
Author SHA1 Message Date
sun 0d3517eb5c fix: 安全加固(VUL-001~009)+ 构建版本注入 + 死代码清理' (#2) from develop into main
Docker Publish / build-and-push (push) Canceled after 0s
Reviewed-on: #2
2026-08-27 11:01:47 +08:00
sun cdc9a7c94b ci: 添加 GitHub Actions Docker 发布 workflow
为 Gitea→GitHub 镜像 + GitHub 镜像仓库场景准备 Docker 镜像
自动构建和发布流程。

触发条件:
  - push tag v*  (release 时打 tag 自动触发)
  - workflow_dispatch  (手动触发,可在 GitHub UI 测试)

构建:
  - 多平台:linux/amd64 + linux/arm64
  - 通过 QEMU + Buildx 跨架构构建
  - 推送到 ghcr.io/<github-user>/volcano-engine-tts-ui
  - 双标签:语义版本 + 短 commit hash
  - OCI labels 包含 version 和 revision

与之前 version 包配合:
  workflow 用 git describe --tags --always --dirty 算 VERSION
  (commit hash 类似),通过 --build-arg 传给 Dockerfile,
  Dockerfile 用 -ldflags 注入到 version.Version / version.Commit
  两个变量。最终 /health 端点返回构建时注入的真实版本信息。

前置条件(用户需自行完成):
  1. GitHub 建镜像仓库
  2. Gitea 配置 Push Mirror 到 GitHub(开启 Sync Tags)
  3. 仓库设为 Public(否则 GHCR 镜像默认 private)
2026-08-27 10:43:19 +08:00
sun c00c46e7a1 chore: 注入构建时版本信息到 /health 端点
现状:controller/tts.go:232 硬编码 Version: "2.0.0 (v3 API)",
不会随代码变化而更新,/health 无法反映实际跑的代码。
修复:四文件改动,实现构建时 ldflags 注入。
  1. 新建 version/version.go,声明两个包级变量:
       Version (默认 "dev")
       Commit  (默认 "dev")
  2. dto/health.go:HealthResponse 加 Commit 字段(JSON 输出多一字段)
  3. controller/tts.go:HealthResponse 用 version.Version / version.Commit
     替代硬编码字面量
  4. Dockerfile:加 ARG VERSION=dev ARG COMMIT=dev,
     go build 时通过 -ldflags 注入到 version 包的两个变量
效果:
  - 本地 go build (不传 ldflags) → version=dev commit=dev
  - 开发 build (git describe) → version=v0.2.0-5-g4abcd5 commit=g4abcd5
  - release build (打 tag 后) → version=v0.2.1 commit=<对应 hash>
测试环境(develop 分支,无 tag)显示距离上次 release 几个 commit +
具体 hash;生产环境(main + tag)显示干净 semver。
2026-08-27 10:18:34 +08:00
sun 3b3aa3b708 chore: 清理 DEBT-2 死代码(7 文件,约 30 行)
VUL-003 修复期间意外发现项目遗留一批死代码,本次一并清掉:

  - middleware/ratelimit_middleware.go(37 行,物理删除)
    文件内 RateLimit / ConcurrencyLimit 函数从 977e9cc 创建后
    从未被引用,370a217 commit 用 ratelimit_instrumented.go
    (带 metrics 埋点 + 路径过滤)取代了它。占用包体,清。

  - middleware/auth.go:InitAPIKeys(6 行)
    注释说"已在 setting.InitAuthConfig 中完成",无 op。

  - middleware/cors.go:InitCORSConfig(6 行)
    同上,setting.InitCORSConfig 已做实际工作。

  - dto/tts.go:ByteDanceTTSConfig 类型(7 行)
    完整的配置走 setting.TTSOptions + adapter/volcano.Options,
    此类型从未被任何代码实例化。

  - setting/config.go: var _ = dto.ByteDanceTTSConfig{} 占位(3 行)
    配合上方类型删除,移除 dto import。

  - controller/tts.go:resolveClientFormat
    合并 if reqFmt == "" 与 default 分支(都返回
    setting.TTSOptions.Format),2 行简化。

  - common/constants.go: MaxResponseTimes / MaxErrors
    定义后从未被任何文件引用。

  - middleware/ratelimit_instrumented.go 顶部注释
    移除对"原 ratelimit_middleware.go"的悬空引用,
    改为描述本文件相对路由使用实现的两个增强点。

影响:
  - 包体减少约 30 行
  - 降低新人接手时的代码理解成本
  - 零功能变更,24 个现有测试用例全过
2026-08-27 00:57:49 +08:00
sun 695b3ecf25 docs: README 修正 speed 与 sample_rate 描述 (VUL-008 / VUL-009)
VUL-008 (信息): speed 超范围静默截断无提示
  修复:「API 使用说明」speed 描述从"0.25 ~ 4.0"改为
  "客户端接受范围 0.25 ~ 4.0;火山实际生效范围 0.5 ~ 2.0
  (speech_rate [-50, 100]),超出范围会被静默截断,客户端无感反馈"。
  选 TODO 方案 1(诚实修正文档),不改代码。

VUL-009 (信息): WAV 采样率依赖配置而非上游实际值
  修复:BYTEDANCE_TTS_SAMPLE_RATE 描述加风险提示
  "此值直接写入 WAV 头,需与上游实际 PCM 采样率一致,
  否则音频变速变调"。
  上游 v3 协议不返回 sample_rate 字段,代码侧无法自动取真实值,
  仅文档强化。
2026-08-26 16:16:56 +08:00
sun 7343d5aa5c VUL-006 (低): 监控端点 (/metrics /health /dashboard) 无鉴权
判定:不引入新鉴权机制(会破坏 Prometheus 抓取),文档引导。
  修复:「观测 / Metrics」section 末尾新增「公网部署:监控端点
  无鉴权」段,含三端点风险表 + nginx 反代 basic auth 配置示例。

VUL-007 (低): 未设置 OPENAI_TTS_API_KEY 时鉴权完全关闭
  判定:不改代码(无 API Key 即不鉴权是 README 明示的设计),
  文档强化。
  修复:三处加强:
    1. 环境变量表 OPENAI_TTS_API_KEY 行加 🔴 公网必设 标记
    2. 「API 使用说明」section 顶部 callout 警示
    3. 「部署」section 末尾新增「公网部署安全清单」段,统一
       覆盖鉴权关闭与监控端点保护,形成完整安全姿态
2026-08-26 16:06:17 +08:00
sun 171503d775 fix: VUL-002 修复 transport 错误埋点缺失
VUL-002 (低): transport 层错误不进入 UpstreamErrors 指标
原代码用 if errCode != 0 判断是否记录错误,但合成链路中
多种错误场景的 errCode 本身是 0(火山 v3 业务码非 0 时才
会传入),导致这些场景在 /metrics 上完全不可见:
  - transport_error: client.PostStream 失败(DNS / 连接 / TLS)
  - request_error  : buildRequest 序列化失败
  - wrap_error     : WAV 头拼装失败
  - stream_error (code=0): 读流错误
修复:把判断改为 if status != "ok",status 是上游调用全链
路的权威错误指示器,任何非 ok 状态都计为错误。
codeLabel(0) → "transport" 已有定义,修复后该分类真正生效,
火山接口不可达等网络故障首次在监控上可见。
向后兼容:已统计的 http_XXX 错误(status="http_xxx" != "ok")
行为不变,新增 transport / request / wrap / stream 错误进入指标。
2026-08-26 15:35:24 +08:00
sun 91b0c8acee fix: VUL-005 修复日志注入(RequestURI 与上游错误体转义)
VUL-005 (低): 攻击者可在 HTTP 请求 URL 或上游错误响应中
注入 \n / \r 字符,伪造日志行干扰排障。无代码执行风险。

修复位置:
  - middleware/logger.go: 访问日志中的 r.RequestURI 是未经
    解析的原始请求行,客户端可控。转义 \n / \r 为字面字符
  - adapter/volcano/synthesis.go: 上游非 200 响应体 (rawBody)
    可能是攻击者控制的恶意内容,转义后再嵌入错误消息
2026-08-26 11:52:28 +08:00
sun a238e5c2a4 chore: 忽略本地 TODO.md,避免误提交
TODO.md 是本地待办清单,通过任务看板追踪更合适,不入 git 仓库。
2026-08-26 00:37:15 +08:00
sun 272565f736 fix: VUL-003 完整修复,启发式/精确双模式 XFF 解析
VUL-003 (中): X-Forwarded-For 信任链可被伪造 IP 绕过限流

本服务定位为公网入口,即使单人使用,公网暴露意味着攻击面
与公开服务等同,不能"够用就行"。

采用渐进式披露设计,平衡易用性与功能性:

  1. 启发式模式(默认, 不设环境变量 或 TRUSTED_PROXY_HOPS=0)
     - 从 XFF 链尾扫描,跳过私有 IP,返回第一个公网 IP
     - 适合 90% 部署(单跳/多跳/直出),无需了解精确跳数
     - 限制:多跳 CDN 场景下,限流粒度为"按 CDN 边缘 IP"
     - 直出部署:整个 XFF 分支不会执行

  2. 精确模式(TRUSTED_PROXY_HOPS=N, N>0)
     - 从 XFF 链尾倒数第 N+1 个位置取值
     - 精准到真实 client,需按实际反代跳数正确配置
     - N=1:单跳反代;N=2:CDN+反代;以此类推

  3. 两种模式都从链尾扫描
     - XFF 首值是客户端可控的,信任首值等于信任攻击者
     - 链尾由受控的反代添加,天然免疫伪造绕过

  4. 默认值从 1 改为 0(行为变化)
     - 旧默认:精确模式 N=1,取 XFF 末值
     - 新默认:启发式模式,跳过链尾私有 IP
     - 对单跳场景行为相同
     - 对多跳/链尾含私有 IP 场景新版更准确(返回真实公网 IP)

  5. 配套
     - middleware/ratelimit_test.go:24 个表驱动测试用例,
       覆盖直出/单跳/多跳/伪造/畸形/精确 N 边界,全部通过
     - setting/config.go:LogStartupSummary 显示当前 XFF 模式
     - .env.example:重写说明,标注默认行为 + 何时需配
     - README.md:新增"反代拓扑与 X-Forwarded-For 解析"章节
       (何时需要/两种模式/行为对比/为什么从链尾/启动日志验证)

  6. 已知边界:TRUSTED_PROXY_HOPS=00 等被 Atoi 解析为 0 的
     输入归入启发式模式,日志不会出现"精确模式 0 跳"矛盾输出。
2026-08-26 00:36:22 +08:00
sun ed3d7c6b61 本次代码审查(全 12 个包,约 2400 行)的交付物:
- 9 项漏洞(高 1 / 中 2 / 低 4 / 信息 2)
  - 已核查无风险项 8 条
  - 工程债务记录(零测试、死代码、云盘占用)
  - 安全加固建议(Docker 密钥传递、TLS、依赖固定)

供后续按优先级处理备查。
"
2026-08-25 23:10:34 +08:00
sun 72d0d6a3a9 VUL-001 (中): aac/flac 响应 Content-Type 与真实数据不一致
controller.tts.go:contentTypeFor 对 aac/flac 返回 audio/aac/flac,
  但 adapter/volcano/synthesis.go 在上游降级时仅修改 opts.Format,
  finalFormat 仍保留 clientFormat,导致响应头与字节流不符。
  修复:finalFormat 改为反映真实输出格式(非 wav 时取 opts.Format),
  客户端按 AAC/FLAC 解码 MP3 流的失败场景消除。

VUL-004 (高): .env 凭据泄露
  README 引导用户 cp .env.example .env 填密钥,但 .gitignore
  未忽略 .env,任何 git add . 都会把含 BYTEDANCE_TTS_API_KEY
  的文件提交进 git 历史,不可逆。
  修复:
  - .gitignore 新增 Secrets section,拦截 .env 与 .env.* 变体,
    保留 .env.example 作为模板追踪
  - .dockerignore 升级为同名规则模式,覆盖未来 .env.local /
    .env.production 等变体,保证 git 与 docker 两通道一致

详见 VULNERABILITY_REPORT.md
"
2026-08-25 23:09:47 +08:00
sun d1e7f9a4dd chore: 删除 ratelimit_middleware.go.tmp 临时文件
与 ratelimit_middleware.go 内容完全重复(SHA256 一致,1116 字节),
且无任何代码引用 .tmp 路径,属于误提交的开发期残留文件。
2026-08-23 13:16:17 +08:00
sun 78c72004bf chore: 删除 ratelimit_middleware.go.tmp 临时文件
与 ratelimit_middleware.go 内容完全重复(SHA256 一致,1116 字节),
且无任何代码引用 .tmp 路径,属于误提交的开发期残留文件。
2026-08-23 13:15:15 +08:00
sun 4a8c563b32 Merge branch 'develop' into main
将 develop 分支累积的 51 个提交合入 main,涵盖完整的架构重构与
可观测性建设,作为下一阶段对外发布的稳定基线。

## 主要变更

### 架构重构
- 将单体 tts_server.go (778 行) 拆分为模块化目录:adapter/volcano、
  controller、dto、middleware、router、setting、telemetry、metrics
- 引入 OpenAI /v1/audio/speech 兼容接口,火山引擎作为后端适配器
- 统一以 main.go 作为入口,集中启动顺序与信号处理

### 火山 TTS 适配器
- 重构请求/响应/合成为独立文件 (request.go / response.go /
  synthesis.go / audio.go),namespace 修正为 UnidirectionalTTS
- 适配 v3 模型参数与音色复刻路由
- 兼容上游 sentence 字段对象/字符串两种形态,处理 event 字段缺失
- 新增请求诊断日志,便于排障

### 可观测性
- 自研轻量 telemetry 库 (counter/gauge/histogram/labels/registry)
- 暴露 Prometheus 格式指标,内置 /metrics 端点
- 限流与并发中间件仅统计 /v1/ 路由,避免监控路径污染计数
- 新增 health.html 监控面板,分组展示请求、延迟、限流、字节数等

### 中间件
- CORS:支持 ALLOWED_ORIGINS 白名单,日志优化
- 限流:令牌桶 + 并发控制,带可观测埋点
- 安全/鉴权:API Key 校验、请求体大小限制
- Logger:统一请求日志,修复中文乱码与换行

### 配置与启动
- setting/config.go 集中环境变量管理,启动时打印配置摘要
- 配置文件切换到 .env.example,支持音频格式/采样率/调试日志开关

### 文档与构建
- README 重写,补充 BYTEDANCE_TTS_DEBUG 调试日志小节,移除 1.0 旧内容
- Go 版本升级 1.23 -> 1.26
- 新增 Dockerfile / docker-compose.yml,镜像构建与编排
- 补充 .dockerignore / .gitignore,清理 BOM 头与废弃 tts-api.exe

### 修复
- 修复 response.go switch 大括号结构错误、synthesis.go 类型不匹配
- 修复请求体过大时的处理、冗余代码与日志乱码
- 限流/并发中间件跳过非 /v1/ 路由,避免监控路径污染计数
2026-08-23 10:01:35 +08:00
sun 370a21760c fix: 限流和并发中间件跳过非 /v1/ 路由,避免监控路径污染计数 2026-08-16 18:50:33 +08:00
sun f971de6821 chore: 删除无用的预检测试脚本 _preflight.js
该脚本仅用于本地测试,不属于项目核心代码,清理冗余文件减少项目杂乱度
2026-08-16 18:43:56 +08:00
sun 0f7102a41d fix: 补上 dashboard 缺失的 formatBytes 函数定义 2026-08-16 18:41:33 +08:00
sun ec2b78533d test: add preflight check test script for cors
新增了用于测试CORS预检请求的_node脚本,包含OPTIONS预检请求和实际POST请求的测试逻辑,用于验证接口的跨域配置是否正确。
2026-08-16 18:35:34 +08:00
sun 36e0d87cec ui: 替换 unpkg 为 bootcdn 国内 CDN 2026-08-16 18:32:58 +08:00
sun c4e278d544 ui: 重写监控面板,解析Prometheus指标分组展示,移除原始文本iframe 2026-08-16 18:13:04 +08:00
sun 5519697f87 fix: merge case sentence and empty to handle audio frames with empty event field 2026-08-16 18:03:21 +08:00
sun 78bb7d87ab refactor: remove fallback audio collection in case empty after namespace fix 2026-08-16 17:56:49 +08:00
sun d516dddf1d fix: namespace 修正为 UnidirectionalTTS 匹配 HTTP 单向端点 2026-08-16 17:54:43 +08:00
sun 677f606935 fix: 修复上游省略event字段导致音频帧被丢弃的bug 2026-08-16 17:50:02 +08:00
sun 55244e9a45 fix: 修复 response.go switch 语句大括号结构错误 2026-08-16 17:32:00 +08:00
sun 483db85259 fix: 兼容上游 sentence 字段为对象/字符串两种形态,加 debug 打印无 event 元数据行 2026-08-16 17:27:20 +08:00
sun b4b8ec33ed docs: 重写README,补充BYTEDANCE_TTS_DEBUG调试日志独立小节 2026-08-16 14:41:07 +08:00
sun cdd143f7e2 docs: 移除复刻1.0相关内容,仅保留2.0 2026-08-15 19:17:42 +08:00
sun c84f99a13f docs: 更新README补充TTS可选参数、调试日志、音频格式说明 2026-08-15 19:12:40 +08:00
sun 155fab6b4b feat: add debug log control and optimize error response and cors log
- add DebugLog global variable controlled by BYTEDANCE_TTS_DEBUG env
- enable debug logs for cors, tts upstream, response parsing etc when debug mode is on
- update tts error response to use standardized json error format
- add dashboard and metrics path to cache-control exempt list
2026-08-15 19:03:37 +08:00
sun 1455e4bf80 fix: 修复 synthesis.go 类型不匹配编译错误 ([]byte→string) 2026-08-15 17:55:13 +08:00
sun f75a8461d1 chore: 移除文件开头多余的UTF-8 BOM头
修复多个文件中存在的隐藏BOM字符问题,包括go.mod、README.md、health.html和.env.example,同时更新go.sum文件的校验和
2026-08-15 15:02:00 +08:00
sun 81fcc9fff4 chore: 清理多余的BOM头并整理依赖与telemetry代码
- 移除所有文件开头的多余UTF-8 BOM头
- 简化go.mod依赖,移除未使用的uuid包
- 重构telemetry包:将Meter改为接口并拆分默认实现
2026-08-15 14:38:14 +08:00
sun 5b970f49e1 feat: 实现完整可观测性架构与火山v3适配器重构
重构整体架构:
1.  新增telemetry包实现零依赖的Prometheus指标系统
2.  新增metrics包集中管理业务埋点指标
3.  重构火山v3适配器,拆分client/request/response等模块
4.  替换旧的service/stats统计系统为标准指标埋点
5.  新增/metrics观测端点与完整仪表盘支持

功能更新:
- 实现基于IP的限流与并发限制,添加指标埋点
- 重构TTS控制器,支持多格式输出与完整错误分类
- 更新.env.example配置示例,新增多项可选参数
- 替换旧的volcano适配器实现,支持完整的v3 API特性
- 清理冗余代码,移除service/stats与旧adapter实现
2026-08-15 13:30:35 +08:00
sun 07a7dd450c refactor(tts): 简化火山TTS实现,移除可配置格式采样率等参数
重构了火山TTS的适配器与配置逻辑,移除了格式、采样率、model等可配置项,固定使用wav/24000Hz参数,简化请求流程并删除冗余的格式转换、WAV头拼装逻辑,同时更新文档与环境变量配置。
2026-07-01 18:27:07 +08:00
sun 638dcc7721 fix: 更新字节语音合成默认模型为音色复刻路由适配版本
更新了默认模型值从seed-tts-2.0-standard为seed-icl-2.0,同步修改了配置检查逻辑、文档说明,修正了复刻音色的使用指引,解决默认模型与复刻资源不匹配导致的55000000错误问题。
2026-07-01 15:02:43 +08:00
sun 7e2d050d51 feat(volcano): add upstream TTS request diagnostic log
移除了README中旧的环境配置日志说明部分,新增火山TTS上游请求的诊断日志,打印关键请求参数和请求体用于排查问题
2026-07-01 14:31:40 +08:00
sun f39c72acbe refactor(setting): 优化配置启动日志与错误提示
1. 移除main.go中重复的TTS配置错误警告
2. 统一日志中文全角逗号格式
3. 新增TTS配置项脱敏掩码函数
4. 完善启动日志的必填项检查和状态展示
5. 调整TTS配置错误的提示文案,明确500路由情况
2026-07-01 00:13:15 +08:00
sun 7e1102902d refactor(volcano adapter): 重构火山 TTS 适配器请求体结构与错误处理
重构请求体为类型安全的结构体实现,替换原有的map动态构造方式;统一错误处理为fmt.Errorf包装原始错误,优化注释与代码格式,提升代码可维护性与可读性。
2026-06-30 23:34:45 +08:00
sun 746da76fa4 chore: 新增os包导入以支持系统信号处理
补充导入os包,完善依赖引入完整性
2026-06-30 22:39:26 +08:00
sun 3dc9632c1b refactor(setting): 集中管理环境配置并新增启动日志汇总
1.  将所有环境变量读取逻辑迁移至setting包统一管理,移除各模块直接os.Getenv调用
2.  新增全局配置结构体统一管理服务端口、鉴权、跨域等配置
3.  添加启动期配置汇总日志,方便运维核对环境配置生效情况
4.  重构鉴权和CORS中间件,改为读取全局配置而非直接读取环境变量
5.  优化health端点的环境变量检查逻辑,使用已加载的全局配置而非重复读取环境
2026-06-30 20:55:56 +08:00
sun 0aad65ed78 chore(docker): add compose file version declaration
补充docker-compose.yml的版本声明,规范compose文件格式
2026-06-27 23:49:09 +08:00
sun 21b86bfcfe feat: 新增音频格式与采样率配置,完善OpenAI兼容格式映射
1.  新增BYTEDANCE_TTS_FORMAT和BYTEDANCE_TTS_SAMPLE_RATE环境变量配置
2.  实现OpenAI TTS格式到火山API格式的映射转换
3.  自动处理wav格式:内部用pcm请求后封装标准WAV头
4.  动态设置响应Content-Type,适配不同输出格式
5.  更新文档与配置示例,完善日志与错误处理
2026-06-27 23:28:35 +08:00
sun 82cc68e7ee chore(setting): add check for ALLOWED_ORIGINS environment variable
新增对ALLOWED_ORIGINS环境变量的存在性检查,完善环境变量校验逻辑
2026-06-27 14:42:55 +08:00
sun 361a9d6401 fix(middleware): 修复日志输出乱码问题
将原本乱码的日志提示替换为简体中文正常文本,提升日志可读性
2026-06-27 14:31:10 +08:00
sun 15b0470cc8 feat(volcano-tts): add support for v3 model parameter
1. 新增BYTEDANCE_TTS_MODEL环境变量支持,为火山TTS v3 API配置子模型版本
2. 为复刻音色添加默认兜底模型,修复55000000报错问题
3. 更新文档说明v3 API调用规则与环境变量配置
4. 修正语速转换的范围限制,匹配官方文档规范
2026-06-27 14:09:46 +08:00
sun bcbd796fa5 docs: 更新README的日志查看文档
补充了完整的日志分类、示例和排查技巧,优化日志说明结构
2026-06-26 23:13:03 +08:00
sun 8592843bdf refactor(tts controller): add detailed request logging for TTS handler
新增truncateForLog工具函数处理日志截断,为各个请求校验环节和错误场景补充详细日志记录,包括请求方法、鉴权状态、配置错误、请求体读取、参数校验以及合成失败等场景,同时记录客户端IP、请求路径等上下文信息,方便问题排查。
2026-06-26 23:09:27 +08:00
sun b93ede29e0 docs: 更新README,添加CORS配置和相关排障说明
新增ALLOWED_ORIGINS环境变量文档,补充资源ID匹配错误、PowerShell curl使用的排障内容,更新日志排查项
2026-06-26 22:57:02 +08:00
sun 4aed9667b7 fix(adapter/volcano): 修改火山语音合成的命名空间为UnidirectionalTTS
将原BidirectionalTTS命名空间调整为UnidirectionalTTS,适配接口要求
2026-06-26 21:53:46 +08:00
sun 03bb98beb8 fix: 修复日志输出乱码和缺失换行符的问题
修复了两处日志问题:一是将乱码的中文提示改为正确的"配置初始化成功",二是为两条警告日志补充了换行符,保证日志输出格式正确。
2026-06-26 21:07:35 +08:00
sun bc42295ff6 chore(controller/tts): 移除未使用的log导入包
删除了tts控制器文件中不再需要的log标准库导入
2026-06-26 20:46:34 +08:00
sun f704e7d71d build(docker): 将go基础镜像版本从1.23升级到1.26
更新构建镜像使用的golang官方镜像版本,以获取最新的语言特性和安全补丁
2026-06-26 20:43:44 +08:00
sun 61431e00ba refactor: 重构并优化项目多项功能
1. 调整CORS中间件挂载位置,重构CORS处理逻辑
2. 重写IP获取逻辑,增加私有网络IP信任校验
3. 优化日志中间件,移除/health接口单独日志逻辑
4. 改进API密钥未配置时的提示信息
5. 重构volcano TTS调用,新增voice参数支持
6. 优化请求体过大错误处理
7. 完善统计服务,修复环形缓冲区遍历逻辑,新增去重错误日志功能
2026-06-26 19:12:50 +08:00
sun 3d50b6c69d fix: 修复代码格式、乱码和CORS校验问题
1. 修复tts.go中多余的缩进错误
2. 修复main.go和auth.go中的中文乱码问题,修正日志文本
3. 修复CORS校验逻辑,将origin转为小写后再判断协议前缀
4. 修正auth.go中API密钥未配置时的提示逻辑
2026-06-23 10:23:44 +08:00
sun 977e9ccadb build: 升级go版本到1.26并添加限流中间件
1.  调整go.mod将Go版本升级至1.26
2.  新增速率限制和并发限制中间件,将其加入路由中间件链
3.  重构TTS处理逻辑,将限流逻辑迁移至中间件统一处理
4.  优化CORS中间件代码,移除冗余日志和格式调整
2026-06-22 18:13:35 +08:00
sun b92d3dbc00 fix(middleware/cors): 完善CORS中间件的日志和校验逻辑
1.  修复了变量缩进的格式问题
2.  添加初始化时的白名单来源打印
3.  优化origin校验逻辑,兼容大小写的协议前缀
4.  新增各阶段的CORS请求日志
5.  修正日志中的客户端IP字段名
6.  修复文件末尾缺失换行符的问题
2026-06-22 13:47:17 +08:00
sun b92973cdc9 chore: 移除废弃的tts-api.exe并修复文件头部bom问题
删除了不再使用的tts-api.exe可执行文件,同时移除了common/constants.go文件头部多余的BOM头。
2026-05-27 14:19:22 +08:00
sun be6c2ad34e Revert "fix: 修复请求体过大处理和冗余代码问题"
This reverts commit 23b962a90e.
2026-05-26 10:42:41 +08:00
sun 23b962a90e fix: 修复请求体过大处理和冗余代码问题
1. 修复OpenAI TTS接口的请求体过大错误提示,返回正确的状态码和提示信息
2. 删除volcano适配器中无用的空循环扫描代码
3. 修正constants.go的包声明重复空格问题
2026-05-26 10:41:47 +08:00
sun 9b2a1d1531 feat: 添加安全中间件、优化限流器与API密钥验证
1. 新增安全响应头中间件强化请求安全性
2. 使用恒定时间比较修复API密钥验证时序漏洞
3. 新增限流器条目上限配置与自动清理逻辑
4. 优化CORS日志与客户端IP获取逻辑
5. 升级Go构建环境与基础镜像版本
6. 新增非root用户运行容器增强安全性
2026-05-24 15:33:27 +08:00
sun 9c35f780db 功能 (语音合成):新增语音合成请求的模型名校验
新增模型名最大长度常量,限定字符上限为 64 位
传入模型名称时,校验名称长度并拦截非法控制字符
移除闲置的 tts-api.exe 可执行文件
2026-05-24 11:52:57 +08:00
sun 7bedb222d1 chore: 删除过时的TTS API架构设计文档
移除了不再维护的tts_api_architecture.html文档文件,清理项目冗余资源。
2026-05-23 20:44:20 +08:00
sun 4e1820d45b refactor: 重构项目架构,拆分代码到模块化目录
将单文件tts_server.go重构为模块化项目结构,拆分出common、dto、middleware、router、controller、service、adapter、setting等目录,优化代码组织提升可维护性
2026-05-23 20:32:12 +08:00
sun 1b84a6c9ee refactor(health dashboard & tts server): 优化健康页面展示与服务配置
- 重构健康页面配置状态展示,替换原有资源ID和发音人字段
- 简化错误记录展示逻辑,改为统计展示错误数量并提示查看日志
- 调整HTTP客户端连接池配置与请求体缓冲区大小
- 重构速率限制逻辑位置,优化请求处理流程
- 重写CORS配置,支持环境变量白名单和通配符模式
- 增加静态文件检查与更严格的跨域校验
- 调整服务器超时配置,适配更长时间的请求处理
2026-05-22 23:32:10 +08:00
sun 4c93638250 feat(tts server): 新增跨域白名单配置并优化错误处理
1. 重构CORS中间件,支持通过ALLOWED_ORIGINS环境变量配置跨域白名单,默认允许所有来源并添加凭证支持
2. 优化TTS服务错误响应体读取逻辑,处理读取失败的情况
3. 精简健康检查接口返回的冗余配置信息
4. 删除部分重复的配置日志输出
2026-05-20 22:26:21 +08:00
sun 45591a4e3a feat: add web dashboard for TTS service monitoring
新增了可视化的服务监控面板,添加了/health接口的扩展信息展示,将根路径重定向到监控面板页面,包含服务状态、请求统计、错误记录等监控数据
2026-05-20 19:48:55 +08:00
43 changed files with 4429 additions and 936 deletions
+10
View File
@@ -0,0 +1,10 @@
*.exe
*.md
.env
.env.*
!.env.example
.git
.gitignore
tts_api_architecture.html
代码审查报告.md
fix_list.md
+48 -18
View File
@@ -1,4 +1,4 @@
# ByteDance TTS v3 API 配置示例
# 字节火山引擎 TTS v3 API 配置示例
# 将此文件复制为 .env 并填入实际配置
# ==========================================
@@ -8,32 +8,62 @@
# 火山引擎新版控制台获取的 API Key
BYTEDANCE_TTS_API_KEY=your_api_key_here
# 资源信息ID(决定使用1.0还是2.0模型)
# 语音合成模型:
# - seed-tts-1.0: 豆包语音合成模型1.0字符版
# - seed-tts-1.0-concurr: 豆包语音合成模型1.0并发版
# - seed-tts-2.0: 豆包语音合成模型2.0字符版
# 声音复刻模型:
# - seed-icl-1.0: 声音复刻1.0字符版
# - seed-icl-1.0-concurr: 声音复刻1.0并发版
# - seed-icl-2.0: 声音复刻2.0字符版
BYTEDANCE_TTS_RESOURCE_ID=seed-tts-1.0
# 资源信息ID(决定使用1.0还是2.0模型)
# 复刻 2.0 音色(seed-icl-2.0)
BYTEDANCE_TTS_RESOURCE_ID=seed-icl-2.0
# 发音人(音色)ID,具体参考火山引擎音色列表
# 注意:1.0音色只能搭配 seed-tts-1.0 Resource ID
# 2.0音色只能搭配 seed-tts-2.0 Resource ID
# 发音人(音色)ID
BYTEDANCE_TTS_SPEAKER=your_speaker_id_here
# ==========================================
# 可选的环境变量
# ==========================================
# 请求超时时间,默认30秒
# 单次合成超时,默认30s
BYTEDANCE_TTS_TIMEOUT=30s
# OpenAI兼容接口的API密钥(可选)
# 配置后,客户端请求需要携带 Authorization: Bearer <OPENAI_TTS_API_KEY>
# 上游实际请求的音频格式:mp3 / pcm / ogg_opus
# 客户端要求 wav 时,内部自动转 pcm 上游 + 本地拼 WAV 头
BYTEDANCE_TTS_FORMAT=mp3
# 上游采样率:8000/16000/22050/24000/32000/44100/48000
BYTEDANCE_TTS_SAMPLE_RATE=24000
# MP3 比特率(可选),仅 MP3 生效
# BYTEDANCE_TTS_BIT_RATE=128000
# 复刻 2.0 子模型(可选),留空则使用控制台默认值
# seed-tts-2.0-standard:标准版,延时更优
# seed-tts-2.0-expressive:表现力增强版,支持 QA / Cot
# BYTEDANCE_TTS_MODEL=seed-tts-2.0-standard
# 复刻 2.0 模型类型(可选,推荐显式指定)
# 4 = ICL V2,5 = ICL V3
# BYTEDANCE_TTS_MODEL_TYPE=4
# 非中文/英文合成时指定语种(可选)
# zh-cn / en / ja / es-mx / id / pt-br / ko
# BYTEDANCE_TTS_EXPLICIT_LANGUAGE=zh-cn
# 复刻 2.0 启用字级时间戳(可选)
# BYTEDANCE_TTS_ENABLE_SUBTITLE=false
# OpenAI兼容接口的API密钥(可选,多个用逗号分隔)
OPENAI_TTS_API_KEY=your_openai_compatible_key_here
# 服务监听端口,默认8080
# 反代拓扑配置(0-10)。控制 X-Forwarded-For 解析方式,影响 IP 限流的 key。
# 不设置 / 0:启发式模式(默认)—— 从 XFF 链尾扫描,跳过私有 IP,返回第一个公网 IP
# 适合 90% 部署(单跳/多跳/直出),无需了解精确跳数
# N (N>0) :精确模式 —— 精准到真实 client IP,需要正确配置跳数
# N=1:单跳反代(client → nginx → 本服务)
# N=2:双跳反代(client → CDN → nginx → 本服务,如 Cloudflare + nginx)
# N=3:三跳,以此类推
# 直出部署(无反代):无需配置,XFF 分支不会执行
# 详见 README "反代拓扑与 X-Forwarded-For 解析"章节
# TRUSTED_PROXY_HOPS=
# CORS 跨域白名单(逗号分隔;开发环境可设 *;空则拒绝所有跨域)
# ALLOWED_ORIGINS=https://example.com,https://app.example.com
# 服务监听端口,默认8080
PORT=8080
+65
View File
@@ -0,0 +1,65 @@
name: Docker Publish
on:
push:
tags:
- 'v*'
workflow_dispatch: # 允许手动触发测试
env:
REGISTRY: ghcr.io
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0 # 拉完整历史,git describe 能取到 tag
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract version from git
id: version
run: |
echo "version=$(git describe --tags --always --dirty)" >> "$GITHUB_OUTPUT"
echo "commit=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ github.repository }}
tags: |
type=semver,pattern={{version}}
type=sha,format=short
labels: |
org.opencontainers.image.version=${{ steps.version.outputs.version }}
org.opencontainers.image.revision=${{ steps.version.outputs.commit }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VERSION=${{ steps.version.outputs.version }}
COMMIT=${{ steps.version.outputs.commit }}
+22
View File
@@ -0,0 +1,22 @@
# Go build cache
.gocache/
*.exe
*.test
*.out
# Editor / OS
.vscode/
.idea/
.DS_Store
Thumbs.db
# Logs
*.log
# Secrets (do NOT commit local .env files; keep .env.example tracked as template)
.env
.env.*
!.env.example
# Local-only working notes (use task board for shared tracking)
TODO.md
+39
View File
@@ -0,0 +1,39 @@
FROM golang:1.26-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# VERSION 由 CI/CD 传入,通常为 `git describe --tags --always --dirty` 的输出
# COMMIT 为 `git rev-parse --short HEAD`
# 本地默认 dev
ARG VERSION=dev
ARG COMMIT=dev
RUN CGO_ENABLED=0 GOOS=linux go build \
-ldflags "-X github.com/volcano-tts/tts-api/version.Version=${VERSION} \
-X github.com/volcano-tts/tts-api/version.Commit=${COMMIT}" \
-o tts-api .
FROM alpine:3.21
RUN apk --no-cache add ca-certificates tzdata \
&& addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
COPY --from=builder /app/tts-api .
COPY --from=builder /app/health.html .
RUN chown -R appuser:appgroup /app
USER appuser
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD wget -qO- http://localhost:8080/health || exit 1
ENTRYPOINT ["./tts-api"]
+416 -132
View File
@@ -2,215 +2,417 @@
## 项目简介
本项目将字节跳动火山引擎TTS(文本转语音)v3 API封装为OpenAI兼容的TTS API接口,使原本调用OpenAI TTS服务的应用可以无缝切换到火山引擎TTS服务。
本项目将字节跳动火山引擎TTS(文本转语音)v3 API 封装为 OpenAI 兼容的 TTS API 接口,使原本调用 OpenAI TTS 服务的应用可以无缝切换到火山引擎。
### 主要特性
- ✅ 完全兼容OpenAI `/v1/audio/speech` API接口
- ✅ 支持火山引擎TTS v3 API(单向流式)
- ✅ 支持API Key鉴权方式
- ✅ 支持多种发音人和模型版本
- ✅ 内置速率限制和统计功能
- ✅ 支持配置API密钥验证
- ✅ 并发限制:最多同时处理10个请求(保护上游API)
- ✅ 跨平台支持(Windows/Linux/macOS)
## 文件说明
- `tts_server.go` - 主程序源码
- `.env.example` - 环境变量配置示例
- `go.mod` / `go.sum` - Go模块依赖
- 完全兼容 OpenAI `/v1/audio/speech` API
- 支持火山引擎 TTS v3 HTTP Chunked 单向流式 API
- 支持多种音频格式:mp3 / ogg_opus / pcm / wav(wav 内部转 pcm 后本地拼头)
- 支持火山复刻 2.0 子模型(`seed-tts-2.0-standard` / `-expressive`)
- API Key 鉴权、IP 速率限制、全局并发限制
- 内置 Prometheus 文本格式 `/metrics` 端点,零外部依赖
- 跨平台支持(Windows / Linux / macOS)
## 快速开始
### 前置要求
- Go 1.19 或更高版本
- 火山引擎账号并开通TTS服务
- Go 1.26 或更高版本
- 火山引擎账号并开通 TTS 服务
### 1. 编译程序
### 1. 编译
```bash
go build -o tts_server tts_server.go
go build -o tts-api .
```
### 2. 配置环境变量
复制 `.env.example` 为 `.env` 并填入你的配置:
复制 `.env.example` 为 `.env` 并填入实际配置:
```bash
cp .env.example .env
```
编辑 `.env` 文件,填入必要的配置参数。
### 3. 启动服务
### 3. 启动
```bash
# Windows
tts_server.exe
tts-api.exe
# Linux/macOS
./tts_server
./tts-api
```
服务默认监听 `8080` 端口。
服务默认监听 `8080` 端口,可通过 `PORT` 环境变量修改。
## 环境变量配置
### 必需参数
| 变量名 | 说明 | 示例 |
|--------|------|------|
| `BYTEDANCE_TTS_API_KEY` | 火山引擎新版控制台 API Key | `your_api_key_here` |
| `BYTEDANCE_TTS_RESOURCE_ID` | 资源ID,决定模型版本 | `seed-tts-1.0` |
| `BYTEDANCE_TTS_SPEAKER` | 发音人(音色)ID | `zh_female_qingxin` |
| 变量名 | 说明 |
|--------|------|
| `BYTEDANCE_TTS_API_KEY` | 火山引擎新版控制台 API Key |
| `BYTEDANCE_TTS_RESOURCE_ID` | 资源 ID,决定模型版本与计费(`seed-tts-1.0` / `seed-icl-2.0` 等) |
| `BYTEDANCE_TTS_SPEAKER` | 发音人(音色)ID,复刻音色以 `S_` 开头 |
### 可选参数
### TTS 行为参数
| 变量名 | 说明 | 默认值 |
|--------|------|--------|
| `BYTEDANCE_TTS_TIMEOUT` | 请求超时时间 | `30s` |
| `OPENAI_TTS_API_KEY` | OpenAI兼容接口的API密钥(逗号分隔支持多个) | 无 |
| `BYTEDANCE_TTS_TIMEOUT` | 单次合成超时 | `30s` |
| `BYTEDANCE_TTS_FORMAT` | 上游实际请求的音频格式(mp3 / pcm / ogg_opus);客户端要求 wav 时内部自动转 pcm + 本地拼 WAV 头 | `mp3` |
| `BYTEDANCE_TTS_SAMPLE_RATE` | 上游采样率(8000 / 16000 / 22050 / 24000 / 32000 / 44100 / 48000);**此值直接写入 WAV 头,需与上游实际 PCM 采样率一致,否则音频变速变调** | `24000` |
| `BYTEDANCE_TTS_BIT_RATE` | MP3 比特率,仅 mp3 生效 | 无 |
### 复刻 2.0 扩展参数
| 变量名 | 说明 | 默认值 |
|--------|------|--------|
| `BYTEDANCE_TTS_MODEL` | 复刻 2.0 子模型(`seed-tts-2.0-standard` / `seed-tts-2.0-expressive`) | 控制台默认 |
| `BYTEDANCE_TTS_MODEL_TYPE` | 模型类型(4=ICL V2, 5=ICL V3),推荐显式指定 | 无 |
| `BYTEDANCE_TTS_EXPLICIT_LANGUAGE` | 非中英文合成时指定语种(zh-cn / en / ja / es-mx / id / pt-br / ko) | 无 |
| `BYTEDANCE_TTS_ENABLE_SUBTITLE` | 启用字级时间戳(复刻 2.0 生效) | `false` |
### 运行时 / 服务参数
| 变量名 | 说明 | 默认值 |
|--------|------|--------|
| `OPENAI_TTS_API_KEY` | 🔴 **公网必设** OpenAI 兼容接口的 API Key(逗号分隔支持多个);**未设置时鉴权完全关闭** | 无(不鉴权) |
| `TRUSTED_PROXY_HOPS` | X-Forwarded-For 解析模式(0=启发式/默认,>0=精确 N 跳) | `0`(启发式) |
| `PORT` | 服务监听端口 | `8080` |
| `ALLOWED_ORIGINS` | CORS 跨域白名单(逗号分隔,调试可设 `*`;空则拒绝所有跨域) | 无 |
### 反代拓扑与 X-Forwarded-For 解析
当服务部署在反代(nginx / caddy / CDN)后面时,反代会通过 `X-Forwarded-For`(XFF)头传递真实客户端 IP。本服务通过 `TRUSTED_PROXY_HOPS` 环境变量控制 XFF 解析方式,支持两种模式。
#### 何时需要关心这个配置
| 部署方式 | 是否需要配置 |
|---|---|
| 服务直接暴露公网 IP(无反代)| ❌ 不适用,跳过本节 |
| 服务前有 1 个反代(nginx / caddy)| ❌ 不必配置,启发式模式自动处理 |
| 服务前有 2 跳以上反代(CDN + 自建反代)| ⚠️ 启发式模式"够用",需要精准按真实 client 限流时再设 |
> **直出部署(无反代)的用户**:本节不适用,跳过阅读。`TRUSTED_PROXY_HOPS` 在你的部署下不会被读取。
#### 启发式模式(默认 / `TRUSTED_PROXY_HOPS=0`)
从 XFF 链尾向前扫描,**跳过私有 IP,返回第一个公网 IP**。
适用场景:单跳反代(最常见)、多跳含公网代理(CDN + nginx)。
**行为示例**:
| XFF 链 | 启发式返回 | 备注 |
|---|---|---|
| `1.2.3.4` | `1.2.3.4` | 单跳,真实 client |
| `fake, 1.2.3.4` | `1.2.3.4` | 攻击者伪造首值,跳过 fake |
| `1.2.3.4, 5.6.7.8, 10.0.0.1` | `5.6.7.8` | 多跳,返回最末公网 IP(CDN 边缘) |
| `1.2.3.4, 192.168.1.1` | `1.2.3.4` | 链尾是私有 IP,跳过 |
**优点**:零配置,大多数部署自动正确。
**限制**:多跳 CDN 场景下,限流粒度为"按 CDN 边缘 IP"而非"按真实 client"。攻击者填满某 CDN 边缘配额可能影响该 CDN 下的其他用户——但无法伪造身份、无法越权。
#### 精确模式(`TRUSTED_PROXY_HOPS=N`,N > 0)
从 XFF 链尾倒数第 N+1 个位置取值,即"信任最近 N 跳反代,取该信任链之前那一跳的 IP"。
适用场景:多跳 CDN + 反代,且需要精准按真实 client 限流。
**N 的确定方法**:统计客户端到本服务之间的反代跳数。
| 拓扑 | 跳数 | 配置 |
|---|---|---|
| `client → nginx → 本服务` | 1 | `TRUSTED_PROXY_HOPS=1` |
| `client → Cloudflare → nginx → 本服务` | 2 | `TRUSTED_PROXY_HOPS=2` |
| `client → CDN → WAF → nginx → 本服务` | 3 | `TRUSTED_PROXY_HOPS=3` |
**行为对比**(以 `client(1.2.3.4) → CDN(203.0.113.5) → nginx(10.0.0.1) → 本服务` 为例,XFF 链 = `1.2.3.4, 203.0.113.5`):
| `TRUSTED_PROXY_HOPS` | 返回 | 评价 |
|---|---|---|
| 0(默认启发式)| `203.0.113.5` | CDN 边缘 IP,限流粒度粗 |
| 1(数到 nginx,未穿透)| `203.0.113.5` | 配置不当,与默认相同 |
| 2(穿透到真实 client)| `1.2.3.4` | 精准到真实 client ✓ |
| 3(超出实际跳数)| `directIP`(链长不足保护)| 配置错误,需修正 |
#### 为什么两种模式都从链尾扫描
XFF 链的第一个值是**客户端可控**的:攻击者可以发送任意 `X-Forwarded-For: 1.2.3.4`,若反代用追加模式(如 nginx 默认的 `$proxy_add_x_forwarded_for`),链尾才会追加真实 IP。
若代码取首值,攻击者每次换伪造 IP 即可绕过 IP 限流,也可伪装成受害 IP 把其配额耗尽(间接 DoS)。两种模式都从链尾扫描,天然免疫这种攻击。
#### 验证当前模式
启动期日志会显示当前模式:
```
TRUSTED_PROXY_HOPS 未设置,使用默认启发式模式(XFF 链尾第一个公网 IP)
# 或
已配置 TRUSTED_PROXY_HOPS=0(启发式模式,等同默认)
# 或
已配置 TRUSTED_PROXY_HOPS=2(精确模式,信任 2 跳反代)
```
也可在 `GetClientIP` 临时加 `log.Printf` 打印解析结果,或写一个 Go 测试用例(参见 DEBT-1 单元测试任务)来覆盖不同 XFF 链场景。生产环境不要保留 debug 日志。
### Resource ID 说明
| Resource ID | 模型说明 |
|-------------|----------|
| `seed-tts-1.0` | 豆包语音合成模型1.0字符版 |
| `seed-tts-1.0-concurr` | 豆包语音合成模型1.0并发版 |
| `seed-tts-2.0` | 豆包语音合成模型2.0字符版 |
| `seed-icl-1.0` | 声音复刻1.0字符版 |
| `seed-icl-1.0-concurr` | 声音复刻1.0并发版 |
| `seed-icl-2.0` | 声音复刻2.0字符版 |
| `seed-tts-1.0` | 豆包语音合成模型 1.0 字符版 |
| `seed-tts-1.0-concurr` | 豆包语音合成模型 1.0 并发版 |
| `seed-tts-2.0` | 豆包语音合成模型 2.0 字符版 |
| `seed-icl-2.0` | 声音复刻 2.0 字符版 |
**注意:** 1.0音色只能搭配 `seed-tts-1.0` Resource ID,2.0音色只能搭配 `seed-tts-2.0` Resource ID。
> 上表为通用模型名。火山控制台实际显示的资源 ID 字符串通常是 `volc.megatts.default`、`volc.megatts.icl` 等(带版本号形如 `volc.megatts.icl.2_0`),**以控制台资源管理页面显示的字符串为准**。资源 ID 与音色必须**同时在控制台开通**才能组合使用,否则 API 返回 `code=55000000, message=resource ID is mismatched with speaker related resource`。
**注意:** 复刻音色(speaker 以 `S_` 开头)必须搭配对应族的 Resource ID,否则 API 返回 resource mismatched 错误。
## 调试日志
### BYTEDANCE_TTS_DEBUG
服务运行期日志分为**始终输出**和**调试模式才输出**两类。通过 `BYTEDANCE_TTS_DEBUG` 环境变量控制调试日志开关。
| 值 | 行为 |
|----|------|
| 不设置 / `false` | 仅输出错误、警告、启动摘要、成功日志(默认,生产环境推荐) |
| `true` | 额外输出适配器层调试日志 |
```bash
# 启用调试
BYTEDANCE_TTS_DEBUG=true ./tts-api
# 或写入 .env
echo "BYTEDANCE_TTS_DEBUG=true" >> .env
```
启用后启动时会打印:
```
调试日志已启用 BYTEDANCE_TTS_DEBUG
```
### 始终输出的日志
启动摘要、错误警告、合成成功/失败、访问日志(Logger 中间件):
```
[TTS-Server] config.go:238: === 环境配置汇总 ===
[TTS-Server] config.go:239: 服务端口: 8080
...
警告: TTS 合成失败 - 路径=/v1/audio/speech 客户端=... 文本长度=50 耗时=114ms 错误=...
TTS 合成成功 - 音色=zh_female_qingxin 格式=mp3 文本=50字 音频=12345字节 分片=3 耗时=1.2s
POST /v1/audio/speech 1.2.3.4:56789 200 1.2s
```
### 调试模式才输出的日志(`BYTEDANCE_TTS_DEBUG=true`)
适配器层与 CORS 拦截详情:
```
TTS upstream: resource_id=seed-icl-2.0 speaker=zh_female_qingxin model="seed-tts-2.0-standard" format=mp3 sample_rate=24000 speech_rate=0 additions="..."
Sentence start: sequence=0, sentence=...
Sentence end: sequence=0
TTS 合成结束, usage: text_words=5
volcano: 忽略未识别事件 event="xxx" sequence=1
CORS拦截: 来源="https://..." 路径=/v1/audio/speech 方法=POST 客户端=...
```
> **生产建议:** 默认不开 `BYTEDANCE_TTS_DEBUG`,需要排查问题时再临时开启,避免 sentence 级别日志刷屏。
## CORS 跨域配置
跨域请求由 `ALLOWED_ORIGINS` 控制,按**完整 origin**(协议 + 域名 + 端口)精确匹配:
- `https://app.example.com` — 精确匹配一个来源
- `https://a.com,https://b.com` — 多个来源逗号分隔
- `*` — 允许所有来源(**不可与凭据请求共存**)
- `app.example.com` — 缺协议头,**永远不会匹配**(强制校验 `http://` / `https://` 开头)
**典型坑:**
1. 客户端是 `http://` 但服务端是 `https://`:浏览器按 `http://...` 的 origin 发请求,白名单里的 `https://...` 不会匹配 → 403。**客户端必须用 `https://` 开头**。
2. `ALLOWED_ORIGINS=*` + 客户端带 `Authorization`:浏览器按规范**直接拒绝预检**(凭据 + 通配符冲突),POST 根本发不出去。
3. 同源请求不受 CORS 限制。
## API 使用说明
> ⚠️ **公网部署前必读**:如果你的服务暴露在公网,**必须**设置 `OPENAI_TTS_API_KEY` 或由前置反代(nginx / caddy)承担鉴权。未设置时 `Authorization` 头完全跳过校验,任何能访问 `:8080` 的人都能调用 TTS 合成,消耗你的火山额度。详见[部署 → 公网安全清单](#公网部署安全清单)。
### OpenAI 兼容接口
**端点:** `POST /v1/audio/speech`
**端点:** `POST /v1/audio/speech`
**请求头:**
**请求头:**
- `Content-Type: application/json`
- `Authorization: Bearer <你的API密钥>`(如果配置了OPENAI_TTS_API_KEY)
- `Authorization: Bearer <你的API密钥>`(如果配置了 `OPENAI_TTS_API_KEY`)
**请求体:**
**请求体:**
```json
{
"model": "tts-1",
"input": "你好,这是一个测试文本",
"input": "你好,这是一个测试文本",
"voice": "alloy",
"response_format": "wav",
"response_format": "mp3",
"speed": 1.0
}
```
**参数说明:**
- `model` - 模型名称(OpenAI兼容,实际不影响)
- `input` - 要合成的文本
- `voice` - 发音人(OpenAI兼容,实际不影响)
- `response_format` - 输出格式:仅支持 `wav`
- `speed` - 语速:0.25 ~ 4.0
**参数说明:**
- `model` — 模型名(OpenAI 兼容,实际不影响,火山侧用 `BYTEDANCE_TTS_MODEL`)
- `input` — 要合成的文本
- `voice` — 发音人(OpenAI 兼容,实际用 `BYTEDANCE_TTS_SPEAKER`)
- `response_format` — 输出格式:`mp3`(默认)/ `opus`(映射 ogg_opus)/ `wav` / `pcm` / `aac` / `flac`(降级到 mp3)
- `speed` — 语速倍率,客户端接受范围 0.25 ~ 4.0;**火山实际生效范围 0.5 ~ 2.0**(speech_rate [-50, 100]),超出范围会被静默截断,客户端无感反馈
**示例调用:**
**格式映射:**
| OpenAI response_format | 火山 API 格式 | Content-Type |
|------------------------|--------------|--------------|
| `mp3` | mp3 | audio/mpeg |
| `opus` | ogg_opus | audio/ogg |
| `wav` | pcm → 本地拼 wav header | audio/wav |
| `pcm` | pcm | audio/pcm |
| `aac` / `flac` | mp3(降级) | audio/mpeg |
**调用示例:**
```bash
# MP3
curl -X POST "http://localhost:8080/v1/audio/speech" \
-H "Content-Type: application/json" \
-d '{"model":"tts-1","input":"你好,世界","voice":"alloy","speed":1.0}' \
-d '{"model":"tts-1","input":"你好,世界","voice":"alloy","speed":1.0}' \
-o output.mp3
# WAV
curl -X POST "http://localhost:8080/v1/audio/speech" \
-H "Content-Type: application/json" \
-d '{"model":"tts-1","input":"你好,世界","voice":"alloy","response_format":"wav"}' \
-o output.wav
```
### 健康检查(含统计信息)
### 健康检查
```bash
curl http://localhost:8080/health
```
返回包含:服务状态、请求统计、错误记录、配置检查结果
返回服务状态、版本、运行时长、内存、配置检查结果(**不鉴权**)。
## 限流机制
为保护上游火山引擎API,服务实现了两层限流保护:
为保护上游火山 API,服务实现两层限流:
### 1. 全局并发限制
- **限制**:最多同时处理 **10个** TTS请求
- **触发**:超过10个并发请求时
- **错误码**:`503 Service Unavailable`
- **说明**:确保不超过上游API的并发限制
### 全局并发限制
- 最多同时处理 **10 个** TTS 请求
- 超过返回 `503 Service Unavailable`
### 2. IP速率限制
- **限制**:每个IP每分钟 **100个** 请求
- **触发**:单个IP调用过于频繁
- **错误码**:`429 Too Many Requests`
- **说明**:防止单个客户端滥用服务
### IP 速率限制
- 每个 IP 每分钟 **100 个** 请求
- 超过返回 `429 Too Many Requests`
### 触发限流时的响应
```json
{
"error": {
"message": "Server is busy, maximum concurrent requests reached.",
"type": "concurrency_limit_error",
"code": "max_concurrent_requests"
**触发日志(始终输出):**
```
警告: 已达到最大并发请求数限制,拒绝请求 - 客户端IP: 1.2.3.4
警告: 已超过IP速率限制,拒绝请求 - 客户端IP: 1.2.3.4
```
## 观测 / Metrics
服务内置 Prometheus 文本格式的 `/metrics` 端点,**不鉴权**(与 `/health` 一致),可直接被 Prometheus 抓取或浏览器查看。Go 进程内埋点,零外部依赖,实现位于 `telemetry/` 与 `metrics/` 包。
### 主要指标
| 指标名 | 类型 | 标签 | 说明 |
|---|---|---|---|
| `tts_request_total` | counter | status, format, speaker, model | /v1/audio/speech 请求数 |
| `tts_request_duration_seconds` | histogram | status, format | 端到端延迟 |
| `tts_upstream_total` | counter | status, format, model, speaker | 上游调用数 |
| `tts_upstream_duration_seconds` | histogram | status, format | 上游调用耗时 |
| `tts_upstream_first_byte_seconds` | histogram | format | TTFB |
| `tts_upstream_chunks_total` | counter | format | 收到的音频 chunk 数 |
| `tts_upstream_audio_bytes_total` | counter | format | 实际返回字节数 |
| `tts_upstream_errors_total` | counter | code | 上游错误(code 聚合到 transport/client/server/upstream) |
| `tts_usage_text_words_total` | counter | model | 上游计费字符数 |
| `tts_concurrency_active` | gauge | | 当前在飞请求数 |
| `tts_concurrency_rejected_total` | counter | | 并发上限拒绝数 |
| `tts_ratelimit_rejected_total` | counter | | 速率限制拒绝数 |
| `tts_auth_failed_total` | counter | | API Key 鉴权失败数 |
### Prometheus 抓取示例
```yaml
scrape_configs:
- job_name: tts-api
static_configs:
- targets: ['localhost:8080']
```
### 仪表盘
`/dashboard` 展示服务状态 + 内存 + 配置信息,并内嵌 `/metrics` 预览;Grafana 等工具可直接基于上面指标做面板。
### ⚠️ 公网部署:监控端点无鉴权
`/metrics`、`/health`、`/dashboard` **均不鉴权**,这是对齐 Prometheus 抓取场景的设计权衡:
| 端点 | 暴露内容 | 风险 |
|---|---|---|
| `/metrics` | 业务标签(speaker/model/format)、运行指标、错误计数 | 侦察面:可推断使用量、技术栈、错误模式 |
| `/health` | 服务状态、版本号、运行时长、内存 | 侦察面:版本号可用于匹配已知 CVE |
| `/dashboard` | 配置检查结果(含 `TTSConfigErr` 状态) | 信息泄露:可确认配置是否就绪 |
**部署建议**:
- **内网 / 反代后**:无影响,符合预期
- **公网直接暴露**:在前置反代(nginx / caddy)上保护这些端点,示例 nginx 配置:
```nginx
location /metrics {
auth_basic "metrics";
auth_basic_user_file /etc/nginx/.htpasswd;
allow 10.0.0.0/8; # 仅允许 Prometheus 服务器网段
deny all;
}
}
```
location /dashboard {
auth_basic "admin";
auth_basic_user_file /etc/nginx/.htpasswd;
}
location /health {
allow 10.0.0.0/8; # 或保留给监控系统访问
deny all;
}
```
### 服务器日志
触发限流时服务器会输出中文警告日志:
- `警告: 已达到最大并发请求数限制,拒绝请求 - 客户端IP: x.x.x.x`
- `警告: 已超过IP速率限制,拒绝请求 - 客户端IP: x.x.x.x`
- **最简方案**:反代层直接限制 `/metrics` 只能从 Prometheus 服务器 IP 访问,无需 basic auth
## 支持的发音人
## 架构
具体发音人列表请参考火山引擎官方文档:
- 1.0音色:https://www.volcengine.com/docs/6561/97454
- 2.0音色:https://www.volcengine.com/docs/6561/1340515
| 包 | 职责 |
|---|---|
| `main.go` | 启动入口,信号处理 |
| `telemetry/` | Counter / Gauge / Histogram + Prometheus 文本导出(零依赖) |
| `metrics/` | TTS 业务指标注册,火山适配器埋点适配 |
| `adapter/volcano/` | 火山 v3 HTTP Chunked 客户端(client/request/response/audio/errors/synthesis) |
| `controller/` | /v1/audio/speech、/health 处理器 |
| `middleware/` | SecurityHeaders、CORS、鉴权、限流、并发、日志、客户端 IP 提取 |
| `setting/` | 单一环境变量入口 + 启动汇总 |
| `common/`、`dto/` | 常量、请求/响应类型,`common.DebugLog` 控制调试日志 |
| `router/` | 路由注册 |
## 常见问题
## 部署
### 1. 如何获取鉴权信息?
### Linux Systemd
- 登录火山引擎新版控制台
- 进入"语音合成"服务
- 创建应用并获取API Key
### 2. 端口被占用怎么办?
通过环境变量修改端口:
```bash
# Windows
set PORT=8081 && tts_server.exe
# Linux/macOS
PORT=8081 ./tts_server
```
### 3. 如何配置多个API密钥?
使用逗号分隔:
```bash
OPENAI_TTS_API_KEY=sk-key1,sk-key2,sk-key3
```
### 4. 查看日志
服务启动后会输出详细日志,包括:
- 服务启动信息
- 配置状态
- 请求统计信息
- 错误详情
## 部署建议
### Linux Systemd 服务
创建 `/etc/systemd/system/tts-server.service`:
创建 `/etc/systemd/system/tts-server.service`:
```ini
[Unit]
@@ -222,7 +424,7 @@ Type=simple
User=www-data
WorkingDirectory=/www/wwwroot/tts-server
EnvironmentFile=/www/wwwroot/tts-server/.env
ExecStart=/www/wwwroot/tts-server/tts_server
ExecStart=/www/wwwroot/tts-server/tts-api
Restart=always
RestartSec=10
@@ -230,22 +432,104 @@ RestartSec=10
WantedBy=multi-user.target
```
启动服务:
```bash
sudo systemctl daemon-reload
sudo systemctl enable tts-server
sudo systemctl start tts-server
```
## 许可证
### Docker
本项目采用非商业用途许可协议。您可以免费使用本软件用于非商业目的,但禁止用于任何商业活动。详细条款请参阅 [LICENSE](LICENSE) 文件。
```bash
docker compose up -d
```
环境变量通过 `.env` 或 `docker-compose.yml` 传入。
### 公网部署安全清单
公网直接暴露(`:8080` 可被互联网任意访问)时,**至少满足以下两条之一**,否则视为不安全的部署:
1. **设置 `OPENAI_TTS_API_KEY`**(推荐,最简单)
```bash
# .env
OPENAI_TTS_API_KEY=<32+ 位随机字符串>
```
客户端请求时带 `Authorization: Bearer <那个字符串>`。
2. **前置反代承担鉴权**(nginx / caddy / Cloudflare Access)
- 反代层做 basic auth、mTLS、Cloudflare Access 等任一方案
- 反代**仅**把鉴权后的请求转发到 `:8080`,Go 服务本身保持"无鉴权"
- 此时 `OPENAI_TTS_API_KEY` 可不设
**两个端点还需要单独保护**(无论上面哪种方案):
- `/metrics`:暴露业务标签与运行指标,详见[观测 / Metrics → 公网部署](#公网部署监控端点无鉴权)
- `/dashboard`:暴露配置检查结果,同上
**未做保护的典型风险**:
- 任意人 curl `POST /v1/audio/speech` → 消耗你火山账号的字符额度
- 任意人 `GET /metrics` → 推断你的使用量、技术栈、错误模式
- 任意人 `GET /dashboard` → 确认你 TTS 配置就绪状态
**内网部署 / 私网反代后**:这些警示不适用,直接用就行。
## 常见问题
### 1. `code=55000000, message=resource ID is mismatched with speaker related resource`
资源/音色不匹配。修复:
1. 火山控制台 → 语音技术 → 你的应用 → 资源管理或音色库
2. 用控制台在线体验/调试同一对 `BYTEDANCE_TTS_RESOURCE_ID` + 音色
3. 控制台能合成的组合才是正确的
4. 把控制台实际显示的资源 ID 字符串(通常是 `volc.megatts.*` 格式)填到 `BYTEDANCE_TTS_RESOURCE_ID`
5. 复刻音色(speaker 以 `S_` 开头)需确认 Resource ID 已开通且与音色同族
### 2. PowerShell 下 `curl` 解释错
PowerShell 里 `curl` 是 `Invoke-WebRequest` 的别名。**必须写 `curl.exe`**:
```powershell
curl.exe -v -X POST "http://localhost:8080/v1/audio/speech" -H "Content-Type: application/json" --data-binary "@body.json"
```
JSON 用单引号包,或写到文件用 `--data-binary "@file.json"`。
### 3. WAV 格式音频播放异常
流式场景下火山 API 的 wav 格式每个 chunk 都返回完整 wav header,拼接后损坏。本项目已自动处理:选择 wav 输出时,内部用 pcm 格式请求 API,本地拼装标准 wav header。如仍有问题,改用 `mp3`。
### 4. 调试时如何看详细日志
设置 `BYTEDANCE_TTS_DEBUG=true` 后重启服务,会额外输出上游请求参数、sentence 事件、CORS 拦截等。详见上文「调试日志」一节。
### 5. 多 API Key 配置
```bash
OPENAI_TTS_API_KEY=sk-key1,sk-key2,sk-key3
```
### 6. 修改端口
```bash
PORT=8081 ./tts-api
```
## 技术支持
如有问题,请检查:
如有问题,请检查:
1. 环境变量配置是否正确
2. 网络是否能访问火山引擎TTS服务
2. 网络是否能访问火山引擎 TTS 服务
3. 鉴权信息是否有效
4. Resource ID与Speaker是否匹配
4. Resource ID 与 Speaker 是否匹配
5. `ALLOWED_ORIGINS` 是否包含前端完整 origin(含 https://)
6. 客户端请求 URL 是否以 https:// 开头
7. 生产环境凭据是否定期轮换
8. 复刻音色确保 Resource ID 与音色 ID 同族
9. 音频格式是否匹配客户端解码能力(默认 mp3 兼容性最好)
## 许可证
本项目采用非商业用途许可协议。详细条款请参阅 [LICENSE](LICENSE) 文件。
+197
View File
@@ -0,0 +1,197 @@
# 漏洞报告 — Volcano-Engine-TTS-UI
## 元信息
| 项目 | 内容 |
|---|---|
| 目标 | ByteDance TTS v3 → OpenAI 兼容接口适配器(Go) |
| 审查范围 | 全部 12 个包、约 2400 行源码(不含 health.html 前端静态页) |
| 审查方式 | 人工代码审查 + `go build` / `go vet`(均通过) |
| 分支/提交 | develop @ 78c7200 |
| 报告日期 | 2026 年 8 月 25 日 |
| 严重度分级 | 🔴 高(必须修复)/ 🟠 中(建议修复)/ 🟡 低(视部署环境)/ ⚪ 信息(记录备查) |
---
## 漏洞清单(按严重度)
| 编号 | 严重度 | 标题 | 位置 | 一句话影响 |
|---|---|---|---|---|
| VUL-004 | 🔴 高 | `.env` 未被 `.gitignore` 忽略,凭据可能入库/入镜像 | `.gitignore` | API Key 随 git 提交或 Docker 镜像层泄露 |
| VUL-001 | 🟠 中 | aac/flac 响应 Content-Type 与数据不一致 | controller/tts.go、adapter/volcano/synthesis.go | 客户端按 AAC 解码 MP3 数据,播放失败 |
| VUL-003 | 🟠 中 | `X-Forwarded-For` 信任链可伪造 IP 绕过限流 | middleware/ratelimit.go | 反代追加模式下限流失效 |
| VUL-002 | 🟡 低 | transport 层错误不进入 `UpstreamErrors` 指标 | metrics/metrics.go、adapter/volcano/synthesis.go | 网络故障在监控上不可见 |
| VUL-005 | 🟡 低 | 日志注入:客户端可控内容原样写入日志 | middleware/logger.go、controller/tts.go | 可伪造日志行 |
| VUL-006 | 🟡 低 | `/metrics`、`/health`、`/dashboard` 无鉴权 | router/router.go | 公网暴露时泄漏运行细节(设计权衡) |
| VUL-007 | 🟡 低 | `OPENAI_TTS_API_KEY` 未设置时鉴权完全关闭 | middleware/auth.go | 公网直连即无访问控制(设计权衡) |
| VUL-008 | ⚪ 信息 | speed 超范围静默截断 | adapter/volcano/request.go | 0.25~0.5x、2.0~4.0x 实际被 clamp,无提示 |
| VUL-009 | ⚪ 信息 | WAV 输出采样率依赖配置而非上游实际值 | adapter/volcano/audio.go | 配置错误导致音频变速 |
---
## VUL-004 🔴 高 — `.env` 未被忽略,凭据可能入库/入镜像
**位置**: `.gitignore`(全文件仅忽略构建产物与编辑器文件)
**描述**: README 与 `.env.example` 均指导用户执行 `cp .env.example .env` 后填入火山 API Key。但 `.gitignore` **没有包含 `.env`**。任何按此流程操作并执行 `git add .` / `git commit` 的用户,都会把含 `BYTEDANCE_TTS_API_KEY`、`OPENAI_TTS_API_KEY` 的文件提交进仓库历史(即使之后删除,历史中仍可找回)。Dockerfile 第 8 行 `COPY . .` 同样会把 `.env` 拷入镜像层。
**影响**: 火山账号 API Key 泄露 → 冒用额度、产生费用、音色资源被盗用。密钥一旦进入 git 历史或镜像层即视为已泄露,只能吊销重建。
**修复建议**:
```gitignore
# Secrets
.env
.env.*
!.env.example
```
**验证**: 当前工作区无 `.env` 文件,仓库历史也未发现已提交的 `.env`(已核查 `git log` 提交列表无该文件),属"配置隐患"而非"已泄露"。
---
## VUL-001 🟠 中 — aac/flac 响应 Content-Type 与真实数据不一致
**位置**: controller/tts.go:196-212(`contentTypeFor`)、adapter/volcano/synthesis.go:59-63、118-119
**描述**: 客户端请求 `response_format: "aac"`(或 `flac`)时,调用链为:
```
resolveClientFormat("aac") → "aac"(白名单放行)
synthesis: opts.Format = "mp3"(上游降级,正确)
synthesis: finalFormat = clientFormat = "aac"(错误,保留客户端格式)
controller: Content-Type = contentTypeFor("aac") = "audio/aac"(错误)
```
实际响应字节是 **MP3**,但 `Content-Type` 是 `audio/aac`。
**影响**: 客户端(浏览器 `<audio>`、播放器 SDK)按 AAC 解码器处理 MP3 流,轻则播放失败/杂音,重则解码崩溃。README 声称"降级到 mp3",但响应头未同步降级。
**修复建议**(二选一):
1. `synthesis.go` 在降级后把 `finalFormat` 置为实际上游格式(`mp3`);
2. `contentTypeFor` 对 `aac`/`flac` 直接返回 `audio/mpeg`。
推荐方案 1(响应头应反映真实数据)。
---
## VUL-003 🟠 中 — X-Forwarded-For 信任链可伪造 IP 绕过限流
**位置**: middleware/ratelimit.go:129-150(`GetClientIP`)
**描述**: `GetClientIP` 在直连 IP 为私有地址(即判定为反代)时,信任 `X-Forwarded-For` 的**第一个**值,其次信任 `X-Real-IP`。若反代(nginx 等)使用追加模式(`$proxy_add_x_forwarded_for`),攻击者发送 `X-Forwarded-For: 1.2.3.4`,反代追加真实 IP 后请求头为 `1.2.3.4, 真实IP`,代码取 `1.2.3.4`。
**影响**:
- 攻击者每次请求携带不同伪造 IP,即可绕过 100 次/分钟的 IP 限流(限流 key 由该函数返回值决定);
- 可伪装成受害 IP 请求,把受害 IP 的限流配额耗尽(间接 DoS)。
**前提**: 服务必须部署在反代之后(反代 IP 为私有)。直接公网直连时直连 IP 非私有,不走信任分支,不受影响。
**修复建议**(任一):
1. 反代配置覆盖而非追加:`proxy_set_header X-Forwarded-For $remote_addr`;
2. 代码改取 `XFF` **最后一个**值(追加模式下最后一个为真实来源);
3. 部署时用 `X-Real-IP` 且确保反代覆盖该头,代码优先信任 `X-Real-IP`。
---
## VUL-002 🟡 低 — transport 层错误不进入 UpstreamErrors 指标
**位置**: metrics/metrics.go:134-136、adapter/volcano/synthesis.go:90-92、112
**描述**: `UpstreamFinished` 中 `if errCode != 0 { UpstreamErrors.Inc(...) }`。传输错误(连接失败、DNS 失败、读流失败)时调用方传入的 `errCode` 均为 0:
- `client.PostStream` 失败 → `UpstreamFinished(..., 0)` → 不计
- `ParseStream` 读流错误 → `UpstreamError{Code: 0}` → 不计
而 `codeLabel`(metrics/metrics.go:148-158)明确设计了 `code == 0 → "transport"` 分类,**该分类永远不会被触发**。
**影响**: 上游网络故障时 `tts_upstream_errors_total` 不增长,`/metrics` 与监控面板无法发现"火山接口连不上"类故障,只能从日志人工发现。
**修复建议**: 将 transport 错误单独计数,例如 `if errCode != 0 || status == "transport_error" { UpstreamErrors.Inc(Labels{"code": codeLabel(errCode)}) }`。
---
## VUL-005 🟡 低 — 日志注入
**位置**: middleware/logger.go:26、adapter/volcano/synthesis.go:100(`Message` 拼入上游响应体)
**描述**: 访问日志直接拼接 `r.RequestURI`(客户端可控,URL 中可含 `\n`/`\r`);上游非 200 响应体 `rawBody` 拼入错误日志。Go `log` 不做转义,原样输出。
**影响**: 攻击者可在请求 URL 中注入换行符,伪造服务端日志行(如伪造"合成成功"记录、注入误导信息),干扰排障;无代码执行风险。
**修复建议**(低优先): 对 RequestURI 做换行转义(`strings.NewReplacer("\n", "\\n", "\r", "\\r")`)。
---
## VUL-006 🟡 低 — 监控端点无鉴权(设计权衡)
**位置**: router/router.go:21-31
**描述**: `/health`、`/metrics`、`/dashboard` 均不鉴权(README 明示,与 Prometheus 抓取场景对齐)。
**影响**: 若公网直接暴露,任何人可查看 `/metrics`(含 speaker/model/format 业务标签、请求计数、上游错误聚合)与 `/dashboard`(运行状态、配置检查结果)。不涉及凭据,但为侦察提供信息。
**判定**: 属于明确的设计决策,个人/内网使用可接受;公网部署建议通过反代鉴权(如 basic auth)保护 `/metrics`。
---
## VUL-007 🟡 低 — 未配置 OPENAI_TTS_API_KEY 时鉴权完全关闭
**位置**: middleware/auth.go:20-22、setting/config.go:64-79
**描述**: `ValidateAPIKey` 在 `setting.Auth.APIKeys` 为空时直接返回 `true`(全部放行)。该变量仅在 `OPENAI_TTS_API_KEY` 设置后才会填充。
**影响**: 公网直接暴露且未配置该环境变量时,任何人均可无限制调用 TTS 合成,消耗火山额度。
**判定**: 属设计行为(内网可信),README 已有说明。公网部署必须配置该变量,或由反代承担鉴权。
---
## VUL-008 ⚪ 信息 — speed 超范围静默截断
**位置**: adapter/volcano/request.go:89-101、controller/tts.go:132-141
**描述**: README 声明 `speed` 支持 0.25~4.0;controller 按此范围 clamp,但火山 `speech_rate` 仅支持 [-50, 100](即 0.5x~2.0x)。`0.25~0.5x` 与 `2.0~4.0x` 区间会被二次 clamp 截断,且无任何客户端提示。
**影响**: 用户请求 0.25x 实际得到 0.5x 语速,表现与预期不符。
**修复建议**: README 修正文档范围,或对超范围请求返回 400 而非静默截断。
---
## VUL-009 ⚪ 信息 — WAV 采样率依赖配置而非上游实际值
**位置**: adapter/volcano/audio.go:32-39、controller/tts.go:121
**描述**: `WrapWAVHeader` 使用 `opts.SampleRate`(环境变量 `BYTEDANCE_TTS_SAMPLE_RATE`,默认 24000)写 WAV 头。若上游实际返回的 PCM 采样率与配置不一致(配置错误或上游忽略该参数),WAV 头与数据不匹配。
**影响**: 音频以错误速率播放(变速/变调)。
**判定**: 正常配置下无影响;配置异常时表现为"音频怪声",README 第 3 条已有排查指引。
---
## 安全加固建议(非漏洞)
1. **Docker 环境变量**: compose 中密钥通过 `environment` 明文传递,进程环境可见(`/proc/<pid>/environ`)。生产可改用 Docker Secrets 或启动时注入。
2. **TLS**: 当前 HTTP 明文,建议生产经反代(nginx/caddy)终结 TLS,或服务前挂证书。
3. **依赖固定**: go.mod 仅锁定 `gorilla/mux v1.8.1`(2018 年发布),建议 `go get -u` 检查是否存在已知 CVE 的新版本,或至少 `go mod verify`。
## 工程债务(非安全,记录备查)
| 项目 | 说明 |
|---|---|
| 零单元测试 | 全部 12 个包无 `_test.go`;流解析(曾有 4 次 bug 修复)、WAV 拼头、speech_rate 转换、限流窗口、Prometheus 转义均无自动化回归保护 |
| 死代码 | `ratelimit_middleware.go` 整文件未引用;`auth.go:InitAPIKeys`、`cors.go:InitCORSConfig` 为未被调用的 no-op;`dto.ByteDanceTTSConfig` + `setting/config.go:311` 占位引用 |
| 冗余代码 | `resolveClientFormat`(controller/tts.go:49-52)两分支同值;`common.MaxResponseTimes`/`MaxErrors` 常量未使用 |
| 云盘占用 | 注释表明 `ratelimit_middleware.go` 因"云盘同步被永久占用"无法删除,仓库位于云盘目录,git 操作与文件删除存在异常风险 |
## 已核查无风险项
- ✅ API Key 比较使用 `subtle.ConstantTimeCompare`,无时序侧信道
- ✅ 启动日志与 `/health` 对 API Key 脱敏(`maskAPIKey`)
- ✅ 请求体上限 1MB、文本上限 5000 字、model 名长度/字符校验
- ✅ 并发信号量 + IP 限流仅对 `/v1/` 生效,监控路径豁免;CORS 预检不消耗配额
- ✅ telemetry label key 注册时锁定,当前 cardinality 可控(无客户端可控高基数标签)
- ✅ 上游连接池复用、超时(30s)与 context 取消正确传播
- ✅ 优雅退出(SIGINT/SIGTERM → 5s 内 Shutdown)
- ✅ 无 `_test.go` 之外的明显并发竞态:全局配置启动期写入后只读,共享状态均有锁
+74
View File
@@ -0,0 +1,74 @@
package volcano
import (
"encoding/binary"
"fmt"
)
// 标准 PCM WAV 头(44 字节)。
// 文档 3.3 节:流式场景不推荐 wav(会多次返回 wav header),
// 本项目策略:上游走 pcm,本地拼一次标准头,避免拼接过个 header。
type wavHeader struct {
// RIFF chunk descriptor
ChunkID [4]byte // "RIFF"
ChunkSize uint32 // 36 + SubChunk2Size
Format [4]byte // "WAVE"
// fmt sub-chunk
Subchunk1ID [4]byte // "fmt "
Subchunk1Size uint32 // 16 for PCM
AudioFormat uint16 // 1 = PCM
NumChannels uint16
SampleRate uint32
ByteRate uint32
BlockAlign uint16
BitsPerSample uint16
// data sub-chunk
Subchunk2ID [4]byte // "data"
Subchunk2Size uint32
}
// WrapWAVHeader 把 PCM 原始字节封装成完整的 WAV 字节流。
// sampleRate 决定 WAV 头里的采样率字段;pcm 视为 16-bit 单声道 little-endian。
func WrapWAVHeader(pcm []byte, sampleRate int) ([]byte, error) {
if sampleRate <= 0 {
return nil, fmt.Errorf("invalid sample rate %d", sampleRate)
}
const channels uint16 = 1
const bitsPerSample uint16 = 16
blockAlign := channels * bitsPerSample / 8
byteRate := uint32(sampleRate) * uint32(blockAlign)
dataSize := uint32(len(pcm))
hdr := wavHeader{
ChunkID: [4]byte{'R', 'I', 'F', 'F'},
ChunkSize: 36 + dataSize,
Format: [4]byte{'W', 'A', 'V', 'E'},
Subchunk1ID: [4]byte{'f', 'm', 't', ' '},
Subchunk1Size: 16,
AudioFormat: 1,
NumChannels: channels,
SampleRate: uint32(sampleRate),
ByteRate: byteRate,
BlockAlign: blockAlign,
BitsPerSample: bitsPerSample,
Subchunk2ID: [4]byte{'d', 'a', 't', 'a'},
Subchunk2Size: dataSize,
}
out := make([]byte, 0, 44+len(pcm))
out = append(out, hdr.ChunkID[:]...)
out = binary.LittleEndian.AppendUint32(out, hdr.ChunkSize)
out = append(out, hdr.Format[:]...)
out = append(out, hdr.Subchunk1ID[:]...)
out = binary.LittleEndian.AppendUint32(out, hdr.Subchunk1Size)
out = binary.LittleEndian.AppendUint16(out, hdr.AudioFormat)
out = binary.LittleEndian.AppendUint16(out, hdr.NumChannels)
out = binary.LittleEndian.AppendUint32(out, hdr.SampleRate)
out = binary.LittleEndian.AppendUint32(out, hdr.ByteRate)
out = binary.LittleEndian.AppendUint16(out, hdr.BlockAlign)
out = binary.LittleEndian.AppendUint16(out, hdr.BitsPerSample)
out = append(out, hdr.Subchunk2ID[:]...)
out = binary.LittleEndian.AppendUint32(out, hdr.Subchunk2Size)
out = append(out, pcm...)
return out, nil
}
+44
View File
@@ -0,0 +1,44 @@
package volcano
import (
"bytes"
"context"
"fmt"
"net/http"
"time"
)
// HTTPClient 持有共享的 http.Client 以便复用连接(v3 keep-alive 1 分钟)。
type HTTPClient struct {
client *http.Client
}
// NewHTTPClient 构造默认配置的 HTTPClient。
func NewHTTPClient() *HTTPClient {
return &HTTPClient{
client: &http.Client{
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 20,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
},
},
}
}
// PostStream 发送一次流式请求,返回带上下文的 *http.Response。
// 调用方负责关闭 resp.Body。
func (h *HTTPClient) PostStream(ctx context.Context, url string, headers map[string]string, body []byte) (*http.Response, error) {
if ctx == nil {
ctx = context.Background()
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
for k, v := range headers {
req.Header.Set(k, v)
}
return h.client.Do(req)
}
+27
View File
@@ -0,0 +1,27 @@
package volcano
import "fmt"
// UpstreamError 表示火山 v3 返回的 业务错误(code != 0 且 != 20000000)或传输错误。
// 包含上游错误码,便于 telemetry 把它作为 label。
type UpstreamError struct {
Code int
Message string
Stage string // "request"/"stream"/"http" - 出错阶段
Wrapped error
}
func (e *UpstreamError) Error() string {
if e.Wrapped != nil {
return fmt.Sprintf("volcano %s: code=%d %s: %v", e.Stage, e.Code, e.Message, e.Wrapped)
}
return fmt.Sprintf("volcano %s: code=%d %s", e.Stage, e.Code, e.Message)
}
func (e *UpstreamError) Unwrap() error { return e.Wrapped }
// IsAuth 当上游返回认证/权限类错误时返回 true。
func (e *UpstreamError) IsAuth() bool {
return e.Code == 45000000 || e.Code == 55000000 ||
e.Code == 401 || e.Code == 403
}
+63
View File
@@ -0,0 +1,63 @@
package volcano
// Options 是火山 v3 TTS 适配器的完整调用参数集合。
// 由 setting 包从环境变量构造,controller 直接透传,不做 OpenAI 侧映射。
//
// 字段顺序与文档 3.x 节一致,便于对照。
type Options struct {
// --- 鉴权 / 路由 ---
APIKey string // X-Api-Key
ResourceID string // X-Api-Resource-Id,决定模型版本与计费,如 seed-icl-2.0
// --- req_params 核心字段 ---
Text string
Speaker string
Model string // 可空,仅复刻 2.0 生效;env 默认 seed-tts-2.0-standard
UID string // user.uid,默认 "uid"
// --- audio_params ---
Format string // 上游实际请求的 format:mp3 / pcm / ogg_opus
SampleRate int // 8000/16000/22050/24000/32000/44100/48000
BitRate int // 可选,仅 MP3 生效
SpeechRate int // [-50, 100]
LoudnessRate int // [-50, 100]
EnableSubtitle bool // 复刻 2.0 生效,返回 TTSSubtitle
EnableTimestamp bool // 复刻 1.0 生效,内嵌字级时间戳
// --- additions(扩展参数,JSON 字符串承载)---
// 文档明确 additions 在请求体里必须是 string,内容是 JSON。
// 这里直接存结构体,序列化时由 MarshalJSON 输出为 string。
Additions *Additions
}
// Additions 对应文档 3.4 节的扩展参数。
// 注意:在请求体里 additions 是 JSON 字符串,所以 MarshalJSON 序列化为 string。
type Additions struct {
ModelType *int `json:"model_type,omitempty"` // 复刻 2.0 推荐显式指定,4=ICL V2、5=ICL V3
ContextTexts []string `json:"context_texts,omitempty"` // 语音指令
UseTagParser *bool `json:"use_tag_parser,omitempty"` // 复刻 2.0 expressive 启用语音标签 Cot
ExplicitLanguage string `json:"explicit_language,omitempty"` // 明确语种
ContextLanguage string `json:"context_language,omitempty"` // 参考语种
SilenceDuration *int `json:"silence_duration,omitempty"` // 0~30000ms
EnableLanguageDetector *bool `json:"enable_language_detector,omitempty"` // 自动识别语种
DisableMarkdownFilter *bool `json:"disable_markdown_filter,omitempty"` // 是否解析 markdown
DisableEmojiFilter *bool `json:"disable_emoji_filter,omitempty"` // 是否过滤 emoji
MaxLengthFilterParenthesis *int `json:"max_length_to_filter_parenthesis,omitempty"`
UnsupportedCharRatio *float64 `json:"unsupported_char_ratio_thresh,omitempty"`
AIGCWatermark *bool `json:"aigc_watermark,omitempty"`
AIGCMetadata any `json:"aigc_metadata,omitempty"`
CacheConfig any `json:"cache_config,omitempty"`
PostProcess any `json:"post_process,omitempty"`
}
// IsZero 报告 Additions 是否为空(没有任何字段设置),用于在序列化前跳过 additions。
func (a *Additions) IsZero() bool {
if a == nil {
return true
}
return a.ModelType == nil && a.ContextTexts == nil && a.UseTagParser == nil &&
a.ExplicitLanguage == "" && a.ContextLanguage == "" && a.SilenceDuration == nil &&
a.EnableLanguageDetector == nil && a.DisableMarkdownFilter == nil && a.DisableEmojiFilter == nil &&
a.MaxLengthFilterParenthesis == nil && a.UnsupportedCharRatio == nil &&
a.AIGCWatermark == nil && a.AIGCMetadata == nil && a.CacheConfig == nil && a.PostProcess == nil
}
+113
View File
@@ -0,0 +1,113 @@
package volcano
import (
"encoding/json"
"fmt"
)
// requestBody 是真正发到上游 v3 端点的 JSON 顶层结构。
type requestBody struct {
User ttsUser `json:"user"`
Namespace string `json:"namespace"`
ReqParams ttsReqParams `json:"req_params"`
}
type ttsUser struct {
UID string `json:"uid"`
}
type ttsReqParams struct {
Text string `json:"text"`
Speaker string `json:"speaker"`
Model string `json:"model,omitempty"`
AudioParams ttsAudioParams `json:"audio_params"`
Additions string `json:"additions,omitempty"` // 注意:字符串
}
type ttsAudioParams struct {
Format string `json:"format"`
SampleRate int `json:"sample_rate"`
BitRate int `json:"bit_rate,omitempty"`
SpeechRate int `json:"speech_rate"`
LoudnessRate int `json:"loudness_rate,omitempty"`
EnableSubtitle bool `json:"enable_subtitle,omitempty"`
EnableTimestamp bool `json:"enable_timestamp,omitempty"`
}
// buildRequest 把 Options 序列化为上游请求体 JSON。
func buildRequest(opts Options) ([]byte, error) {
if opts.Text == "" {
return nil, fmt.Errorf("volcano: text is required")
}
if opts.Speaker == "" {
return nil, fmt.Errorf("volcano: speaker is required")
}
if opts.ResourceID == "" {
return nil, fmt.Errorf("volcano: resource id is required")
}
if opts.APIKey == "" {
return nil, fmt.Errorf("volcano: api key is required")
}
body := requestBody{
User: ttsUser{UID: opts.UID},
Namespace: "UnidirectionalTTS",
ReqParams: ttsReqParams{
Text: opts.Text,
Speaker: opts.Speaker,
Model: opts.Model,
AudioParams: ttsAudioParams{
Format: opts.Format,
SampleRate: opts.SampleRate,
BitRate: opts.BitRate,
SpeechRate: opts.SpeechRate,
LoudnessRate: opts.LoudnessRate,
EnableSubtitle: opts.EnableSubtitle,
EnableTimestamp: opts.EnableTimestamp,
},
},
}
if opts.Additions != nil && !opts.Additions.IsZero() {
// 文档明确 additions 字段为 JSON 字符串。
raw, err := json.Marshal(opts.Additions)
if err != nil {
return nil, fmt.Errorf("marshal additions: %w", err)
}
body.ReqParams.Additions = string(raw)
}
raw, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("marshal request: %w", err)
}
return raw, nil
}
// convertSpeedToSpeechRate 把 OpenAI 风格的 speed(倍率)转换为 speech_rate(百分比)。
// speech_rate 范围 [-50, 100],对应 0.5x ~ 2.0x。
func convertSpeedToSpeechRate(speed float64) int {
if speed <= 0 {
speed = 1.0
}
rate := int((speed - 1.0) * 100)
if rate < -50 {
rate = -50
}
if rate > 100 {
rate = 100
}
return rate
}
// resolveUpstreamFormat 决定上游实际请求的 format。
// - 客户端要求 wav -> 上游走 pcm,我们本地拼 header
// - 其他 -> 直接用 clientFormat
//
// sampleRate 在 wav 走 pcm 的情况下也按原样传给上游(影响 PCM 的实际采样率)。
func resolveUpstreamFormat(clientFormat string) string {
if clientFormat == "wav" {
return "pcm"
}
return clientFormat
}
+152
View File
@@ -0,0 +1,152 @@
package volcano
import (
"bufio"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"time"
"github.com/volcano-tts/tts-api/common"
"github.com/volcano-tts/tts-api/dto"
)
// ParsedStream 是一次流式响应的累计结果。
type ParsedStream struct {
AudioData []byte
Chunks int
TextWords int
FirstChunk time.Duration // 从请求发起到收到第一个 sentence chunk 的耗时
HasUsage bool
Subtitles []dto.SubtitleEntry
}
// ParseStream 读取 v3 chunked NDJSON 响应,按文档 5.1 节的 event 取值分类处理。
//
// 关键修复(对比原实现):只有 event == "sentence" 才是音频帧;
// TTSSubtitle 单独收集,不会污染音频字节流。
func ParseStream(body io.Reader, started time.Time) (*ParsedStream, error) {
out := &ParsedStream{}
scanner := bufio.NewScanner(body)
scanner.Buffer(make([]byte, 1024*1024), 8*1024*1024)
gotFirstChunk := false
for scanner.Scan() {
line := scanner.Bytes()
if len(line) == 0 {
continue
}
var resp dto.V3TTSResponse
if err := json.Unmarshal(line, &resp); err != nil {
if common.DebugLog {
log.Printf("volcano: 解析响应行失败: %v, line=%q", err, truncateForLog(line, 200))
}
continue
}
if resp.Code != 0 && resp.Code != 20000000 {
return nil, &UpstreamError{
Code: resp.Code,
Message: resp.Message,
Stage: "stream",
}
}
if resp.Code == 20000000 {
if resp.Usage != nil {
out.TextWords = resp.Usage.TextWords
out.HasUsage = true
if common.DebugLog {
log.Printf("TTS 合成结束, usage: text_words=%d", out.TextWords)
}
}
for scanner.Scan() {
}
break
}
// 事件分发:显式匹配已知事件,绝不把未知事件当作音频。
switch resp.Event {
case "TTSSentenceStart":
if common.DebugLog {
log.Printf("Sentence start: sequence=%d, sentence=%s", resp.Sequence, resp.SentenceText())
}
case "TTSSentenceEnd":
if common.DebugLog {
log.Printf("Sentence end: sequence=%d", resp.Sequence)
}
case "TTSSubtitle":
if resp.Data != "" {
out.Subtitles = append(out.Subtitles, dto.SubtitleEntry{
Text: resp.SentenceText(),
Sequence: resp.Sequence,
})
}
case "sentence", "":
// HTTP 单向协议下,音频帧的 event 字段可能是空也可能是 "sentence";
// 两种都当音频处理。
if resp.Data == "" {
continue
}
chunk, err := base64.StdEncoding.DecodeString(resp.Data)
if err != nil {
return nil, &UpstreamError{
Code: resp.Code,
Message: fmt.Sprintf("decode audio chunk: %v", err),
Stage: "stream",
Wrapped: err,
}
}
out.AudioData = append(out.AudioData, chunk...)
out.Chunks++
if !gotFirstChunk {
out.FirstChunk = time.Since(started)
gotFirstChunk = true
}
default:
if common.DebugLog {
log.Printf("volcano: 忽略未识别事件 event=%q sequence=%d sentence=%s data_len=%d", resp.Event, resp.Sequence, resp.SentenceText(), len(resp.Data))
}
}
}
if err := scanner.Err(); err != nil {
return nil, &UpstreamError{
Code: 0,
Message: fmt.Sprintf("read stream: %v", err),
Stage: "stream",
Wrapped: err,
}
}
if len(out.AudioData) == 0 {
return nil, &UpstreamError{
Code: 0,
Message: "no audio data received from TTS service",
Stage: "stream",
}
}
return out, nil
}
// ReadErrorBody 把非 200 响应的 body 读出来用于日志。
func ReadErrorBody(body io.Reader) string {
const max = 2048
buf := make([]byte, max)
n, err := io.ReadFull(body, buf)
if err != nil && !errors.Is(err, io.ErrUnexpectedEOF) && !errors.Is(err, io.EOF) {
return fmt.Sprintf("read body fail: %v", err)
}
return string(buf[:n])
}
func truncateForLog(b []byte, max int) string {
if len(b) > max {
return string(b[:max]) + fmt.Sprintf("...(truncated, total %d bytes)", len(b))
}
return string(b)
}
+198
View File
@@ -0,0 +1,198 @@
package volcano
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"log"
"strings"
"time"
"github.com/volcano-tts/tts-api/common"
"github.com/volcano-tts/tts-api/dto"
)
// MetricsRecorder 是适配器向上报告埋点的接口。
// 适配器本身不依赖 telemetry 包,controller 在 main 启动时把 Meter 适配成实现;
// 这样测试可以注入 mock,生产可以无侵入替换成 OTel。
type MetricsRecorder interface {
UpstreamStarted(speaker, model, format string)
UpstreamFinished(speaker, model, format, status string, duration, ttfb time.Duration, chunks, audioBytes, errCode int)
UpstreamUsage(model string, textWords int)
}
// nopMetrics 是 MetricsRecorder 的 no-op 默认值。
type nopMetrics struct{}
func (nopMetrics) UpstreamStarted(string, string, string) {}
func (nopMetrics) UpstreamFinished(string, string, string, string, time.Duration, time.Duration, int, int, int) {
}
func (nopMetrics) UpstreamUsage(string, int) {}
// Synthesis 调用火山 v3 一次,返回组装好的结果。
//
// 入参:
// - ctx:超时控制
// - client:复用的 HTTPClient
// - opts:从 setting 构造的完整参数(text 字段会被 text 覆盖)
// - text:本次合成的实际文本
// - clientFormat:客户端期望的最终格式,"wav" 内部转 pcm 后本地拼 wav 头
// - speed:OpenAI 风格的 speed(倍率,0.5~2.0)
// - mtr:可选埋点;传 nil 等价于 nopMetrics
func Synthesis(
ctx context.Context,
client *HTTPClient,
opts Options,
text string,
clientFormat string,
speed float64,
mtr MetricsRecorder,
) (*dto.SynthesisResult, error) {
if mtr == nil {
mtr = nopMetrics{}
}
opts.Text = text
opts.SpeechRate = convertSpeedToSpeechRate(speed)
reqID := newRequestID()
upstreamFormat := resolveUpstreamFormat(clientFormat)
opts.Format = upstreamFormat
if upstreamFormat != "pcm" && upstreamFormat != "mp3" && upstreamFormat != "ogg_opus" {
opts.Format = "mp3"
}
started := time.Now()
mtr.UpstreamStarted(opts.Speaker, opts.Model, opts.Format)
body, err := buildRequest(opts)
if err != nil {
mtr.UpstreamFinished(opts.Speaker, opts.Model, opts.Format, "request_error", time.Since(started), 0, 0, 0, 0)
return nil, &UpstreamError{Code: 0, Message: err.Error(), Stage: "request", Wrapped: err}
}
headers := map[string]string{
"Content-Type": "application/json",
"Connection": "keep-alive",
"X-Api-Resource-Id": opts.ResourceID,
"X-Api-Request-Id": reqID,
"X-Api-Key": opts.APIKey,
"X-Control-Require-Usage-Tokens-Return": "*",
}
if common.DebugLog {
log.Printf("TTS upstream: resource_id=%s speaker=%s model=%q format=%s sample_rate=%d speech_rate=%d additions=%q",
opts.ResourceID, opts.Speaker, opts.Model, opts.Format, opts.SampleRate, opts.SpeechRate, extractAdditionsForLog(body))
}
resp, err := client.PostStream(ctx, "https://openspeech.bytedance.com/api/v3/tts/unidirectional", headers, body)
if err != nil {
mtr.UpstreamFinished(opts.Speaker, opts.Model, opts.Format, "transport_error", time.Since(started), 0, 0, 0, 0)
return nil, &UpstreamError{Code: 0, Message: err.Error(), Stage: "request", Wrapped: err}
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
rawBody := ReadErrorBody(resp.Body)
// rawBody 来自上游响应体,可能是攻击者控制的恶意内容(例如包含
// \n 伪造日志行)。转义后再嵌入错误消息。
safeBody := strings.NewReplacer("\n", "\\n", "\r", "\\r").Replace(rawBody)
mtr.UpstreamFinished(opts.Speaker, opts.Model, opts.Format, fmt.Sprintf("http_%d", resp.StatusCode), time.Since(started), 0, 0, 0, resp.StatusCode)
return nil, &UpstreamError{
Code: resp.StatusCode,
Message: fmt.Sprintf("upstream http %d: %s", resp.StatusCode, safeBody),
Stage: "http",
}
}
parsed, err := ParseStream(resp.Body, started)
if err != nil {
ue, _ := err.(*UpstreamError)
code := 0
if ue != nil {
code = ue.Code
}
mtr.UpstreamFinished(opts.Speaker, opts.Model, opts.Format, "stream_error", time.Since(started), 0, 0, 0, code)
return nil, err
}
duration := time.Since(started)
finalData := parsed.AudioData
// finalFormat 反映真实输出格式(用于 controller 写 Content-Type):
// - wav 走 pcm 上游 + 本地拼头,对外仍是 wav
// - aac/flac 在上方已被上游降级为 mp3,真实输出也是 mp3
// - 其余与 clientFormat 一致
finalFormat := clientFormat
if clientFormat != "wav" {
finalFormat = opts.Format
}
sampleRate := opts.SampleRate
if clientFormat == "wav" {
wav, wrapErr := WrapWAVHeader(parsed.AudioData, opts.SampleRate)
if wrapErr != nil {
mtr.UpstreamFinished(opts.Speaker, opts.Model, opts.Format, "wrap_error", duration, parsed.FirstChunk, parsed.Chunks, len(parsed.AudioData), 0)
return nil, &UpstreamError{Code: 0, Message: wrapErr.Error(), Stage: "wrap", Wrapped: wrapErr}
}
finalData = wav
}
if parsed.HasUsage {
mtr.UpstreamUsage(opts.Model, parsed.TextWords)
}
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",
opts.Speaker, clientFormat, len(text), len(finalData), parsed.Chunks, duration)
return &dto.SynthesisResult{
AudioData: finalData,
Format: finalFormat,
SampleRate: sampleRate,
ReqID: reqID,
TextWords: parsed.TextWords,
Chunks: parsed.Chunks,
AudioBytes: len(finalData),
TTFB: parsed.FirstChunk,
Duration: duration,
}, nil
}
// newRequestID 16 字节随机 ID(hex 编码),无外部依赖。
func newRequestID() string {
var b [16]byte
_, _ = rand.Read(b[:])
return hex.EncodeToString(b[:])
}
// extractAdditionsForLog 从已编码的请求体里取 additions 字段值,便于日志展示。
func extractAdditionsForLog(body []byte) string {
const key = "\"additions\":\""
idx := bytesIndex(body, key)
if idx < 0 {
return ""
}
rest := body[idx+len(key):]
end := bytesIndex(rest, "\"")
if end < 0 {
return ""
}
return string(rest[:end])
}
func bytesIndex(haystack []byte, needle string) int {
if len(needle) == 0 {
return 0
}
outer:
for i := 0; i+len(needle) <= len(haystack); i++ {
for j := 0; j < len(needle); j++ {
if haystack[i+j] != needle[j] {
continue outer
}
}
return i
}
return -1
}
+22
View File
@@ -0,0 +1,22 @@
package common
import "time"
// DebugLog 控制非必要日志输出;由 setting 包在启动时通过 BYTEDANCE_TTS_DEBUG 环境变量设置。
var DebugLog bool
const (
DefaultPort = "8080"
DefaultTimeout = 30 * time.Second
MaxTextLength = 5000
MinSpeed = 0.25
MaxSpeed = 4.0
DefaultSpeed = 1.0
MaxRequestBodySize = 1024 * 1024
RateLimitRequests = 100
RateLimitWindow = time.Minute
MaxConcurrentRequests = 10
CleanupInterval = time.Hour
MaxModelNameLength = 64
MaxRateLimiterEntries = 100000
)
+258
View File
@@ -0,0 +1,258 @@
package controller
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"runtime"
"strings"
"time"
"github.com/volcano-tts/tts-api/adapter/volcano"
"github.com/volcano-tts/tts-api/common"
"github.com/volcano-tts/tts-api/dto"
"github.com/volcano-tts/tts-api/metrics"
"github.com/volcano-tts/tts-api/middleware"
"github.com/volcano-tts/tts-api/setting"
"github.com/volcano-tts/tts-api/telemetry"
"github.com/volcano-tts/tts-api/version"
)
var (
volcanoClient *volcano.HTTPClient
adapterRec volcano.MetricsRecorder = metrics.AdapterRecorder{}
)
func InitController() {
volcanoClient = volcano.NewHTTPClient()
}
func truncateForLog(b []byte, max int) string {
if len(b) > max {
return string(b[:max]) + fmt.Sprintf("...(truncated, total %d bytes)", len(b))
}
return string(b)
}
// resolveClientFormat 把 OpenAI 风格的 response_format 映射为最终输出格式;
// 不识别或未指定时回退到 setting.TTSOptions.Format。
func resolveClientFormat(reqFmt string) string {
switch strings.ToLower(reqFmt) {
case "mp3", "wav", "opus", "pcm", "aac", "flac":
if reqFmt == "opus" {
return "ogg_opus"
}
return strings.ToLower(reqFmt)
}
return setting.TTSOptions.Format
}
// OpenaiTTSHandler 是 /v1/audio/speech 的入口。
func OpenaiTTSHandler(w http.ResponseWriter, r *http.Request) {
start := time.Now()
if r.Method != http.MethodPost {
log.Printf("警告: 错误的方法 - 方法=%s 期望=POST 路径=%s 客户端=%s",
r.Method, r.URL.Path, middleware.GetClientIP(r))
metrics.RequestTotal.Inc(telemetry.Labels{"status": "method_not_allowed", "format": "", "speaker": "", "model": ""})
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
if !middleware.ValidateAPIKey(r) {
metrics.AuthFailed.Inc(telemetry.Labels{})
log.Printf("警告: API Key 鉴权失败 - 路径=%s 客户端=%s 远端=%s",
r.URL.Path, middleware.GetClientIP(r), r.RemoteAddr)
middleware.SendJSONError(w, http.StatusUnauthorized, "Invalid API key provided.", "invalid_request_error", "invalid_api_key")
return
}
if setting.TTSConfigErr != nil {
log.Printf("警告: TTS配置未就绪,拒绝请求 - 错误=%v 路径=%s 客户端=%s",
setting.TTSConfigErr, r.URL.Path, middleware.GetClientIP(r))
middleware.SendJSONError(w, http.StatusServiceUnavailable, "TTS service configuration error. Please check environment variables and restart the service.", "configuration_error", "service_unavailable")
return
}
r.Body = http.MaxBytesReader(w, r.Body, common.MaxRequestBodySize)
body, err := io.ReadAll(r.Body)
if err != nil {
if strings.Contains(err.Error(), "request body too large") {
log.Printf("警告: 请求体过大 - 路径=%s 客户端=%s 限制=%d字节",
r.URL.Path, middleware.GetClientIP(r), common.MaxRequestBodySize)
http.Error(w, "Request body too large", http.StatusRequestEntityTooLarge)
return
}
log.Printf("警告: 读取请求体失败 - 路径=%s 客户端=%s 错误=%v",
r.URL.Path, middleware.GetClientIP(r), err)
http.Error(w, "Failed to read request body", http.StatusBadRequest)
return
}
var req dto.OpenAITTSRequest
if err := json.Unmarshal(body, &req); err != nil {
log.Printf("警告: JSON 解析失败 - 路径=%s 客户端=%s 错误=%v body前200字节=%q",
r.URL.Path, middleware.GetClientIP(r), err, truncateForLog(body, 200))
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
if req.Model != "" {
if len(req.Model) > common.MaxModelNameLength {
log.Printf("警告: Model 名过长 - 路径=%s 客户端=%s 长度=%d 限制=%d",
r.URL.Path, middleware.GetClientIP(r), len(req.Model), common.MaxModelNameLength)
http.Error(w, fmt.Sprintf("Model name too long (max %d characters)", common.MaxModelNameLength), http.StatusBadRequest)
return
}
if strings.ContainsAny(req.Model, "\x00\n\r\t") {
log.Printf("警告: Model 名含非法字符 - 路径=%s 客户端=%s model前50字节=%q",
r.URL.Path, middleware.GetClientIP(r), truncateForLog([]byte(req.Model), 50))
http.Error(w, "Model name contains invalid characters", http.StatusBadRequest)
return
}
}
if req.Input == "" {
log.Printf("警告: input 字段为空 - 路径=%s 客户端=%s", r.URL.Path, middleware.GetClientIP(r))
http.Error(w, "Input text is required", http.StatusBadRequest)
return
}
if len(req.Input) > common.MaxTextLength {
log.Printf("警告: input 文本过长 - 路径=%s 客户端=%s 长度=%d 限制=%d",
r.URL.Path, middleware.GetClientIP(r), len(req.Input), common.MaxTextLength)
http.Error(w, fmt.Sprintf("Input text too long (max %d characters)", common.MaxTextLength), http.StatusBadRequest)
return
}
speed := req.Speed
if speed <= 0 {
speed = common.DefaultSpeed
}
if speed < common.MinSpeed {
speed = common.MinSpeed
}
if speed > common.MaxSpeed {
speed = common.MaxSpeed
}
clientFormat := resolveClientFormat(req.ResponseFormat)
opts := setting.TTSOptions
opts.Text = req.Input
ctx, cancel := context.WithTimeout(r.Context(), setting.TTSTimeout)
defer cancel()
result, err := volcano.Synthesis(ctx, volcanoClient, opts, req.Input, clientFormat, speed, adapterRec)
duration := time.Since(start)
finalLabels := telemetry.Labels{
"format": clientFormat,
"speaker": opts.Speaker,
"model": opts.Model,
}
if err != nil {
finalLabels["status"] = classifyStatus(err)
metrics.RequestTotal.Inc(finalLabels)
metrics.RequestDuration.Observe(duration.Seconds(), telemetry.Labels{"status": finalLabels["status"], "format": clientFormat})
log.Printf("警告: TTS 合成失败 - 路径=%s 客户端=%s 文本长度=%d 耗时=%v 错误=%v",
r.URL.Path, middleware.GetClientIP(r), len(req.Input), duration, err)
middleware.SendJSONError(w, http.StatusInternalServerError, "TTS synthesis failed.", "server_error", "synthesis_failed")
return
}
finalLabels["status"] = "ok"
metrics.RequestTotal.Inc(finalLabels)
metrics.RequestDuration.Observe(duration.Seconds(), telemetry.Labels{"status": "ok", "format": clientFormat})
w.Header().Set("Content-Type", contentTypeFor(result.Format))
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(result.AudioData)))
w.Header().Set("X-Request-Id", result.ReqID)
w.WriteHeader(http.StatusOK)
w.Write(result.AudioData)
}
func classifyStatus(err error) string {
if ue, ok := err.(*volcano.UpstreamError); ok {
switch ue.Stage {
case "request":
return "request_error"
case "http":
return fmt.Sprintf("http_%d", ue.Code)
case "stream":
return "upstream_error"
case "wrap":
return "wrap_error"
}
}
return "internal_error"
}
func contentTypeFor(format string) string {
switch strings.ToLower(format) {
case "wav":
return "audio/wav"
case "mp3":
return "audio/mpeg"
case "ogg_opus", "opus":
return "audio/ogg"
case "pcm":
return "audio/L16"
case "aac":
return "audio/aac"
case "flac":
return "audio/flac"
}
return "application/octet-stream"
}
// HealthHandler 暴露运行期状态;无鉴权。
func HealthHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if setting.TTSConfigErr != nil {
w.WriteHeader(http.StatusServiceUnavailable)
} else {
w.WriteHeader(http.StatusOK)
}
env := setting.CheckEnvironmentVariables()
allRequired := env["all_required_vars_set"].(bool)
status := "ok"
if !allRequired {
status = "configuration_error"
}
resp := dto.HealthResponse{
Status: status,
Service: "ByteDance TTS to OpenAI API Adapter",
Version: version.Version,
Commit: version.Commit,
Uptime: fmt.Sprintf("%.0f seconds", time.Since(startTime).Seconds()),
StartTime: startTime.Format(time.RFC3339),
Memory: collectMemorySnapshot(),
ConfigStatus: dto.ConfigStatusResponse{
AllRequiredVarsSet: allRequired,
ConfigError: setting.TTSConfigErr != nil,
},
}
json.NewEncoder(w).Encode(resp)
}
var startTime time.Time
func SetStartTime(t time.Time) { startTime = t }
func collectMemorySnapshot() map[string]interface{} {
var ms runtime.MemStats
runtime.ReadMemStats(&ms)
return map[string]interface{}{
"heap_alloc": ms.HeapAlloc,
"heap_inuse": ms.HeapInuse,
"goroutines": runtime.NumGoroutine(),
}
}
+23
View File
@@ -0,0 +1,23 @@
version: '3.8'
services:
tts-api:
build: .
container_name: tts-api
ports:
- "${PORT:-8080}:8080"
environment:
- BYTEDANCE_TTS_API_KEY=${BYTEDANCE_TTS_API_KEY}
- BYTEDANCE_TTS_RESOURCE_ID=${BYTEDANCE_TTS_RESOURCE_ID}
- BYTEDANCE_TTS_SPEAKER=${BYTEDANCE_TTS_SPEAKER}
- BYTEDANCE_TTS_TIMEOUT=${BYTEDANCE_TTS_TIMEOUT:-30s}
- OPENAI_TTS_API_KEY=${OPENAI_TTS_API_KEY:-}
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-}
- PORT=8080
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8080/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 5s
+20
View File
@@ -0,0 +1,20 @@
package dto
// HealthResponse 是 /health 端点的 JSON 响应。
// 数值类信息(请求统计、错误)迁移到 /metrics 端点,
// 这里只保留运行期最关键的状态。
type HealthResponse struct {
Status string `json:"status"`
Service string `json:"service"`
Version string `json:"version"`
Commit string `json:"commit"`
Uptime string `json:"uptime"`
StartTime string `json:"start_time"`
Memory map[string]interface{} `json:"memory"`
ConfigStatus ConfigStatusResponse `json:"config_status"`
}
type ConfigStatusResponse struct {
AllRequiredVarsSet bool `json:"all_required_vars_set"`
ConfigError bool `json:"config_error"`
}
+82
View File
@@ -0,0 +1,82 @@
package dto
import (
"encoding/json"
"time"
)
// OpenAITTSRequest 是 /v1/audio/speech 接收的请求体。
// 仅 input / speed / response_format 实际影响火山侧;
// voice / model 当前保留接收但不做映射,详见 controller。
type OpenAITTSRequest struct {
Model string `json:"model"`
Input string `json:"input"`
Voice string `json:"voice"`
ResponseFormat string `json:"response_format,omitempty"`
Speed float64 `json:"speed,omitempty"`
}
// V3TTSResponse 是火山 v3 HTTP Chunked 流式响应中每一行的 JSON 结构。
// Sentence 字段上游有时返回字符串(TTSSentenceStart 里的句文本),有时返回对象
// ({"phonemes":[...],"text":"...","words":[...]}),用 json.RawMessage 兼容两种形态,
// 避免任意一种上游变更都导致整行解析失败。
type V3TTSResponse struct {
ReqID string `json:"reqid"`
Code int `json:"code"`
Message string `json:"message"`
Event string `json:"event"`
Sequence int `json:"sequence"`
Data string `json:"data"`
Sentence json.RawMessage `json:"sentence,omitempty"`
IsFinal bool `json:"is_final"`
Usage *V3Usage `json:"usage,omitempty"`
}
// SentenceText 从 Sentence 提取可读文本:
// - 字符串直接返回
// - 对象尝试取 .text 字段
// - 其它情况返回原始 JSON
func (r *V3TTSResponse) SentenceText() string {
if len(r.Sentence) == 0 {
return ""
}
var s string
if err := json.Unmarshal(r.Sentence, &s); err == nil {
return s
}
var obj struct {
Text string `json:"text"`
}
if err := json.Unmarshal(r.Sentence, &obj); err == nil && obj.Text != "" {
return obj.Text
}
return string(r.Sentence)
}
// V3Usage 由 X-Control-Require-Usage-Tokens-Return 触发,包含计费字符数。
type V3Usage struct {
TextWords int `json:"text_words"`
}
// SynthesisResult 是火山适配器向 controller 返回的最终结果。
// Format 与 AudioData 的实际编码一致;controller 据此设置响应 Content-Type。
type SynthesisResult struct {
AudioData []byte
Format string
SampleRate int
ReqID string
TextWords int // 来自 V3Usage,无 usage 时为 0
Chunks int // 实际收到的音频 chunk 数
AudioBytes int // 解码后总字节数
TTFB time.Duration // 收到首个音频 chunk 的耗时
Duration time.Duration // 整体合成耗时
}
// SubtitleEntry 描述一个字级时间戳条目(当 enable_subtitle / enable_timestamp 启用时返回)。
type SubtitleEntry struct {
Text string
StartMs int
EndMs int
Sequence int
// 原始事件可能为不同形态,这里只保留通用字段
}
+3 -6
View File
@@ -1,8 +1,5 @@
module bytedance-tts-openai-adapter
module github.com/volcano-tts/tts-api
go 1.19
go 1.26
require (
github.com/google/uuid v1.6.0
github.com/gorilla/mux v1.8.1
)
require github.com/gorilla/mux v1.8.1
-2
View File
@@ -1,4 +1,2 @@
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
+668
View File
@@ -0,0 +1,668 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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/axios/1.6.7/axios.min.js"></script>
<style>
:root {
--bg: #0b0f1a;
--bg-card: #131826;
--bg-elev: #1a2138;
--border: #232b42;
--text: #e4e7ee;
--text-dim: #8a92a6;
--text-mute: #5a6275;
--accent: #00d4ff;
--accent-2: #7b2ff7;
--success: #22c55e;
--warning: #f59e0b;
--danger: #ef4444;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
background: var(--bg); color: var(--text);
min-height: 100vh; padding: 24px; font-size: 14px;
background-image:
radial-gradient(ellipse 80% 50% at 50% -20%, rgba(0,212,255,0.08), transparent),
radial-gradient(ellipse 60% 40% at 80% 100%, rgba(123,47,247,0.06), transparent);
}
#app { max-width: 1280px; margin: 0 auto; }
.header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 24px; flex-wrap: wrap; gap: 16px;
}
.header-left { display: flex; align-items: center; gap: 16px; }
.logo {
width: 44px; height: 44px; border-radius: 12px;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
display: flex; align-items: center; justify-content: center;
font-weight: 700; font-size: 18px; color: #fff; box-shadow: 0 4px 20px rgba(0,212,255,0.3);
}
.header h1 { font-size: 20px; font-weight: 600; }
.header .sub { font-size: 12px; color: var(--text-dim); margin-top: 2px; }
.status-badge {
display: inline-flex; align-items: center; gap: 6px;
padding: 4px 10px; border-radius: 999px; font-size: 12px; font-weight: 500;
}
.status-badge.ok { background: rgba(34,197,94,0.15); color: var(--success); }
.status-badge.error { background: rgba(239,68,68,0.15); color: var(--danger); }
.status-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.status-dot.live { animation: pulse 2s infinite; }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.3; } }
.btn {
background: var(--bg-elev); border: 1px solid var(--border); color: var(--text);
padding: 8px 16px; border-radius: 8px; cursor: pointer; font-size: 13px;
transition: all 0.2s; display: inline-flex; align-items: center; gap: 6px;
}
.btn:hover { border-color: var(--accent); }
.btn-primary { background: var(--accent); color: #00121a; border-color: var(--accent); font-weight: 500; }
.btn-primary:hover { background: #33ddff; }
.stats {
display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px; margin-bottom: 20px;
}
.stat-card {
background: var(--bg-card); border: 1px solid var(--border); border-radius: 12px;
padding: 18px; transition: all 0.2s;
}
.stat-card:hover { border-color: rgba(0,212,255,0.4); transform: translateY(-1px); }
.stat-label { font-size: 12px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.5px; }
.stat-value { font-size: 28px; font-weight: 700; margin-top: 6px; color: var(--text); }
.stat-value.accent { color: var(--accent); }
.stat-value.success { color: var(--success); }
.stat-value.warning { color: var(--warning); }
.stat-value.danger { color: var(--danger); }
.stat-foot { font-size: 11px; color: var(--text-mute); margin-top: 4px; }
.section { margin-bottom: 20px; }
.section-title {
font-size: 13px; font-weight: 600; color: var(--text-dim);
text-transform: uppercase; letter-spacing: 1px; margin-bottom: 12px;
display: flex; align-items: center; gap: 8px;
}
.section-title::before {
content: ""; width: 3px; height: 14px; background: var(--accent); border-radius: 2px;
}
.grid-2 { display: grid; grid-template-columns: repeat(auto-fit, minmax(420px, 1fr)); gap: 16px; }
.card {
background: var(--bg-card); border: 1px solid var(--border); border-radius: 12px; padding: 20px;
}
.card-head {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 14px; padding-bottom: 12px; border-bottom: 1px solid var(--border);
}
.card-title { font-size: 14px; font-weight: 600; }
.card-desc { font-size: 11px; color: var(--text-mute); margin-top: 2px; }
.table { width: 100%; border-collapse: collapse; }
.table th, .table td {
text-align: left; padding: 8px 10px; font-size: 12px;
border-bottom: 1px solid var(--border);
}
.table th { color: var(--text-dim); font-weight: 500; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; }
.table tr:last-child td { border-bottom: none; }
.table td.num { font-family: "SF Mono", Monaco, "Cascadia Code", monospace; text-align: right; color: var(--accent); font-weight: 500; }
.table td.num.success { color: var(--success); }
.table td.num.warning { color: var(--warning); }
.table td.num.danger { color: var(--danger); }
.table td.label-cell { color: var(--text-dim); font-family: "SF Mono", Monaco, monospace; font-size: 11px; }
.table td.name { color: var(--text); font-family: "SF Mono", Monaco, monospace; }
.tag {
display: inline-block; padding: 2px 6px; border-radius: 4px;
background: var(--bg-elev); color: var(--text-dim);
font-size: 10px; font-family: "SF Mono", Monaco, monospace;
margin-right: 4px; border: 1px solid var(--border);
}
.tag.status-ok { background: rgba(34,197,94,0.12); color: var(--success); border-color: rgba(34,197,94,0.3); }
.tag.status-error { background: rgba(239,68,68,0.12); color: var(--danger); border-color: rgba(239,68,68,0.3); }
.tag.format-mp3 { background: rgba(0,212,255,0.12); color: var(--accent); border-color: rgba(0,212,255,0.3); }
.empty { color: var(--text-mute); font-size: 12px; text-align: center; padding: 20px; }
.refresh-time { color: var(--text-mute); font-size: 11px; }
.error-box {
background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.3);
border-radius: 8px; padding: 12px 16px; color: var(--danger);
margin-bottom: 16px; font-size: 13px;
}
.row-flex { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
</style>
</head>
<body>
<div id="app">
<div class="header">
<div class="header-left">
<div class="logo">TTS</div>
<div>
<h1>火山 TTS 服务监控</h1>
<div class="sub">{{ health.service || '' }} · {{ health.version || '' }}</div>
</div>
</div>
<div class="row-flex">
<span class="status-badge" :class="health.status === 'ok' ? 'ok' : 'error'">
<span class="status-dot" :class="{ live: health.status === 'ok' }"></span>
{{ health.status === 'ok' ? '运行中' : (health.status || '加载中') }}
</span>
<button class="btn btn-primary" @click="refresh" :disabled="loading">
{{ loading ? "刷新中..." : "刷新" }}
</button>
<span class="refresh-time">{{ lastRefresh }}</span>
</div>
</div>
<div v-if="error" class="error-box">{{ error }}</div>
<div class="stats">
<div class="stat-card">
<div class="stat-label">运行时长</div>
<div class="stat-value">{{ formatUptime(health.uptime) }}</div>
<div class="stat-foot">启动于 {{ formatTime(health.start_time) }}</div>
</div>
<div class="stat-card">
<div class="stat-label">总请求数</div>
<div class="stat-value accent">{{ totalRequests }}</div>
<div class="stat-foot">自启动以来</div>
</div>
<div class="stat-card">
<div class="stat-label">成功率</div>
<div class="stat-value" :class="successRateClass">{{ successRate }}</div>
<div class="stat-foot">{{ okRequests }} 成功 / {{ errRequests }} 失败</div>
</div>
<div class="stat-card">
<div class="stat-label">当前并发</div>
<div class="stat-value">{{ concurrencyActive }}</div>
<div class="stat-foot">在飞请求数</div>
</div>
<div class="stat-card">
<div class="stat-label">Goroutines</div>
<div class="stat-value">{{ health.memory?.goroutines || 0 }}</div>
<div class="stat-foot">Go 运行时</div>
</div>
<div class="stat-card">
<div class="stat-label">堆内存</div>
<div class="stat-value">{{ formatBytes(health.memory?.heap_alloc) }}</div>
<div class="stat-foot">已分配 / 容量 {{ formatBytes(health.memory?.heap_inuse) }}</div>
</div>
</div>
<div class="section">
<div class="section-title">请求 & 流量</div>
<div class="grid-2">
<div class="card">
<div class="card-head">
<div>
<div class="card-title">请求数</div>
<div class="card-desc">tts_request_total · 按 status / format / speaker 拆分</div>
</div>
</div>
<table class="table" v-if="requestRows.length">
<thead><tr><th>状态</th><th>格式</th><th>音色</th><th>次数</th></tr></thead>
<tbody>
<tr v-for="r in requestRows" :key="r.key">
<td><span class="tag" :class="r.statusClass">{{ r.status }}</span></td>
<td><span class="tag" v-if="r.format" :class="`format-${r.format}`">{{ r.format }}</span><span v-else class="tag">-</span></td>
<td class="label-cell">{{ r.speaker || "-" }}</td>
<td class="num" :class="r.statusClass">{{ r.value }}</td>
</tr>
</tbody>
</table>
<div v-else class="empty">暂无数据</div>
</div>
<div class="card">
<div class="card-head">
<div>
<div class="card-title">端到端延迟</div>
<div class="card-desc">tts_request_duration_seconds · 50/95/99 百分位</div>
</div>
</div>
<table class="table" v-if="durationRows.length">
<thead><tr><th>状态</th><th>格式</th><th>p50</th><th>p95</th><th>p99</th></tr></thead>
<tbody>
<tr v-for="r in durationRows" :key="r.key">
<td><span class="tag" :class="r.statusClass">{{ r.status }}</span></td>
<td><span class="tag" v-if="r.format" :class="`format-${r.format}`">{{ r.format }}</span><span v-else class="tag">-</span></td>
<td class="num">{{ r.p50 }}</td>
<td class="num">{{ r.p95 }}</td>
<td class="num warning">{{ r.p99 }}</td>
</tr>
</tbody>
</table>
<div v-else class="empty">暂无数据</div>
</div>
</div>
</div>
<div class="section">
<div class="section-title">上游火山 API</div>
<div class="grid-2">
<div class="card">
<div class="card-head">
<div>
<div class="card-title">上游调用</div>
<div class="card-desc">tts_upstream_total · 按 status / format 拆分</div>
</div>
</div>
<table class="table" v-if="upstreamRows.length">
<thead><tr><th>状态</th><th>格式</th><th>次数</th></tr></thead>
<tbody>
<tr v-for="r in upstreamRows" :key="r.key">
<td><span class="tag" :class="r.statusClass">{{ r.status }}</span></td>
<td><span class="tag" v-if="r.format" :class="`format-${r.format}`">{{ r.format }}</span><span v-else class="tag">-</span></td>
<td class="num" :class="r.statusClass">{{ r.value }}</td>
</tr>
</tbody>
</table>
<div v-else class="empty">暂无数据</div>
</div>
<div class="card">
<div class="card-head">
<div>
<div class="card-title">首字节耗时 (TTFB)</div>
<div class="card-desc">tts_upstream_first_byte_seconds · 按格式拆分</div>
</div>
</div>
<table class="table" v-if="ttfbRows.length">
<thead><tr><th>格式</th><th>p50</th><th>p95</th><th>p99</th></tr></thead>
<tbody>
<tr v-for="r in ttfbRows" :key="r.format">
<td><span class="tag" :class="`format-${r.format}`">{{ r.format }}</span></td>
<td class="num">{{ r.p50 }}</td>
<td class="num">{{ r.p95 }}</td>
<td class="num warning">{{ r.p99 }}</td>
</tr>
</tbody>
</table>
<div v-else class="empty">暂无数据</div>
</div>
<div class="card">
<div class="card-head">
<div>
<div class="card-title">流量统计</div>
<div class="card-desc">chunks & 音频字节数</div>
</div>
</div>
<table class="table" v-if="trafficRows.length">
<thead><tr><th>格式</th><th>音频分片</th><th>音频字节</th></tr></thead>
<tbody>
<tr v-for="r in trafficRows" :key="r.format">
<td><span class="tag" :class="`format-${r.format}`">{{ r.format }}</span></td>
<td class="num">{{ r.chunks }}</td>
<td class="num success">{{ r.audioBytes }}</td>
</tr>
</tbody>
</table>
<div v-else class="empty">暂无数据</div>
</div>
<div class="card">
<div class="card-head">
<div>
<div class="card-title">上游错误</div>
<div class="card-desc">tts_upstream_errors_total · 按错误码聚合</div>
</div>
</div>
<table class="table" v-if="errorRows.length">
<thead><tr><th>错误码</th><th>次数</th></tr></thead>
<tbody>
<tr v-for="r in errorRows" :key="r.code">
<td class="label-cell">{{ r.code }}</td>
<td class="num danger">{{ r.value }}</td>
</tr>
</tbody>
</table>
<div v-else class="empty">暂无错误</div>
</div>
</div>
</div>
<div class="section">
<div class="section-title">限流 & 计费</div>
<div class="grid-2">
<div class="card">
<div class="card-head">
<div>
<div class="card-title">被拒请求</div>
<div class="card-desc">限流 / 并发 / 鉴权失败</div>
</div>
</div>
<table class="table">
<tbody>
<tr>
<td class="label-cell">tts_ratelimit_rejected_total</td>
<td class="num" :class="{ danger: rateLimitRejected > 0 }">{{ rateLimitRejected }}</td>
</tr>
<tr>
<td class="label-cell">tts_concurrency_rejected_total</td>
<td class="num" :class="{ danger: concurrencyRejected > 0 }">{{ concurrencyRejected }}</td>
</tr>
<tr>
<td class="label-cell">tts_auth_failed_total</td>
<td class="num" :class="{ danger: authFailed > 0 }">{{ authFailed }}</td>
</tr>
</tbody>
</table>
</div>
<div class="card">
<div class="card-head">
<div>
<div class="card-title">计费字符</div>
<div class="card-desc">tts_usage_text_words_total · 按模型拆分</div>
</div>
</div>
<table class="table" v-if="usageRows.length">
<thead><tr><th>模型</th><th>字符数</th></tr></thead>
<tbody>
<tr v-for="r in usageRows" :key="r.model">
<td class="label-cell">{{ r.model }}</td>
<td class="num success">{{ r.value }}</td>
</tr>
</tbody>
</table>
<div v-else class="empty">暂无数据</div>
</div>
</div>
</div>
</div>
<script>
const { createApp, ref, computed, onMounted } = Vue;
function parseProm(text) {
const out = {};
text.split("\n").forEach(line => {
line = line.trim();
if (!line || line.startsWith("#")) return;
const m = line.match(/^([a-zA-Z_:][a-zA-Z0-9_:]*)(\{([^}]*)\})?\s+(.+)$/);
if (!m) return;
const name = m[1];
const labels = {};
if (m[3]) {
m[3].split(",").forEach(p => {
const kv = p.match(/^(\w+)="([^"]*)"$/);
if (kv) labels[kv[1]] = kv[2];
});
}
const value = parseFloat(m[4]);
if (!out[name]) out[name] = [];
out[name].push({ labels, value });
});
return out;
}
function histogramStats(samples) {
// bucket le="+Inf" 的值是总数
let total = 0;
const buckets = [];
for (const s of samples) {
if (s.labels.le === "+Inf") total = s.value;
else buckets.push({ le: parseFloat(s.labels.le), count: s.value });
}
buckets.sort((a, b) => a.le - b.le);
function quantile(q) {
if (!total) return 0;
const target = total * q;
let prevCount = 0;
let prevLe = 0;
for (const b of buckets) {
if (b.count >= target) {
if (b.count === prevCount) return prevLe;
return prevLe + (b.le - prevLe) * (target - prevCount) / (b.count - prevCount);
}
prevCount = b.count;
prevLe = b.le;
}
return buckets.length ? buckets[buckets.length - 1].le : 0;
}
return { p50: quantile(0.5), p95: quantile(0.95), p99: quantile(0.99) };
}
function statusClass(status) {
if (status === "ok" || !status) return "status-ok";
if (status.includes("error") || status.includes("reject") || status === "internal_error") return "status-error";
return "status-error";
}
function fmtNum(n) {
if (n === undefined || n === null) return "-";
if (n >= 1e6) return (n / 1e6).toFixed(1) + "M";
if (n >= 1e3) return (n / 1e3).toFixed(1) + "k";
return String(Math.round(n));
}
function fmtSec(s) {
if (!s || s === 0) return "0s";
if (s < 1) return (s * 1000).toFixed(0) + "ms";
return s.toFixed(2) + "s";
}
function fmtBytes(b) {
if (!b) return "0 B";
const k = 1024, sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(b) / Math.log(k));
return (b / Math.pow(k, i)).toFixed(1) + " " + sizes[i];
}
createApp({
setup() {
const health = ref({});
const metrics = ref({});
const loading = ref(false);
const error = ref(null);
const lastRefresh = ref("-");
const totalRequests = computed(() => {
const arr = metrics.value["tts_request_total"] || [];
return fmtNum(arr.reduce((a, b) => a + b.value, 0));
});
const okRequests = computed(() => {
const arr = metrics.value["tts_request_total"] || [];
return fmtNum(arr.filter(s => s.labels.status === "ok").reduce((a, b) => a + b.value, 0));
});
const errRequests = computed(() => {
const arr = metrics.value["tts_request_total"] || [];
return fmtNum(arr.filter(s => s.labels.status !== "ok").reduce((a, b) => a + b.value, 0));
});
const successRate = computed(() => {
const total = (metrics.value["tts_request_total"] || []).reduce((a, b) => a + b.value, 0);
if (!total) return "-";
const ok = (metrics.value["tts_request_total"] || []).filter(s => s.labels.status === "ok").reduce((a, b) => a + b.value, 0);
return (ok / total * 100).toFixed(1) + "%";
});
const successRateClass = computed(() => {
const total = (metrics.value["tts_request_total"] || []).reduce((a, b) => a + b.value, 0);
if (!total) return "";
const ok = (metrics.value["tts_request_total"] || []).filter(s => s.labels.status === "ok").reduce((a, b) => a + b.value, 0);
const r = ok / total;
if (r >= 0.95) return "success";
if (r >= 0.8) return "warning";
return "danger";
});
const concurrencyActive = computed(() => {
const arr = metrics.value["tts_concurrency_active"] || [];
return fmtNum(arr.reduce((a, b) => a + b.value, 0));
});
const rateLimitRejected = computed(() => {
const arr = metrics.value["tts_ratelimit_rejected_total"] || [];
return fmtNum(arr.reduce((a, b) => a + b.value, 0));
});
const concurrencyRejected = computed(() => {
const arr = metrics.value["tts_concurrency_rejected_total"] || [];
return fmtNum(arr.reduce((a, b) => a + b.value, 0));
});
const authFailed = computed(() => {
const arr = metrics.value["tts_auth_failed_total"] || [];
return fmtNum(arr.reduce((a, b) => a + b.value, 0));
});
const requestRows = computed(() => {
const arr = metrics.value["tts_request_total"] || [];
return arr
.map(s => ({
key: JSON.stringify(s.labels),
status: s.labels.status || "-",
format: s.labels.format || "",
speaker: s.labels.speaker || "",
value: fmtNum(s.value),
statusClass: statusClass(s.labels.status)
}))
.sort((a, b) => parseFloat(b.value) - parseFloat(a.value));
});
const durationRows = computed(() => {
const groups = {};
(metrics.value["tts_request_duration_seconds"] || []).forEach(s => {
const key = `${s.labels.status || "-"}|${s.labels.format || ""}`;
if (!groups[key]) groups[key] = { status: s.labels.status, format: s.labels.format, samples: [] };
groups[key].samples.push(s);
});
return Object.values(groups).map(g => {
const stats = histogramStats(g.samples);
return {
key: `${g.status}|${g.format}`,
status: g.status || "-",
format: g.format || "",
p50: fmtSec(stats.p50),
p95: fmtSec(stats.p95),
p99: fmtSec(stats.p99),
statusClass: statusClass(g.status)
};
}).sort((a, b) => a.format.localeCompare(b.format));
});
const upstreamRows = computed(() => {
const arr = metrics.value["tts_upstream_total"] || [];
return arr.map(s => ({
key: JSON.stringify(s.labels),
status: s.labels.status || "-",
format: s.labels.format || "",
value: fmtNum(s.value),
statusClass: statusClass(s.labels.status)
})).sort((a, b) => parseFloat(b.value) - parseFloat(a.value));
});
const ttfbRows = computed(() => {
const groups = {};
(metrics.value["tts_upstream_first_byte_seconds"] || []).forEach(s => {
const key = s.labels.format || "-";
if (!groups[key]) groups[key] = { format: s.labels.format, samples: [] };
groups[key].samples.push(s);
});
return Object.values(groups).map(g => {
const stats = histogramStats(g.samples);
return { format: g.format || "-", p50: fmtSec(stats.p50), p95: fmtSec(stats.p95), p99: fmtSec(stats.p99) };
}).sort((a, b) => a.format.localeCompare(b.format));
});
const trafficRows = computed(() => {
const groups = {};
(metrics.value["tts_upstream_chunks_total"] || []).forEach(s => {
const key = s.labels.format || "-";
if (!groups[key]) groups[key] = { format: s.labels.format, chunks: 0, audioBytes: 0 };
groups[key].chunks += s.value;
});
(metrics.value["tts_upstream_audio_bytes_total"] || []).forEach(s => {
const key = s.labels.format || "-";
if (!groups[key]) groups[key] = { format: s.labels.format, chunks: 0, audioBytes: 0 };
groups[key].audioBytes += s.value;
});
return Object.values(groups).map(g => ({
format: g.format || "-",
chunks: fmtNum(g.chunks),
audioBytes: fmtBytes(g.audioBytes)
})).sort((a, b) => a.format.localeCompare(b.format));
});
const errorRows = computed(() => {
const arr = metrics.value["tts_upstream_errors_total"] || [];
return arr.map(s => ({ code: s.labels.code || "-", value: fmtNum(s.value) }))
.sort((a, b) => parseFloat(b.value) - parseFloat(a.value));
});
const usageRows = computed(() => {
const arr = metrics.value["tts_usage_text_words_total"] || [];
return arr.map(s => ({ model: s.labels.model || "-", value: fmtNum(s.value) }))
.sort((a, b) => parseFloat(b.value) - parseFloat(a.value));
});
const fetchHealth = async () => {
try {
const r = await axios.get("/health");
health.value = r.data;
} catch (e) {
error.value = "无法获取健康状态: " + (e.message || "未知错误");
}
};
const fetchMetrics = async () => {
try {
const r = await axios.get("/metrics");
metrics.value = parseProm(r.data);
} catch (e) {
// 不覆盖 error,让 health 错误更显眼
console.warn("metrics fetch failed:", e.message);
}
};
const refresh = async () => {
loading.value = true;
await Promise.all([fetchHealth(), fetchMetrics()]);
loading.value = false;
lastRefresh.value = new Date().toLocaleTimeString("zh-CN", { hour12: false });
error.value = null;
};
const formatTime = (iso) => {
if (!iso) return "-";
try {
const d = new Date(iso);
return d.toLocaleString("zh-CN", { hour12: false });
} catch { return iso; }
};
const formatUptime = (s) => {
if (!s) return "-";
const sec = parseInt(s);
if (isNaN(sec)) return s;
const d = Math.floor(sec / 86400);
const h = Math.floor((sec % 86400) / 3600);
const m = Math.floor((sec % 3600) / 60);
if (d > 0) return `${d}d ${h}h`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
};
const formatBytes = (b) => {
if (!b) return "0 B";
const k = 1024, sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(b) / Math.log(k));
return (b / Math.pow(k, i)).toFixed(1) + " " + sizes[i];
};
onMounted(() => {
refresh();
setInterval(refresh, 10000);
});
return {
health, metrics, loading, error, lastRefresh,
totalRequests, okRequests, errRequests, successRate, successRateClass,
concurrencyActive, rateLimitRejected, concurrencyRejected, authFailed,
requestRows, durationRows, upstreamRows, ttfbRows, trafficRows, errorRows, usageRows,
refresh, formatBytes, formatUptime, formatTime
};
}
}).mount("#app");
</script>
</body>
</html>
+69
View File
@@ -0,0 +1,69 @@
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/volcano-tts/tts-api/controller"
"github.com/volcano-tts/tts-api/metrics"
"github.com/volcano-tts/tts-api/middleware"
"github.com/volcano-tts/tts-api/router"
"github.com/volcano-tts/tts-api/setting"
)
func main() {
log.SetFlags(log.LstdFlags | log.Lshortfile)
log.SetPrefix("[TTS-Server] ")
setting.InitAllConfigs()
metrics.Init()
middleware.InitRateLimiter()
setting.CheckStaticFiles()
controller.InitController()
setting.LogStartupSummary()
controller.SetStartTime(time.Now())
r := router.Setup()
server := &http.Server{
Addr: ":" + setting.Server.Port,
Handler: middleware.CORS(r),
ReadTimeout: 30 * time.Second,
WriteTimeout: 120 * time.Second,
IdleTimeout: 60 * time.Second,
}
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
go func() {
log.Printf("Starting ByteDance TTS to OpenAI API Adapter Server")
log.Printf("Listening on port: %s", setting.Server.Port)
log.Printf("OpenAI TTS endpoint: http://localhost:%s/v1/audio/speech", setting.Server.Port)
log.Printf("Health check: http://localhost:%s/health", setting.Server.Port)
log.Printf("Metrics: http://localhost:%s/metrics", setting.Server.Port)
log.Printf("Using ByteDance v3 API")
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Server failed to start: %v", err)
}
}()
<-quit
log.Println("Shutting down server...")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
log.Printf("Server forced to shutdown: %v", err)
} else {
log.Println("Server exited gracefully")
}
}
+162
View File
@@ -0,0 +1,162 @@
// Package metrics 集中声明本服务所有埋点指标,并提供 telemetry.Meter 的全局访问入口。
//
// 设计:
// - 启动期 Init() 一次性注册所有指标;Panic 表示有重名 bug,应立即暴露。
// - 上游适配器通过 AdapterRecorder 接入,无需直接 import telemetry。
// - 控制器 / 中间件通过本包的全局变量直接 Inc/Observe/Set。
package metrics
import (
"time"
"github.com/volcano-tts/tts-api/telemetry"
)
var (
// Meter 全局 telemetry Meter。
Meter telemetry.Meter = telemetry.NoopMeter{}
// HTTP 请求侧
RequestTotal *telemetry.Counter
RequestDuration *telemetry.Histogram
// 上游 TTS 调用侧
UpstreamTotal *telemetry.Counter
UpstreamDuration *telemetry.Histogram
UpstreamTTFB *telemetry.Histogram
UpstreamChunks *telemetry.Counter
UpstreamBytes *telemetry.Counter
UpstreamErrors *telemetry.Counter
UpstreamUsage *telemetry.Counter
// 限流 / 并发 / 鉴权
ConcurrencyActive *telemetry.Gauge
ConcurrencyRejected *telemetry.Counter
RateLimitRejected *telemetry.Counter
AuthFailed *telemetry.Counter
)
// Init 初始化所有指标。在 main 启动期调用一次。
func Init() {
m := telemetry.NewMeter()
Meter = m
RequestTotal = m.NewCounter(
"tts_request_total",
"Total /v1/audio/speech requests, labeled by status and chosen format/speaker/model.",
"status", "format", "speaker", "model",
)
RequestDuration = m.NewHistogram(
"tts_request_duration_seconds",
"End-to-end /v1/audio/speech latency in seconds.",
telemetry.DefaultLatencyBuckets,
"status", "format",
)
UpstreamTotal = m.NewCounter(
"tts_upstream_total",
"Total upstream TTS calls, labeled by status.",
"status", "format", "model", "speaker",
)
UpstreamDuration = m.NewHistogram(
"tts_upstream_duration_seconds",
"Upstream TTS call duration in seconds.",
telemetry.DefaultLatencyBuckets,
"status", "format",
)
UpstreamTTFB = m.NewHistogram(
"tts_upstream_first_byte_seconds",
"Time from request send to first audio chunk, in seconds.",
telemetry.DefaultLatencyBuckets,
"format",
)
UpstreamChunks = m.NewCounter(
"tts_upstream_chunks_total",
"Total audio chunks received from upstream.",
"format",
)
UpstreamBytes = m.NewCounter(
"tts_upstream_audio_bytes_total",
"Total audio bytes (post-wrap) returned to clients.",
"format",
)
UpstreamErrors = m.NewCounter(
"tts_upstream_errors_total",
"Upstream TTS errors, labeled by error code family.",
"code",
)
UpstreamUsage = m.NewCounter(
"tts_usage_text_words_total",
"Text words charged by upstream, per model.",
"model",
)
ConcurrencyActive = m.NewGauge(
"tts_concurrency_active",
"Current in-flight request count.",
)
ConcurrencyRejected = m.NewCounter(
"tts_concurrency_rejected_total",
"Requests rejected due to concurrency limit.",
)
RateLimitRejected = m.NewCounter(
"tts_ratelimit_rejected_total",
"Requests rejected due to per-IP rate limit.",
)
AuthFailed = m.NewCounter(
"tts_auth_failed_total",
"Requests rejected due to invalid/missing API key.",
)
}
// AdapterRecorder 把 telemetry 指标适配为 volcano.MetricsRecorder。
type AdapterRecorder struct{}
// UpstreamStarted 满足 volcano.MetricsRecorder 接口。
func (AdapterRecorder) UpstreamStarted(speaker, model, format string) {
UpstreamTotal.Inc(telemetry.Labels{"status": "started", "format": format, "model": model, "speaker": speaker})
}
// UpstreamFinished 满足 volcano.MetricsRecorder 接口。
func (AdapterRecorder) UpstreamFinished(speaker, model, format, status string, duration, ttfb time.Duration, chunks, audioBytes, errCode int) {
labels := telemetry.Labels{"status": status, "format": format, "model": model, "speaker": speaker}
UpstreamTotal.Inc(labels)
UpstreamDuration.Observe(duration.Seconds(), telemetry.Labels{"status": status, "format": format})
if ttfb > 0 {
UpstreamTTFB.Observe(ttfb.Seconds(), telemetry.Labels{"format": format})
}
if chunks > 0 {
UpstreamChunks.Add(float64(chunks), telemetry.Labels{"format": format})
}
if audioBytes > 0 {
UpstreamBytes.Add(float64(audioBytes), telemetry.Labels{"format": format})
}
// 上游调用只要 status != "ok" 即视为错误。原版 if errCode != 0 会漏掉
// errCode=0 的 request_error / transport_error / wrap_error / stream_error
// (code=0 的流错误) 等场景,导致 transport 类错误在 /metrics 上完全不可见。
if status != "ok" {
UpstreamErrors.Inc(telemetry.Labels{"code": codeLabel(errCode)})
}
}
// UpstreamUsage 满足 volcano.MetricsRecorder 接口。
func (AdapterRecorder) UpstreamUsage(model string, textWords int) {
if textWords <= 0 {
return
}
UpstreamUsage.Add(float64(textWords), telemetry.Labels{"model": model})
}
// codeLabel 把整数错误码格式化为 label value,聚合到 4 类便于仪表盘展示。
func codeLabel(code int) string {
switch {
case code == 0:
return "transport"
case code >= 400 && code < 500:
return "client"
case code >= 500 && code < 600:
return "server"
default:
return "upstream"
}
}
+45
View File
@@ -0,0 +1,45 @@
package middleware
import (
"crypto/subtle"
"encoding/json"
"net/http"
"strings"
"github.com/volcano-tts/tts-api/setting"
)
func ValidateAPIKey(r *http.Request) bool {
if len(setting.Auth.APIKeys) == 0 {
return true
}
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
return false
}
if !strings.HasPrefix(authHeader, "Bearer ") {
return false
}
token := strings.TrimPrefix(authHeader, "Bearer ")
for _, validKey := range setting.Auth.APIKeys {
if subtle.ConstantTimeCompare([]byte(token), []byte(validKey)) == 1 {
return true
}
}
return false
}
func SendJSONError(w http.ResponseWriter, statusCode int, message string, errType string, code string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
json.NewEncoder(w).Encode(map[string]interface{}{
"error": map[string]interface{}{
"message": message,
"type": errType,
"code": code,
},
})
}
+94
View File
@@ -0,0 +1,94 @@
package middleware
import (
"log"
"net/http"
"strings"
"github.com/volcano-tts/tts-api/common"
"github.com/volcano-tts/tts-api/setting"
)
var (
corsMaxAgeHeader = "86400"
)
func isValidOrigin(origin string) bool {
if origin == "" || origin == "null" || origin == "nil" {
return false
}
lowerOrigin := strings.ToLower(origin)
if !strings.HasPrefix(lowerOrigin, "http://") && !strings.HasPrefix(lowerOrigin, "https://") {
return false
}
return true
}
func matchOrigin(origin string) (string, bool) {
if !isValidOrigin(origin) {
return "", false
}
if setting.CORS.AllowAll {
return "*", true
}
normalized := strings.ToLower(strings.TrimRight(strings.TrimSpace(origin), "/"))
for _, allowed := range setting.CORS.Origins {
if allowed == normalized {
return origin, true
}
}
return "", false
}
func CORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
// 无 Origin 头:非跨域请求,跳过 CORS 处理
if origin == "" {
next.ServeHTTP(w, r)
return
}
// 有 Origin 头时,响应必须携带 Vary: Origin 防止 CDN 缓存污染
vary := w.Header().Get("Vary")
if vary == "" {
w.Header().Set("Vary", "Origin")
} else if !strings.Contains(vary, "Origin") {
w.Header().Set("Vary", vary+", Origin")
}
isPreflight := r.Method == http.MethodOptions
allowOrigin, matched := matchOrigin(origin)
if !matched {
// Origin 不在白名单:拒绝请求(预检和非预检均拒绝),
// 防止不匹配的请求穿透到后端浪费 TTS 资源
if common.DebugLog {
log.Printf("CORS拦截: 来源=%q 路径=%s 方法=%s 客户端=%s",
origin, r.URL.Path, r.Method, GetClientIP(r))
}
w.WriteHeader(http.StatusForbidden)
return
}
// Origin 匹配:设置 CORS 响应头
w.Header().Set("Access-Control-Allow-Origin", allowOrigin)
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
w.Header().Set("Access-Control-Expose-Headers", "X-Request-Id")
w.Header().Set("Access-Control-Max-Age", corsMaxAgeHeader)
if allowOrigin != "*" {
w.Header().Set("Access-Control-Allow-Credentials", "true")
}
// 预检请求:直接返回 204,不进入内层中间件链,
// 避免消耗速率限制配额和并发槽位
if isPreflight {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
+32
View File
@@ -0,0 +1,32 @@
package middleware
import (
"log"
"net/http"
"strings"
"time"
)
type statusRecorder struct {
http.ResponseWriter
statusCode int
}
func (rec *statusRecorder) WriteHeader(code int) {
rec.statusCode = code
rec.ResponseWriter.WriteHeader(code)
}
func Logger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, statusCode: http.StatusOK}
next.ServeHTTP(rec, r)
duration := time.Since(start)
// r.RequestURI 是未经解析的原始请求行,攻击者可在 URL 中注入
// \n / \r 伪造日志行。转义为可见字符后再记录。
uri := strings.NewReplacer("\n", "\\n", "\r", "\\r").Replace(r.RequestURI)
log.Printf("%s %s %s %d %v", r.Method, uri, r.RemoteAddr, rec.statusCode, duration)
})
}
+222
View File
@@ -0,0 +1,222 @@
package middleware
import (
"log"
"net"
"net/http"
"os"
"strconv"
"strings"
"sync"
"time"
"github.com/volcano-tts/tts-api/common"
"github.com/volcano-tts/tts-api/metrics"
"github.com/volcano-tts/tts-api/setting"
)
type RateLimiter struct {
requests map[string][]time.Time
mutex sync.Mutex
limit int
window time.Duration
lastCleanup time.Time
}
var (
GlobalRateLimiter *RateLimiter
ConcurrencySem chan struct{}
// trustedProxyHops controls how X-Forwarded-For (XFF) is parsed when the
// direct connection comes from a private IP (i.e., we're behind a reverse
// proxy). Two modes are supported, switched by this single value:
//
// HEURISTIC MODE (trustedProxyHops == 0, the default):
// Walk XFF from the end, return the first PUBLIC IP. Skips private
// and loopback hops automatically. Works for ~90% of deployments
// without the operator needing to know the exact number of proxy
// hops. Trade-off in multi-hop: rate limiting is per-CDN-edge rather
// than per-real-client, which is "good enough" for abuse protection
// but not for fine-grained per-user quotas.
//
// PRECISE MODE (trustedProxyHops > 0):
// Count back N hops from the end of XFF and return that value. Gives
// precise per-real-client rate limiting even in multi-hop setups
// (e.g., Cloudflare + nginx). Operator MUST set this to the number
// of trusted reverse proxies between this service and the client.
//
// Both modes walk from the END of the XFF chain. The first value is
// client-controllable; trusting it would let attackers bypass IP rate
// limiting by sending a forged X-Forwarded-For header.
trustedProxyHops = 0
)
func InitRateLimiter() {
switch v := os.Getenv("TRUSTED_PROXY_HOPS"); {
case v == "":
log.Printf("TRUSTED_PROXY_HOPS 未设置,使用默认启发式模式(XFF 链尾第一个公网 IP)")
default:
n, err := strconv.Atoi(v)
switch {
case err != nil || n < 0 || n > 10:
log.Printf("警告: TRUSTED_PROXY_HOPS=%q 无效(需 0-10 的整数),回退到默认启发式模式", v)
case n == 0:
// "0" 或 "00" 等被 Atoi 解析为 0 的形式都归到启发式模式,
// 避免日志出现"精确模式, 信任 0 跳"这种自相矛盾的输出。
log.Printf("已配置 TRUSTED_PROXY_HOPS=%d(启发式模式,等同默认)", n)
default:
trustedProxyHops = n
log.Printf("已配置 TRUSTED_PROXY_HOPS=%d(精确模式,信任 %d 跳反代)", n, n)
}
}
GlobalRateLimiter = &RateLimiter{
requests: make(map[string][]time.Time),
limit: common.RateLimitRequests,
window: common.RateLimitWindow,
}
ConcurrencySem = make(chan struct{}, common.MaxConcurrentRequests)
// 同步到 setting 包,供 LogStartupSummary 展示
setting.TrustedProxyHops = trustedProxyHops
}
func (rl *RateLimiter) Allow(key string) bool {
rl.mutex.Lock()
defer rl.mutex.Unlock()
now := time.Now()
cutoff := now.Add(-rl.window)
if now.Sub(rl.lastCleanup) > common.CleanupInterval {
rl.cleanup()
rl.lastCleanup = now
}
timestamps := rl.requests[key]
valid := make([]time.Time, 0, len(timestamps))
for _, ts := range timestamps {
if ts.After(cutoff) {
valid = append(valid, ts)
}
}
if len(valid) >= rl.limit {
rl.requests[key] = valid
metrics.RateLimitRejected.Inc(nil)
return false
}
valid = append(valid, now)
rl.requests[key] = valid
return true
}
func (rl *RateLimiter) cleanup() {
cutoff := time.Now().Add(-rl.window)
for k, v := range rl.requests {
valid := make([]time.Time, 0, len(v))
for _, ts := range v {
if ts.After(cutoff) {
valid = append(valid, ts)
}
}
if len(valid) == 0 {
delete(rl.requests, k)
} else {
rl.requests[k] = valid
}
}
if len(rl.requests) > common.MaxRateLimiterEntries {
log.Printf("警告: 限流器条目数 %d 超过上限 %d,触发强制清理", len(rl.requests), common.MaxRateLimiterEntries)
for k := range rl.requests {
if len(rl.requests) <= common.MaxRateLimiterEntries/2 {
break
}
delete(rl.requests, k)
}
}
}
var privateCIDRs []*net.IPNet
func init() {
for _, cidr := range []string{
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
"127.0.0.0/8",
"169.254.0.0/16",
"::1/128",
"fc00::/7",
"fe80::/10",
} {
_, ipNet, _ := net.ParseCIDR(cidr)
privateCIDRs = append(privateCIDRs, ipNet)
}
}
func isPrivateIP(ipStr string) bool {
ip := net.ParseIP(ipStr)
if ip == nil {
return false
}
if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
return true
}
for _, cidr := range privateCIDRs {
if cidr.Contains(ip) {
return true
}
}
return false
}
func GetClientIP(r *http.Request) string {
directIP, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
directIP = r.RemoteAddr
}
if isPrivateIP(directIP) {
// Parse X-Forwarded-For when there's a reverse proxy in front (direct
// connection is from a private IP). Both modes walk from the END of
// the chain so that the client-controllable first value cannot be
// used to spoof a different client IP for rate limit bypass.
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
parts := strings.Split(xff, ",")
if trustedProxyHops > 0 {
// PRECISE MODE: count back N hops from end. Real client IP
// sits at index (len(parts) - N). Walk backwards to skip
// any malformed values; if chain is shorter than expected,
// fall through to the first valid IP in the chain.
target := len(parts) - trustedProxyHops
if target < 0 {
target = 0
}
for i := target; i >= 0; i-- {
ip := strings.TrimSpace(parts[i])
if net.ParseIP(ip) != nil {
return ip
}
}
} else {
// HEURISTIC MODE (default): walk from end, return first
// PUBLIC IP. Skips private/loopback hops that come from
// internal proxies between the public-facing proxy and us.
for i := len(parts) - 1; i >= 0; i-- {
ip := strings.TrimSpace(parts[i])
if parsed := net.ParseIP(ip); parsed != nil && !isPrivateIP(ip) {
return ip
}
}
}
}
if xri := strings.TrimSpace(r.Header.Get("X-Real-IP")); xri != "" {
if net.ParseIP(xri) != nil {
return xri
}
}
}
return directIP
}
+57
View File
@@ -0,0 +1,57 @@
package middleware
// 本文件提供带 metrics 埋点的限流 / 并发中间件版本。
// 相比 router 实际使用的实现,本版本额外做了:
// - 加 metrics 埋点(限流拒绝 / 并发拒绝计数)
// - 仅对 /v1/ 下的业务请求生效,监控路径(/health /metrics /dashboard)不消耗配额
import (
"log"
"net/http"
"strings"
"github.com/volcano-tts/tts-api/metrics"
)
// RateLimitWithMetrics 是限流中间件,带埋点 + 路径过滤。
func RateLimitWithMetrics(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 仅对 /v1/ 下的业务请求限流,/health /metrics /dashboard 等监控路径不限流
if !strings.HasPrefix(r.URL.Path, "/v1/") {
next.ServeHTTP(w, r)
return
}
clientIP := GetClientIP(r)
if !GlobalRateLimiter.Allow(clientIP) {
log.Printf("警告: 已超过IP速率限制,拒绝请求 - 客户端IP: %s", clientIP)
SendJSONError(w, http.StatusTooManyRequests, "Rate limit exceeded. Please try again later.", "rate_limit_error", "rate_limit_exceeded")
return
}
next.ServeHTTP(w, r)
})
}
// ConcurrencyLimitWithMetrics 是并发控制中间件,带埋点 + 路径过滤。
func ConcurrencyLimitWithMetrics(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 仅对 /v1/ 下的业务请求统计并发和加锁,监控路径不占用并发槽位
if !strings.HasPrefix(r.URL.Path, "/v1/") {
next.ServeHTTP(w, r)
return
}
select {
case ConcurrencySem <- struct{}{}:
metrics.ConcurrencyActive.Inc(nil)
defer func() {
<-ConcurrencySem
metrics.ConcurrencyActive.Dec(nil)
}()
next.ServeHTTP(w, r)
default:
metrics.ConcurrencyRejected.Inc(nil)
log.Printf("警告: 已达到最大并发请求数限制,拒绝请求 - 客户端IP: %s", GetClientIP(r))
SendJSONError(w, http.StatusServiceUnavailable, "Server is busy, maximum concurrent requests reached. Please try again later.", "concurrency_limit_error", "max_concurrent_requests")
return
}
})
}
+229
View File
@@ -0,0 +1,229 @@
package middleware
import (
"net/http/httptest"
"testing"
)
// TestGetClientIP 覆盖 XFF 解析在两种模式下的关键场景。
// 表驱动测试,每个 case 独立设置 trustedProxyHops,验证 GetClientIP 输出。
func TestGetClientIP(t *testing.T) {
tests := []struct {
name string
mode int // 0=启发式, N>0=精确 N 跳
remoteAddr string // 直连 IP:port
xff string // X-Forwarded-For 头(空则不设)
xri string // X-Real-IP 头(空则不设)
want string
}{
// === 直出部署(directIP 是公网,XFF 分支不进)===
{
name: "直出_无XFF",
mode: 0,
remoteAddr: "1.2.3.4:5678",
want: "1.2.3.4",
},
{
name: "直出_XFF被忽略",
mode: 0,
remoteAddr: "1.2.3.4:5678",
xff: "fake",
want: "1.2.3.4", // 公网直连不走 XFF 分支
},
{
name: "直出_精确模式也不走XFF",
mode: 2,
remoteAddr: "1.2.3.4:5678",
xff: "fake, 5.6.7.8",
want: "1.2.3.4",
},
// === 单跳反代 ===
{
name: "单跳_启发式",
mode: 0,
remoteAddr: "10.0.0.1:5678",
xff: "1.2.3.4",
want: "1.2.3.4",
},
{
name: "单跳_精确N1",
mode: 1,
remoteAddr: "10.0.0.1:5678",
xff: "1.2.3.4",
want: "1.2.3.4",
},
// === 攻击者伪造首值 ===
{
name: "伪造_启发式跳过fake",
mode: 0,
remoteAddr: "10.0.0.1:5678",
xff: "fake, 1.2.3.4",
want: "1.2.3.4",
},
{
name: "伪造_精确N1也跳过fake",
mode: 1,
remoteAddr: "10.0.0.1:5678",
xff: "fake, 1.2.3.4",
want: "1.2.3.4", // target=1, 跳过 fake 取 real
},
{
name: "伪造_多个假值前缀",
mode: 0,
remoteAddr: "10.0.0.1:5678",
xff: "fake1, fake2, 1.2.3.4",
want: "1.2.3.4", // 从尾扫,只看最后一个
},
// === 多跳 CDN+nginx ===
{
name: "多跳_启发式返回CDN边缘",
mode: 0,
remoteAddr: "10.0.0.1:5678",
xff: "1.2.3.4, 203.0.113.5",
want: "203.0.113.5", // 链尾公网=CDN 边缘
},
{
name: "多跳_精确N2返回真实client",
mode: 2,
remoteAddr: "10.0.0.1:5678",
xff: "1.2.3.4, 203.0.113.5",
want: "1.2.3.4", // 倒数第2=真实 client
},
{
name: "多跳_精确N1不够穿透",
mode: 1,
remoteAddr: "10.0.0.1:5678",
xff: "1.2.3.4, 203.0.113.5",
want: "203.0.113.5", // 数到 nginx,没穿透到 client
},
// === 链尾私有 IP ===
{
name: "链尾私有_启发式跳过",
mode: 0,
remoteAddr: "10.0.0.1:5678",
xff: "1.2.3.4, 10.0.0.1",
want: "1.2.3.4", // 跳过私有取公网
},
{
name: "链尾私有_精确N1取末值",
mode: 1,
remoteAddr: "10.0.0.1:5678",
xff: "1.2.3.4, 10.0.0.1",
want: "10.0.0.1", // 精确模式不跳私有
},
// === X-Real-IP 兜底 ===
{
name: "无XFF_走XRI",
mode: 0,
remoteAddr: "10.0.0.1:5678",
xri: "1.2.3.4",
want: "1.2.3.4",
},
{
name: "XFF全非法_走XRI",
mode: 0,
remoteAddr: "10.0.0.1:5678",
xff: "not_ip, also_not",
xri: "1.2.3.4",
want: "1.2.3.4",
},
{
name: "XRI被XFF优先_但XFF全非法",
mode: 0,
remoteAddr: "10.0.0.1:5678",
xff: "not_an_ip",
xri: "1.2.3.4",
want: "1.2.3.4",
},
// === 全部私有 IP(启发式无解)===
{
name: "全私有_启发式回退directIP",
mode: 0,
remoteAddr: "10.0.0.1:5678",
xff: "192.168.1.1, 172.16.0.1",
want: "10.0.0.1", // 全跳私有,走 directIP
},
// === 畸形/空 XFF ===
{
name: "畸形XFF_启发式跳过畸形",
mode: 0,
remoteAddr: "10.0.0.1:5678",
xff: "not_an_ip, 1.2.3.4",
want: "1.2.3.4",
},
{
name: "全空XFF_回退directIP",
mode: 0,
remoteAddr: "10.0.0.1:5678",
xff: " , , ",
want: "10.0.0.1",
},
{
name: "XFF带前后空格",
mode: 0,
remoteAddr: "10.0.0.1:5678",
xff: " 1.2.3.4 , 5.6.7.8 ",
want: "5.6.7.8", // TrimSpace 处理
},
// === 精确模式 N 超出链长 ===
{
name: "精确N超出链长_回退到首值",
mode: 5,
remoteAddr: "10.0.0.1:5678",
xff: "1.2.3.4",
want: "1.2.3.4", // target<0 保护,取首个合法
},
{
name: "精确N等于链长_取首值",
mode: 1,
remoteAddr: "10.0.0.1:5678",
xff: "1.2.3.4",
want: "1.2.3.4", // target=0
},
{
name: "精确N大于链长_取首值",
mode: 2,
remoteAddr: "10.0.0.1:5678",
xff: "1.2.3.4",
want: "1.2.3.4", // target<0,fall back
},
// === 精确模式链中含畸形 ===
{
name: "精确N1_链中畸形回退到首值",
mode: 1,
remoteAddr: "10.0.0.1:5678",
xff: "1.2.3.4, not_ip",
want: "1.2.3.4", // target=1(not_ip 失败)→ i=0(1.2.3.4 成功)
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
trustedProxyHops = tt.mode
r := httptest.NewRequest("GET", "/", nil)
r.RemoteAddr = tt.remoteAddr
if tt.xff != "" {
r.Header.Set("X-Forwarded-For", tt.xff)
}
if tt.xri != "" {
r.Header.Set("X-Real-IP", tt.xri)
}
got := GetClientIP(r)
if got != tt.want {
t.Errorf("GetClientIP() = %q, want %q", got, tt.want)
}
})
}
// 重置为默认,避免影响其他测试或运行时行为
trustedProxyHops = 0
}
+21
View File
@@ -0,0 +1,21 @@
package middleware
import (
"net/http"
"strings"
)
func SecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("X-XSS-Protection", "1; mode=block")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
if strings.HasPrefix(r.URL.Path, "/v1/") || r.URL.Path == "/health" || r.URL.Path == "/dashboard" || r.URL.Path == "/metrics" {
w.Header().Set("Cache-Control", "no-store")
}
next.ServeHTTP(w, r)
})
}
+34
View File
@@ -0,0 +1,34 @@
package router
import (
"net/http"
"github.com/gorilla/mux"
"github.com/volcano-tts/tts-api/controller"
"github.com/volcano-tts/tts-api/metrics"
"github.com/volcano-tts/tts-api/middleware"
)
func Setup() *mux.Router {
r := mux.NewRouter()
r.Use(middleware.SecurityHeaders)
r.Use(middleware.RateLimitWithMetrics)
r.Use(middleware.ConcurrencyLimitWithMetrics)
r.Use(middleware.Logger)
r.HandleFunc("/v1/audio/speech", controller.OpenaiTTSHandler).Methods("POST", "OPTIONS")
r.HandleFunc("/health", controller.HealthHandler).Methods("GET")
r.HandleFunc("/dashboard", func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "health.html")
}).Methods("GET")
r.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/dashboard", http.StatusFound)
}).Methods("GET")
// /metrics 不做鉴权(对齐 /health 策略),但仍然走 RateLimit / ConcurrencyLimit。
// Prometheus 抓取不带 Origin,因此经过 CORS 中间件时会直接 pass-through。
r.Handle("/metrics", metrics.Meter.Handler()).Methods("GET")
return r
}
+318
View File
@@ -0,0 +1,318 @@
package setting
import (
"fmt"
"log"
"os"
"strconv"
"strings"
"time"
"github.com/volcano-tts/tts-api/adapter/volcano"
"github.com/volcano-tts/tts-api/common"
)
// 全部环境变量读取的单一入口:其它包不允许直接 os.Getenv,只读这里的全局 Config。
// TTSOptions 是火山 v3 TTS 调用的完整参数集合,启动期由 InitTTSConfig 填充。
// 业务侧(controller)直接读取并传入 volcano.Synthesis。
var (
TTSOptions volcano.Options
TTSConfigErr error
// TTSTimeout 单次合成请求的超时;controller 用来派生 context。
TTSTimeout time.Duration = common.DefaultTimeout
)
// AuthConfig OpenAI 兼容接口的客户端 API Key 鉴权配置。
type AuthConfig struct {
APIKeys []string
}
var Auth AuthConfig
// CORSConfig 跨域白名单配置。
type CORSConfig struct {
Origins []string
AllowAll bool
}
var CORS CORSConfig
// ServerConfig HTTP 服务监听配置。
type ServerConfig struct {
Port string
}
var Server ServerConfig
// TrustedProxyHops 由 middleware.InitRateLimiter 在启动期写入,
// 表示当前 XFF 解析模式:0=启发式,N>0=精确 N 跳。
// setting.LogStartupSummary 读这个字段以展示运行期配置,
// 不直接调用 middleware(避免循环 import)。
var TrustedProxyHops int
// InitAllConfigs 集中初始化所有配置,启动期调用一次。
func InitAllConfigs() {
InitServerConfig()
InitAuthConfig()
InitCORSConfig()
TTSConfigErr = InitTTSConfig()
}
func InitServerConfig() {
Server.Port = os.Getenv("PORT")
if Server.Port == "" {
Server.Port = common.DefaultPort
}
}
func InitAuthConfig() {
raw := os.Getenv("OPENAI_TTS_API_KEY")
if raw == "" {
Auth.APIKeys = nil
return
}
parts := strings.Split(raw, ",")
keys := make([]string, 0, len(parts))
for _, p := range parts {
k := strings.TrimSpace(p)
if k != "" {
keys = append(keys, k)
}
}
Auth.APIKeys = keys
}
func InitCORSConfig() {
raw := os.Getenv("ALLOWED_ORIGINS")
CORS.Origins = nil
CORS.AllowAll = false
if raw == "" {
return
}
for _, p := range strings.Split(raw, ",") {
o := strings.TrimSpace(p)
if o == "" {
continue
}
if o == "*" {
CORS.AllowAll = true
continue
}
CORS.Origins = append(CORS.Origins, normalizeOrigin(o))
}
}
func normalizeOrigin(origin string) string {
origin = strings.TrimSpace(origin)
origin = strings.TrimRight(origin, "/")
return strings.ToLower(origin)
}
// InitTTSConfig 读取火山 TTS 必填和可选配置,填充 TTSOptions 与 TTSTimeout。
// 必填项缺失时返回 error,/v1/audio/speech 路由会拒绝请求。
func InitTTSConfig() error {
apiKey := os.Getenv("BYTEDANCE_TTS_API_KEY")
resourceId := os.Getenv("BYTEDANCE_TTS_RESOURCE_ID")
speaker := os.Getenv("BYTEDANCE_TTS_SPEAKER")
missing := []string{}
if apiKey == "" {
missing = append(missing, "BYTEDANCE_TTS_API_KEY")
}
if resourceId == "" {
missing = append(missing, "BYTEDANCE_TTS_RESOURCE_ID")
}
if speaker == "" {
missing = append(missing, "BYTEDANCE_TTS_SPEAKER")
}
if len(missing) > 0 {
return fmt.Errorf("缺少必需的环境变量: %v", missing)
}
model := os.Getenv("BYTEDANCE_TTS_MODEL")
format := getEnvDefault("BYTEDANCE_TTS_FORMAT", "mp3")
sampleRate := getEnvInt("BYTEDANCE_TTS_SAMPLE_RATE", 24000)
bitRate := getEnvInt("BYTEDANCE_TTS_BIT_RATE", 0)
modelType := getEnvInt("BYTEDANCE_TTS_MODEL_TYPE", 0)
explicitLanguage := os.Getenv("BYTEDANCE_TTS_EXPLICIT_LANGUAGE")
enableSubtitle := getEnvBool("BYTEDANCE_TTS_ENABLE_SUBTITLE", false)
var adds *volcano.Additions
if modelType != 0 || explicitLanguage != "" {
adds = &volcano.Additions{}
if modelType != 0 {
v := modelType
adds.ModelType = &v
}
if explicitLanguage != "" {
adds.ExplicitLanguage = explicitLanguage
}
}
TTSTimeout = common.DefaultTimeout
if ts := os.Getenv("BYTEDANCE_TTS_TIMEOUT"); ts != "" {
if d, err := time.ParseDuration(ts); err == nil {
TTSTimeout = d
} else {
log.Printf("无效的超时设置 %q,使用默认值 %v", ts, TTSTimeout)
}
}
common.DebugLog = getEnvBool("BYTEDANCE_TTS_DEBUG", false)
if common.DebugLog {
log.Println("调试日志已启用 BYTEDANCE_TTS_DEBUG")
}
TTSOptions = volcano.Options{
APIKey: apiKey,
ResourceID: resourceId,
UID: "uid",
Speaker: speaker,
Model: model,
Format: format,
SampleRate: sampleRate,
BitRate: bitRate,
SpeechRate: 0,
LoudnessRate: 0,
EnableSubtitle: enableSubtitle,
Additions: adds,
}
return nil
}
func getEnvDefault(name, def string) string {
if v := os.Getenv(name); v != "" {
return v
}
return def
}
func getEnvInt(name string, def int) int {
v := os.Getenv(name)
if v == "" {
return def
}
n, err := strconv.Atoi(v)
if err != nil {
log.Printf("环境变量 %s=%q 不是合法整数,使用默认 %d", name, v, def)
return def
}
return n
}
func getEnvBool(name string, def bool) bool {
v := os.Getenv(name)
if v == "" {
return def
}
b, err := strconv.ParseBool(v)
if err != nil {
log.Printf("环境变量 %s=%q 不是合法 bool,使用默认 %v", name, v, def)
return def
}
return b
}
// CheckEnvironmentVariables 返回 /health 用的环境变量状态快照。
func CheckEnvironmentVariables() map[string]interface{} {
required := map[string]bool{
"BYTEDANCE_TTS_API_KEY": TTSOptions.APIKey != "",
"BYTEDANCE_TTS_RESOURCE_ID": TTSOptions.ResourceID != "",
"BYTEDANCE_TTS_SPEAKER": TTSOptions.Speaker != "",
}
missing := []string{}
for k, ok := range required {
if !ok {
missing = append(missing, k)
}
}
optional := map[string]bool{
"BYTEDANCE_TTS_MODEL": TTSOptions.Model != "",
"BYTEDANCE_TTS_FORMAT": TTSOptions.Format != "mp3",
"BYTEDANCE_TTS_SAMPLE_RATE": TTSOptions.SampleRate != 24000,
"BYTEDANCE_TTS_EXPLICIT_LANGUAGE": TTSOptions.Additions != nil && TTSOptions.Additions.ExplicitLanguage != "",
"OPENAI_TTS_API_KEY": len(Auth.APIKeys) > 0,
"ALLOWED_ORIGINS": CORS.AllowAll || len(CORS.Origins) > 0,
"PORT": Server.Port != common.DefaultPort,
}
return map[string]interface{}{
"all_required_vars_set": len(missing) == 0,
"missing_required_vars": missing,
"required_vars_set": required,
"optional_vars_set": optional,
}
}
// LogStartupSummary 启动期一次性打印所有 Config 状态。
func LogStartupSummary() {
log.Printf("=== 环境配置汇总 ===")
log.Printf("服务端口: %s", Server.Port)
if len(Auth.APIKeys) == 0 {
log.Printf("OPENAI_TTS_API_KEY: 未设置(所有请求无需鉴权)")
} else {
log.Printf("OPENAI_TTS_API_KEY: 已设置 %d 个有效密钥", len(Auth.APIKeys))
}
if CORS.AllowAll {
log.Printf("ALLOWED_ORIGINS: *(允许所有跨域;不可与鉴权共用)")
} else if len(CORS.Origins) == 0 {
log.Printf("ALLOWED_ORIGINS: 未设置(跨域请求将被拒绝)")
} else {
log.Printf("ALLOWED_ORIGINS: 已配置 %d 个允许的跨域来源白名单", len(CORS.Origins))
}
if h := TrustedProxyHops; h == 0 {
log.Printf("TRUSTED_PROXY_HOPS: 启发式模式(默认,XFF 链尾第一个公网 IP)")
} else {
log.Printf("TRUSTED_PROXY_HOPS: 精确模式,信任 %d 跳反代", h)
}
log.Printf("火山 TTS 必填项状态:")
type ttsCheck struct {
name string
value string
ok bool
}
checks := []ttsCheck{
{"BYTEDANCE_TTS_API_KEY", maskAPIKey(TTSOptions.APIKey), TTSOptions.APIKey != ""},
{"BYTEDANCE_TTS_RESOURCE_ID", TTSOptions.ResourceID, TTSOptions.ResourceID != ""},
{"BYTEDANCE_TTS_SPEAKER", TTSOptions.Speaker, TTSOptions.Speaker != ""},
}
missingCount := 0
for _, c := range checks {
mark := "✓"
if !c.ok {
mark = "✗"
missingCount++
}
val := c.value
if val == "" {
val = "(未设置)"
}
log.Printf(" %s %s: %s", mark, c.name, val)
}
if TTSConfigErr != nil {
log.Printf("火山 TTS 整体: 初始化失败(%d 个必填项缺失),/v1/audio/speech 路由将全部返回 500", missingCount)
} else {
log.Printf("火山 TTS 整体: 初始化成功")
}
}
func maskAPIKey(key string) string {
if key == "" {
return ""
}
if len(key) <= 8 {
return "****"
}
return key[:4] + "****" + key[len(key)-4:]
}
// CheckStaticFiles 检查 /dashboard 路由依赖的 health.html 是否存在。
func CheckStaticFiles() {
if _, err := os.Stat("health.html"); os.IsNotExist(err) {
log.Println("警告: health.html 不存在,/dashboard 路由将返回 404")
}
}
+90
View File
@@ -0,0 +1,90 @@
package telemetry
import (
"fmt"
"io"
"sort"
"sync"
"sync/atomic"
)
// Counter 单调递增的累计指标(整数语义,内部用 float64 位以 atomic 操作)。
type Counter struct {
metricName string
help string
labelNames []string
mu sync.RWMutex
values map[string]*counterChild // key = labelKey(...)
}
type counterChild struct {
labels Labels
bits atomic.Uint64 // float64
}
func newCounter(name, help string, labelNames []string) *Counter {
return &Counter{
metricName: name,
help: help,
labelNames: append([]string(nil), labelNames...),
values: make(map[string]*counterChild),
}
}
// Inc 计数 +1。
func (c *Counter) Inc(labels Labels) { c.Add(1, labels) }
// Add 累加 v(v 必须 >= 0)。
func (c *Counter) Add(v float64, labels Labels) {
if v < 0 {
return
}
child := c.getOrCreate(labels)
for {
bits := child.bits.Load()
cur := float64frombits(bits)
next := float64bits(cur + v)
if child.bits.CompareAndSwap(bits, next) {
return
}
}
}
func (c *Counter) getOrCreate(labels Labels) *counterChild {
key := labelKey(c.labelNames, labels)
c.mu.RLock()
if child, ok := c.values[key]; ok {
c.mu.RUnlock()
return child
}
c.mu.RUnlock()
c.mu.Lock()
defer c.mu.Unlock()
if child, ok := c.values[key]; ok {
return child
}
child := &counterChild{labels: copyLabels(labels, c.labelNames)}
c.values[key] = child
return child
}
func (c *Counter) collect(w io.Writer) {
fmt.Fprintf(w, "# HELP %s %s\n", c.metricName, c.help)
fmt.Fprintf(w, "# TYPE %s counter\n", c.metricName)
c.mu.RLock()
keys := make([]string, 0, len(c.values))
for k := range c.values {
keys = append(keys, k)
}
sort.Strings(keys)
defer c.mu.RUnlock()
for _, k := range keys {
child := c.values[k]
val := float64frombits(child.bits.Load())
writeMetricLine(w, c.metricName, child.labels, val)
}
}
+88
View File
@@ -0,0 +1,88 @@
package telemetry
import (
"fmt"
"io"
"math"
"strconv"
"strings"
)
// copyLabels 返回只包含 labelNames 中声明的 key 的副本,缺失补空串。
// 这样序列化时输出顺序和数量固定。
func copyLabels(labels Labels, names []string) Labels {
if len(names) == 0 {
return Labels{}
}
out := make(Labels, len(names))
for _, n := range names {
out[n] = labels[n]
}
return out
}
func mergeLabels(a, b Labels) Labels {
out := make(Labels, len(a)+len(b))
for k, v := range a {
out[k] = v
}
for k, v := range b {
out[k] = v
}
return out
}
// formatLabels 序列化为 `{k1="v1",k2="v2"}`;空集合返回空字符串。
// value 内的 `\`, `"`, 换行会按 Prometheus 规范转义。
func formatLabels(labels Labels) string {
if len(labels) == 0 {
return ""
}
keys := sortedKeys(labels)
var sb strings.Builder
sb.WriteByte('{')
for i, k := range keys {
if i > 0 {
sb.WriteByte(',')
}
sb.WriteString(k)
sb.WriteString(`="`)
sb.WriteString(escapeLabelValue(labels[k]))
sb.WriteByte('"')
}
sb.WriteByte('}')
return sb.String()
}
func escapeLabelValue(v string) string {
if !strings.ContainsAny(v, "\\\"\n") {
return v
}
var sb strings.Builder
sb.Grow(len(v) + 2)
for i := 0; i < len(v); i++ {
switch v[i] {
case '\\':
sb.WriteString(`\\`)
case '"':
sb.WriteString(`\"`)
case '\n':
sb.WriteString(`\n`)
default:
sb.WriteByte(v[i])
}
}
return sb.String()
}
func writeMetricLine(w io.Writer, name string, labels Labels, value float64) {
fmt.Fprintf(w, "%s%s %s\n", name, formatLabels(labels), formatFloat(value))
}
func formatFloat(f float64) string {
return strconv.FormatFloat(f, 'g', -1, 64)
}
// float64 bits 互转,封装到独立文件避免重复。
func float64bits(f float64) uint64 { return math.Float64bits(f) }
func float64frombits(b uint64) float64 { return math.Float64frombits(b) }
+96
View File
@@ -0,0 +1,96 @@
package telemetry
import (
"fmt"
"io"
"sort"
"sync"
"sync/atomic"
)
// Gauge 可增可减的瞬时值。
type Gauge struct {
metricName string
help string
labelNames []string
mu sync.RWMutex
values map[string]*gaugeChild
}
type gaugeChild struct {
labels Labels
bits atomic.Uint64
}
func newGauge(name, help string, labelNames []string) *Gauge {
return &Gauge{
metricName: name,
help: help,
labelNames: append([]string(nil), labelNames...),
values: make(map[string]*gaugeChild),
}
}
// Set 直接设置当前值。
func (g *Gauge) Set(v float64, labels Labels) {
child := g.getOrCreate(labels)
child.bits.Store(float64bits(v))
}
// Inc +1。
func (g *Gauge) Inc(labels Labels) { g.Add(1, labels) }
// Dec -1。
func (g *Gauge) Dec(labels Labels) { g.Add(-1, labels) }
// Add 累加 v(可负)。
func (g *Gauge) Add(v float64, labels Labels) {
child := g.getOrCreate(labels)
for {
bits := child.bits.Load()
cur := float64frombits(bits)
next := float64bits(cur + v)
if child.bits.CompareAndSwap(bits, next) {
return
}
}
}
func (g *Gauge) getOrCreate(labels Labels) *gaugeChild {
key := labelKey(g.labelNames, labels)
g.mu.RLock()
if c, ok := g.values[key]; ok {
g.mu.RUnlock()
return c
}
g.mu.RUnlock()
g.mu.Lock()
defer g.mu.Unlock()
if c, ok := g.values[key]; ok {
return c
}
c := &gaugeChild{labels: copyLabels(labels, g.labelNames)}
g.values[key] = c
return c
}
func (g *Gauge) collect(w io.Writer) {
fmt.Fprintf(w, "# HELP %s %s\n", g.metricName, g.help)
fmt.Fprintf(w, "# TYPE %s gauge\n", g.metricName)
g.mu.RLock()
keys := make([]string, 0, len(g.values))
for k := range g.values {
keys = append(keys, k)
}
sort.Strings(keys)
defer g.mu.RUnlock()
for _, k := range keys {
child := g.values[k]
val := float64frombits(child.bits.Load())
writeMetricLine(w, g.metricName, child.labels, val)
}
}
+114
View File
@@ -0,0 +1,114 @@
package telemetry
import (
"fmt"
"io"
"sort"
"sync"
"sync/atomic"
)
// DefaultLatencyBuckets 适合 HTTP/TTS 场景的默认桶(秒)。
var DefaultLatencyBuckets = []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30}
// Histogram 累计分布型指标,记录观测值的分布。
//
// 内部为每个 child 维护:
// - buckets[i] 累计计数(<= le_i 的观测数,不含 +Inf 桶)
// - count 全部观测计数
// - sum 全部观测值之和
type Histogram struct {
metricName string
help string
labelNames []string
buckets []float64 // 用户声明的上界,不含 +Inf
mu sync.RWMutex
values map[string]*histChild
}
type histChild struct {
labels Labels
buckets []atomic.Uint64 // 累计计数
count atomic.Uint64
sumBits atomic.Uint64 // float64
}
func newHistogram(name, help string, buckets []float64, labelNames []string) *Histogram {
bs := append([]float64(nil), buckets...)
sort.Float64s(bs)
return &Histogram{
metricName: name,
help: help,
labelNames: append([]string(nil), labelNames...),
buckets: bs,
values: make(map[string]*histChild),
}
}
// Observe 记录一个观测值。
func (h *Histogram) Observe(v float64, labels Labels) {
child := h.getOrCreate(labels)
for {
bits := child.sumBits.Load()
cur := float64frombits(bits)
next := float64bits(cur + v)
if child.sumBits.CompareAndSwap(bits, next) {
break
}
}
child.count.Add(1)
for i, le := range h.buckets {
if v <= le {
child.buckets[i].Add(1)
}
}
}
func (h *Histogram) getOrCreate(labels Labels) *histChild {
key := labelKey(h.labelNames, labels)
h.mu.RLock()
if c, ok := h.values[key]; ok {
h.mu.RUnlock()
return c
}
h.mu.RUnlock()
h.mu.Lock()
defer h.mu.Unlock()
if c, ok := h.values[key]; ok {
return c
}
c := &histChild{
labels: copyLabels(labels, h.labelNames),
buckets: make([]atomic.Uint64, len(h.buckets)),
}
h.values[key] = c
return c
}
func (h *Histogram) collect(w io.Writer) {
fmt.Fprintf(w, "# HELP %s %s\n", h.metricName, h.help)
fmt.Fprintf(w, "# TYPE %s histogram\n", h.metricName)
h.mu.RLock()
keys := make([]string, 0, len(h.values))
for k := range h.values {
keys = append(keys, k)
}
sort.Strings(keys)
defer h.mu.RUnlock()
for _, k := range keys {
child := h.values[k]
for i, le := range h.buckets {
merged := mergeLabels(child.labels, Labels{"le": formatFloat(le)})
fmt.Fprintf(w, "%s_bucket%s %d\n", h.metricName, formatLabels(merged), child.buckets[i].Load())
}
merged := mergeLabels(child.labels, Labels{"le": "+Inf"})
fmt.Fprintf(w, "%s_bucket%s %d\n", h.metricName, formatLabels(merged), child.count.Load())
sum := float64frombits(child.sumBits.Load())
fmt.Fprintf(w, "%s_sum%s %s\n", h.metricName, formatLabels(child.labels), formatFloat(sum))
fmt.Fprintf(w, "%s_count%s %d\n", h.metricName, formatLabels(child.labels), child.count.Load())
}
}
+48
View File
@@ -0,0 +1,48 @@
// Package telemetry 提供进程内可观测能力:Counter / Gauge / Histogram,
// 以及 Prometheus 文本格式导出。
//
// 设计原则:
// - 零外部依赖,只使用标准库;
// - label key 在指标注册时锁定,运行期不可新增(避免 cardinality 爆炸);
// - 所有并发安全由实现保证,调用方无需加锁;
// - Meter 是高层入口,NoopMeter 用于测试。
package telemetry
import "sort"
// Labels 是指标附加的标签集合。Value 在序列化时会按 Prometheus 规范转义。
type Labels map[string]string
// labelKey 计算一组标签的稳定 key,用于在内部 map 中唯一定位 child。
// 缺失或多余的 label 一律视为空串,以保证 child 数量与 label 名集合一致。
func labelKey(names []string, labels Labels) string {
if len(names) == 0 {
return ""
}
parts := make([]string, 0, len(names)*2)
for _, n := range names {
parts = append(parts, n, labels[n])
}
return joinLabelParts(parts)
}
func joinLabelParts(parts []string) string {
out := make([]byte, 0, 16*len(parts))
for i, p := range parts {
if i > 0 {
out = append(out, 0)
}
out = append(out, p...)
}
return string(out)
}
// sortedKeys 返回按字典序排列的 key,用于导出时输出稳定顺序。
func sortedKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
+60
View File
@@ -0,0 +1,60 @@
package telemetry
import "net/http"
// Meter 是 telemetry 的高层入口,提供 Counter / Gauge / Histogram 的构造方法。
// 启动时调用 NewMeter() 得到默认实现,测试时可换成 NoopMeter。
//
// 设计:抽象成 interface 是为了在测试或禁用观测时能无侵入替换实现;
// 真正的注册逻辑全部委托给内部 *Registry。
type Meter interface {
Handler() http.Handler
Registry() *Registry
NewCounter(name, help string, labelNames ...string) *Counter
NewGauge(name, help string, labelNames ...string) *Gauge
NewHistogram(name, help string, buckets []float64, labelNames ...string) *Histogram
}
// RealMeter 是 Meter 的默认实现,内部维护一个 *Registry。
type RealMeter struct {
reg *Registry
}
// NewMeter 构造默认 Meter 实现。
func NewMeter() Meter {
return &RealMeter{reg: newRegistry()}
}
func (m *RealMeter) Handler() http.Handler { return m.reg.Handler() }
// Registry 暴露给特殊用例(如测试断言),生产代码不应使用。
func (m *RealMeter) Registry() *Registry { return m.reg }
// NewCounter 注册并返回一个 Counter。
// - name 指标名(Prometheus 风格,如 "tts_request_total")
// - help 帮助文本
// - labelNames 注册时锁定的 label key 集合,运行期不可变
func (m *RealMeter) NewCounter(name, help string, labelNames ...string) *Counter {
c := newCounter(name, help, labelNames)
if err := m.reg.register(name, c); err != nil {
// 注册重名是启动期 bug,直接 panic 让问题在启动时暴露。
panic(err)
}
return c
}
func (m *RealMeter) NewGauge(name, help string, labelNames ...string) *Gauge {
g := newGauge(name, help, labelNames)
if err := m.reg.register(name, g); err != nil {
panic(err)
}
return g
}
func (m *RealMeter) NewHistogram(name, help string, buckets []float64, labelNames ...string) *Histogram {
h := newHistogram(name, help, buckets, labelNames)
if err := m.reg.register(name, h); err != nil {
panic(err)
}
return h
}
+22
View File
@@ -0,0 +1,22 @@
package telemetry
import "net/http"
// NoopMeter 是一个不采集、不输出的 Meter,用于单元测试或禁用观测的场景。
// 返回的 Counter / Gauge / Histogram 实例不会被注册到任何 Registry,
// 它们的 Inc/Add/Observe 调用在本进程内没有可见效果(每次返回新的空实例)。
//
// 实现 Meter 接口。
type NoopMeter struct{}
func (NoopMeter) NewCounter(string, string, ...string) *Counter {
return newCounter("", "", nil)
}
func (NoopMeter) NewGauge(string, string, ...string) *Gauge {
return newGauge("", "", nil)
}
func (NoopMeter) NewHistogram(string, string, []float64, ...string) *Histogram {
return newHistogram("", "", nil, nil)
}
func (NoopMeter) Handler() http.Handler { return http.NotFoundHandler() }
func (NoopMeter) Registry() *Registry { return nil }
+66
View File
@@ -0,0 +1,66 @@
package telemetry
import (
"fmt"
"io"
"net/http"
"sort"
"sync"
)
// collector 是 Counter / Gauge / Histogram 共同实现的内部接口。
type collector interface {
collect(w io.Writer)
}
// Registry 持有已注册的全部指标,提供 Prometheus 文本格式导出。
type Registry struct {
mu sync.RWMutex
entries map[string]collector
order []string // 保留注册顺序,使输出可预测
}
func newRegistry() *Registry {
return &Registry{
entries: make(map[string]collector),
}
}
func (r *Registry) register(name string, c collector) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, exists := r.entries[name]; exists {
return fmt.Errorf("metric %q already registered", name)
}
r.entries[name] = c
r.order = append(r.order, name)
return nil
}
// Gather 把所有指标按注册顺序写入 w,文本格式遵循 Prometheus 0.0.4。
func (r *Registry) Gather(w io.Writer) error {
r.mu.RLock()
order := append([]string(nil), r.order...)
defer r.mu.RUnlock()
for _, name := range order {
r.entries[name].collect(w)
}
return nil
}
// Handler 返回标准 Prometheus 抓取端点。
func (r *Registry) Handler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
_ = r.Gather(w)
})
}
// 注册顺序的辅助,用于测试断言。
func (r *Registry) names() []string {
r.mu.RLock()
defer r.mu.RUnlock()
out := append([]string(nil), r.order...)
sort.Strings(out)
return out
}
-778
View File
@@ -1,778 +0,0 @@
package main
import (
"bufio"
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"os/signal"
"runtime"
"strings"
"sync"
"syscall"
"time"
"github.com/google/uuid"
"github.com/gorilla/mux"
)
const (
DEFAULT_PORT = "8080"
DEFAULT_TIMEOUT = 30 * time.Second
MAX_TEXT_LENGTH = 5000
MIN_SPEED = 0.25
MAX_SPEED = 4.0
DEFAULT_SPEED = 1.0
MAX_REQUEST_BODY_SIZE = 1024 * 1024
RATE_LIMIT_REQUESTS = 100
RATE_LIMIT_WINDOW = time.Minute
MAX_RESPONSE_TIMES = 100
MAX_ERRORS = 10
MAX_CONCURRENT_REQUESTS = 10
)
type V3TTSResponse struct {
ReqID string `json:"reqid"`
Code int `json:"code"`
Message string `json:"message"`
Event string `json:"event"`
Sequence int `json:"sequence"`
Data string `json:"data"`
Sentence string `json:"sentence,omitempty"`
IsFinal bool `json:"is_final"`
Usage *Usage `json:"usage,omitempty"`
}
type Usage struct {
TextWords int `json:"text_words"`
}
type OpenAITTSRequest struct {
Model string `json:"model"`
Input string `json:"input"`
Voice string `json:"voice"`
ResponseFormat string `json:"response_format,omitempty"`
Speed float64 `json:"speed,omitempty"`
}
type ByteDanceTTSConfig struct {
ApiKey string
ResourceId string
Speaker string
URL string
Timeout time.Duration
}
type RateLimiter struct {
requests map[string][]time.Time
mutex sync.Mutex
limit int
window time.Duration
lastCleanup time.Time
}
const cleanupInterval = time.Hour
type Stats struct {
totalRequests int64
successfulRequests int64
failedRequests int64
totalResponseTime time.Duration
recentResponseTimes []float64
responseTimesIndex int
lastErrors []string
errorsIndex int
mutex sync.RWMutex
}
var (
VALID_API_KEYS []string
ttsConfig ByteDanceTTSConfig
ttsConfigErr error
globalHTTPClient *http.Client
apiStats *Stats
rateLimiter *RateLimiter
concurrencySem chan struct{}
)
func init() {
globalHTTPClient = &http.Client{
Timeout: DEFAULT_TIMEOUT,
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
},
}
apiStats = &Stats{
recentResponseTimes: make([]float64, MAX_RESPONSE_TIMES),
lastErrors: make([]string, MAX_ERRORS),
}
rateLimiter = &RateLimiter{
requests: make(map[string][]time.Time),
limit: RATE_LIMIT_REQUESTS,
window: RATE_LIMIT_WINDOW,
}
concurrencySem = make(chan struct{}, MAX_CONCURRENT_REQUESTS)
}
func (rl *RateLimiter) Allow(key string) bool {
rl.mutex.Lock()
defer rl.mutex.Unlock()
now := time.Now()
cutoff := now.Add(-rl.window)
if now.Sub(rl.lastCleanup) > cleanupInterval {
rl.cleanup()
rl.lastCleanup = now
}
timestamps := rl.requests[key]
valid := make([]time.Time, 0, len(timestamps))
for _, ts := range timestamps {
if ts.After(cutoff) {
valid = append(valid, ts)
}
}
if len(valid) >= rl.limit {
rl.requests[key] = valid
return false
}
valid = append(valid, now)
rl.requests[key] = valid
return true
}
func (rl *RateLimiter) cleanup() {
cutoff := time.Now().Add(-rl.window)
for k, v := range rl.requests {
valid := make([]time.Time, 0, len(v))
for _, ts := range v {
if ts.After(cutoff) {
valid = append(valid, ts)
}
}
if len(valid) == 0 {
delete(rl.requests, k)
} else {
rl.requests[k] = valid
}
}
}
func initTTSConfig() error {
apiKey := os.Getenv("BYTEDANCE_TTS_API_KEY")
resourceId := os.Getenv("BYTEDANCE_TTS_RESOURCE_ID")
speaker := os.Getenv("BYTEDANCE_TTS_SPEAKER")
missingVars := []string{}
if apiKey == "" {
missingVars = append(missingVars, "BYTEDANCE_TTS_API_KEY")
}
if resourceId == "" {
missingVars = append(missingVars, "BYTEDANCE_TTS_RESOURCE_ID")
}
if speaker == "" {
missingVars = append(missingVars, "BYTEDANCE_TTS_SPEAKER")
}
if len(missingVars) > 0 {
return fmt.Errorf("缺少必需的环境变量: %v", missingVars)
}
url := "https://openspeech.bytedance.com/api/v3/tts/unidirectional"
timeout := DEFAULT_TIMEOUT
if timeoutStr := os.Getenv("BYTEDANCE_TTS_TIMEOUT"); timeoutStr != "" {
if parsedTimeout, err := time.ParseDuration(timeoutStr); err == nil {
timeout = parsedTimeout
} else {
log.Printf("无效的超时设置 '%s',使用默认值: %v", timeoutStr, timeout)
}
}
ttsConfig = ByteDanceTTSConfig{
ApiKey: apiKey,
ResourceId: resourceId,
Speaker: speaker,
URL: url,
Timeout: timeout,
}
return nil
}
func initAPIKeys() {
apiKey := os.Getenv("OPENAI_TTS_API_KEY")
if apiKey != "" {
VALID_API_KEYS = strings.Split(apiKey, ",")
for i, k := range VALID_API_KEYS {
VALID_API_KEYS[i] = strings.TrimSpace(k)
}
log.Printf("已配置 %d 个有效的API密钥", len(VALID_API_KEYS))
} else {
log.Println("警告: OPENAI_TTS_API_KEY 环境变量未设置,将允许所有请求")
}
}
func checkEnvironmentVariables() map[string]interface{} {
requiredVars := map[string]bool{
"BYTEDANCE_TTS_API_KEY": os.Getenv("BYTEDANCE_TTS_API_KEY") != "",
"BYTEDANCE_TTS_RESOURCE_ID": os.Getenv("BYTEDANCE_TTS_RESOURCE_ID") != "",
"BYTEDANCE_TTS_SPEAKER": os.Getenv("BYTEDANCE_TTS_SPEAKER") != "",
}
missingVars := []string{}
for varName, isSet := range requiredVars {
if !isSet {
missingVars = append(missingVars, varName)
}
}
optionalVars := map[string]bool{
"BYTEDANCE_TTS_TIMEOUT": os.Getenv("BYTEDANCE_TTS_TIMEOUT") != "",
"OPENAI_TTS_API_KEY": os.Getenv("OPENAI_TTS_API_KEY") != "",
"PORT": os.Getenv("PORT") != "",
}
return map[string]interface{}{
"all_required_vars_set": len(missingVars) == 0,
"missing_required_vars": missingVars,
"required_vars_set": requiredVars,
"optional_vars_set": optionalVars,
}
}
func httpPostStream(url string, headers map[string]string, body []byte, timeout time.Duration) (*http.Response, error) {
req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(body))
if err != nil {
return nil, err
}
for key, value := range headers {
req.Header.Set(key, value)
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
req = req.WithContext(ctx)
return globalHTTPClient.Do(req)
}
func convertSpeedToSpeechRate(speed float64) int {
if speed <= 0.5 {
return -50
}
if speed >= 2.0 {
return 100
}
return int((speed - 1.0) * 100)
}
type SynthesisResult struct {
AudioData []byte
ReqID string
}
func synthesis(text string, speed float64) (*SynthesisResult, error) {
reqID := uuid.NewString()
speechRate := convertSpeedToSpeechRate(speed)
params := map[string]interface{}{
"user": map[string]interface{}{
"uid": "uid",
},
"namespace": "BidirectionalTTS",
"req_params": map[string]interface{}{
"text": text,
"speaker": ttsConfig.Speaker,
"audio_params": map[string]interface{}{
"format": "wav",
"sample_rate": 24000,
"speech_rate": speechRate,
},
},
}
headers := map[string]string{
"Content-Type": "application/json",
"Connection": "keep-alive",
"X-Api-Resource-Id": ttsConfig.ResourceId,
"X-Api-Request-Id": reqID,
"X-Api-Key": ttsConfig.ApiKey,
}
bodyStr, err := json.Marshal(params)
if err != nil {
log.Printf("JSON marshal fail: %v", err)
return nil, err
}
resp, err := httpPostStream(ttsConfig.URL, headers, bodyStr, ttsConfig.Timeout)
if err != nil {
log.Printf("http post fail: %v", err)
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
log.Printf("TTS service error: status=%d, body=%s", resp.StatusCode, string(body))
return nil, fmt.Errorf("TTS service error: status %d", resp.StatusCode)
}
var audioData []byte
scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Bytes()
if len(line) == 0 {
continue
}
var v3Resp V3TTSResponse
if err := json.Unmarshal(line, &v3Resp); err != nil {
log.Printf("unmarshal chunk fail: %v, line: %s", err, string(line))
continue
}
if v3Resp.Code == 20000000 {
if v3Resp.Usage != nil {
log.Printf("TTS synthesis completed, usage: %+v", v3Resp.Usage)
}
for scanner.Scan() {
}
break
}
if v3Resp.Code != 0 {
log.Printf("TTS service error: code=%d, message=%s", v3Resp.Code, v3Resp.Message)
return nil, fmt.Errorf("TTS service error: %s", v3Resp.Message)
}
if v3Resp.Data != "" {
chunk, err := base64.StdEncoding.DecodeString(v3Resp.Data)
if err != nil {
log.Printf("base64 decode fail: %v", err)
return nil, err
}
audioData = append(audioData, chunk...)
} else if v3Resp.Sentence != "" {
log.Printf("Received sentence info (sequence %d): %s", v3Resp.Sequence, v3Resp.Sentence)
}
}
if err := scanner.Err(); err != nil {
log.Printf("read stream fail: %v", err)
return nil, err
}
if len(audioData) == 0 {
return nil, fmt.Errorf("no audio data received")
}
return &SynthesisResult{AudioData: audioData, ReqID: reqID}, nil
}
func validateAPIKey(r *http.Request) bool {
if len(VALID_API_KEYS) == 0 {
return true
}
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
return false
}
if !strings.HasPrefix(authHeader, "Bearer ") {
return false
}
token := strings.TrimPrefix(authHeader, "Bearer ")
for _, validKey := range VALID_API_KEYS {
if token == validKey {
return true
}
}
return false
}
func getClientIP(r *http.Request) string {
xForwardedFor := r.Header.Get("X-Forwarded-For")
if xForwardedFor != "" {
ips := strings.Split(xForwardedFor, ",")
if len(ips) > 0 {
return strings.TrimSpace(ips[0])
}
}
xRealIP := r.Header.Get("X-Real-IP")
if xRealIP != "" {
return xRealIP
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
func openaiTTSHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
if !validateAPIKey(r) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]interface{}{
"error": map[string]interface{}{
"message": "Invalid API key provided.",
"type": "invalid_request_error",
"code": "invalid_api_key",
},
})
return
}
if ttsConfigErr != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusServiceUnavailable)
json.NewEncoder(w).Encode(map[string]interface{}{
"error": map[string]interface{}{
"message": fmt.Sprintf("TTS service configuration error: %v. Please check environment variables and restart the service.", ttsConfigErr),
"type": "configuration_error",
"code": "service_unavailable",
},
})
return
}
clientIP := getClientIP(r)
if !rateLimiter.Allow(clientIP) {
log.Printf("警告: 已超过IP速率限制,拒绝请求 - 客户端IP: %s", clientIP)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusTooManyRequests)
json.NewEncoder(w).Encode(map[string]interface{}{
"error": map[string]interface{}{
"message": "Rate limit exceeded. Please try again later.",
"type": "rate_limit_error",
"code": "rate_limit_exceeded",
},
})
return
}
select {
case concurrencySem <- struct{}{}:
defer func() { <-concurrencySem }()
default:
log.Printf("警告: 已达到最大并发请求数限制,拒绝请求 - 客户端IP: %s", getClientIP(r))
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusServiceUnavailable)
json.NewEncoder(w).Encode(map[string]interface{}{
"error": map[string]interface{}{
"message": "Server is busy, maximum concurrent requests reached. Please try again later.",
"type": "concurrency_limit_error",
"code": "max_concurrent_requests",
},
})
return
}
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, MAX_REQUEST_BODY_SIZE))
if err != nil {
if strings.Contains(err.Error(), "request body too large") {
http.Error(w, "Request body too large", http.StatusRequestEntityTooLarge)
} else {
http.Error(w, "Failed to read request body", http.StatusBadRequest)
}
return
}
var req OpenAITTSRequest
if err := json.Unmarshal(body, &req); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
if req.Input == "" {
http.Error(w, "Input text is required", http.StatusBadRequest)
return
}
if len(req.Input) > MAX_TEXT_LENGTH {
http.Error(w, fmt.Sprintf("Input text too long (max %d characters)", MAX_TEXT_LENGTH), http.StatusBadRequest)
return
}
speed := req.Speed
if speed <= 0 {
speed = DEFAULT_SPEED
}
if speed < MIN_SPEED {
speed = MIN_SPEED
}
if speed > MAX_SPEED {
speed = MAX_SPEED
}
ttsStart := time.Now()
result, err := synthesis(req.Input, speed)
duration := time.Since(ttsStart)
if err != nil {
addRequestStats(false, duration, err.Error())
http.Error(w, "TTS synthesis failed", http.StatusInternalServerError)
return
}
addRequestStats(true, duration, "")
w.Header().Set("Content-Type", "audio/wav")
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(result.AudioData)))
w.Header().Set("X-Request-Id", result.ReqID)
w.WriteHeader(http.StatusOK)
w.Write(result.AudioData)
}
func addRequestStats(success bool, responseTime time.Duration, errMsg string) {
apiStats.mutex.Lock()
defer apiStats.mutex.Unlock()
apiStats.totalRequests++
apiStats.totalResponseTime += responseTime
apiStats.recentResponseTimes[apiStats.responseTimesIndex] = responseTime.Seconds() * 1000
apiStats.responseTimesIndex = (apiStats.responseTimesIndex + 1) % MAX_RESPONSE_TIMES
if success {
apiStats.successfulRequests++
} else {
apiStats.failedRequests++
if errMsg != "" {
errInfo := fmt.Sprintf("%s: %s", time.Now().Format(time.RFC3339), errMsg)
apiStats.lastErrors[apiStats.errorsIndex] = errInfo
apiStats.errorsIndex = (apiStats.errorsIndex + 1) % MAX_ERRORS
}
}
}
func getMemoryInfo() map[string]interface{} {
var m runtime.MemStats
runtime.ReadMemStats(&m)
return map[string]interface{}{
"total_alloc": m.TotalAlloc,
"heap_alloc": m.HeapAlloc,
"heap_inuse": m.HeapInuse,
"goroutines": runtime.NumGoroutine(),
}
}
func healthHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if ttsConfigErr != nil {
w.WriteHeader(http.StatusServiceUnavailable)
} else {
w.WriteHeader(http.StatusOK)
}
apiStats.mutex.RLock()
totalRequests := apiStats.totalRequests
successfulRequests := apiStats.successfulRequests
failedRequests := apiStats.failedRequests
totalResponseTime := apiStats.totalResponseTime
recentResponseTimes := make([]float64, 0, MAX_RESPONSE_TIMES)
for _, t := range apiStats.recentResponseTimes {
if t > 0 {
recentResponseTimes = append(recentResponseTimes, t)
}
}
lastErrors := make([]string, 0, MAX_ERRORS)
for _, e := range apiStats.lastErrors {
if e != "" {
lastErrors = append(lastErrors, e)
}
}
apiStats.mutex.RUnlock()
var errorRate float64
if totalRequests > 0 {
errorRate = float64(failedRequests) / float64(totalRequests) * 100
}
var avgResponseTime float64
if totalRequests > 0 {
avgResponseTime = totalResponseTime.Seconds() * 1000 / float64(totalRequests)
}
envCheckStatus := checkEnvironmentVariables()
allEnvVarsSet := envCheckStatus["all_required_vars_set"].(bool)
status := "ok"
if !allEnvVarsSet {
status = "configuration_error"
}
response := map[string]interface{}{
"status": status,
"service": "ByteDance TTS to OpenAI API Adapter",
"version": "2.0.0 (v3 API)",
"uptime": fmt.Sprintf("%.0f seconds", time.Since(startTime).Seconds()),
"start_time": startTime.Format(time.RFC3339),
"memory": getMemoryInfo(),
"api_stats": map[string]interface{}{
"total_requests": totalRequests,
"successful_requests": successfulRequests,
"failed_requests": failedRequests,
"error_rate_percent": fmt.Sprintf("%.2f", errorRate),
"avg_response_time_ms": fmt.Sprintf("%.2f", avgResponseTime),
"recent_response_times_ms": recentResponseTimes,
},
"errors": map[string]interface{}{
"recent_errors_count": len(lastErrors),
},
"config_status": map[string]interface{}{
"all_required_vars_set": allEnvVarsSet,
"config_error": ttsConfigErr != nil,
"config_error_message": fmt.Sprintf("%v", ttsConfigErr),
},
}
json.NewEncoder(w).Encode(response)
}
var startTime time.Time
type statusRecorder struct {
http.ResponseWriter
statusCode int
}
func (rec *statusRecorder) WriteHeader(code int) {
rec.statusCode = code
rec.ResponseWriter.WriteHeader(code)
}
func corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
next.ServeHTTP(w, r)
})
}
func main() {
startTime = time.Now()
log.SetFlags(log.LstdFlags | log.Lshortfile)
log.SetPrefix("[TTS-Server] ")
initAPIKeys()
ttsConfigErr = initTTSConfig()
if ttsConfigErr != nil {
log.Printf("警告: 配置初始化失败: %v", ttsConfigErr)
log.Printf("服务将继续运行,但TTS功能不可用,请检查环境变量配置")
} else {
log.Printf("配置初始化成功")
}
router := mux.NewRouter()
router.Use(corsMiddleware)
router.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/health" {
start := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s %s %v", r.Method, r.RequestURI, r.RemoteAddr, time.Since(start))
return
}
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, statusCode: http.StatusOK}
next.ServeHTTP(rec, r)
duration := time.Since(start)
log.Printf("%s %s %s %d %v", r.Method, r.RequestURI, r.RemoteAddr, rec.statusCode, duration)
})
})
router.HandleFunc("/v1/audio/speech", openaiTTSHandler).Methods("POST", "OPTIONS")
router.HandleFunc("/health", healthHandler).Methods("GET")
router.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/health", http.StatusFound)
}).Methods("GET")
port := os.Getenv("PORT")
if port == "" {
port = DEFAULT_PORT
}
server := &http.Server{
Addr: ":" + port,
Handler: router,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
go func() {
log.Printf("Starting ByteDance TTS to OpenAI API Adapter Server")
log.Printf("Listening on port: %s", port)
log.Printf("OpenAI TTS endpoint: http://localhost:%s/v1/audio/speech", port)
log.Printf("Health check: http://localhost:%s/health", port)
log.Printf("Using ByteDance v3 API: %s", ttsConfig.URL)
log.Printf("Resource ID: %s", ttsConfig.ResourceId)
log.Printf("Speaker: %s", ttsConfig.Speaker)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Server failed to start: %v", err)
}
}()
<-quit
log.Println("Shutting down server...")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
log.Printf("Server forced to shutdown: %v", err)
} else {
log.Println("Server exited gracefully")
}
}
+18
View File
@@ -0,0 +1,18 @@
// Package version 提供构建时注入的版本信息。
//
// Version 和 Commit 在编译时通过 -ldflags 注入:
//
// go build -ldflags "-X github.com/volcano-tts/tts-api/version.Version=$VERSION \
// -X github.com/volcano-tts/tts-api/version.Commit=$COMMIT"
//
// 开发时默认 "dev",CI/CD 时通常由 git describe 自动算出:
// VERSION=$(git describe --tags --always --dirty)
// COMMIT=$(git rev-parse --short HEAD)
//
// /health 端点会暴露这两个值,方便运维确认"跑的到底是哪个 commit"。
package version
var (
Version = "dev"
Commit = "dev"
)