Compare commits
13
Commits
a238e5c2a4
..
v0.2.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d3517eb5c | ||
|
|
cdc9a7c94b | ||
|
|
c00c46e7a1 | ||
|
|
3b3aa3b708 | ||
|
|
695b3ecf25 | ||
|
|
7343d5aa5c | ||
|
|
171503d775 | ||
|
|
91b0c8acee | ||
|
|
d1e7f9a4dd | ||
|
|
4a8c563b32 | ||
|
|
cd70157aea | ||
|
|
ac614be190 | ||
|
|
880c199ce4 |
@@ -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 }}
|
||||||
+9
-1
@@ -7,7 +7,15 @@ RUN go mod download
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -o tts-api .
|
# 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
|
FROM alpine:3.21
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ tts-api.exe
|
|||||||
|--------|------|--------|
|
|--------|------|--------|
|
||||||
| `BYTEDANCE_TTS_TIMEOUT` | 单次合成超时 | `30s` |
|
| `BYTEDANCE_TTS_TIMEOUT` | 单次合成超时 | `30s` |
|
||||||
| `BYTEDANCE_TTS_FORMAT` | 上游实际请求的音频格式(mp3 / pcm / ogg_opus);客户端要求 wav 时内部自动转 pcm + 本地拼 WAV 头 | `mp3` |
|
| `BYTEDANCE_TTS_FORMAT` | 上游实际请求的音频格式(mp3 / pcm / ogg_opus);客户端要求 wav 时内部自动转 pcm + 本地拼 WAV 头 | `mp3` |
|
||||||
| `BYTEDANCE_TTS_SAMPLE_RATE` | 上游采样率(8000 / 16000 / 22050 / 24000 / 32000 / 44100 / 48000) | `24000` |
|
| `BYTEDANCE_TTS_SAMPLE_RATE` | 上游采样率(8000 / 16000 / 22050 / 24000 / 32000 / 44100 / 48000);**此值直接写入 WAV 头,需与上游实际 PCM 采样率一致,否则音频变速变调** | `24000` |
|
||||||
| `BYTEDANCE_TTS_BIT_RATE` | MP3 比特率,仅 mp3 生效 | 无 |
|
| `BYTEDANCE_TTS_BIT_RATE` | MP3 比特率,仅 mp3 生效 | 无 |
|
||||||
|
|
||||||
### 复刻 2.0 扩展参数
|
### 复刻 2.0 扩展参数
|
||||||
@@ -79,7 +79,7 @@ tts-api.exe
|
|||||||
|
|
||||||
| 变量名 | 说明 | 默认值 |
|
| 变量名 | 说明 | 默认值 |
|
||||||
|--------|------|--------|
|
|--------|------|--------|
|
||||||
| `OPENAI_TTS_API_KEY` | OpenAI 兼容接口的 API Key(逗号分隔支持多个) | 无(不鉴权) |
|
| `OPENAI_TTS_API_KEY` | 🔴 **公网必设** OpenAI 兼容接口的 API Key(逗号分隔支持多个);**未设置时鉴权完全关闭** | 无(不鉴权) |
|
||||||
| `TRUSTED_PROXY_HOPS` | X-Forwarded-For 解析模式(0=启发式/默认,>0=精确 N 跳) | `0`(启发式) |
|
| `TRUSTED_PROXY_HOPS` | X-Forwarded-For 解析模式(0=启发式/默认,>0=精确 N 跳) | `0`(启发式) |
|
||||||
| `PORT` | 服务监听端口 | `8080` |
|
| `PORT` | 服务监听端口 | `8080` |
|
||||||
| `ALLOWED_ORIGINS` | CORS 跨域白名单(逗号分隔,调试可设 `*`;空则拒绝所有跨域) | 无 |
|
| `ALLOWED_ORIGINS` | CORS 跨域白名单(逗号分隔,调试可设 `*`;空则拒绝所有跨域) | 无 |
|
||||||
@@ -243,6 +243,8 @@ CORS拦截: 来源="https://..." 路径=/v1/audio/speech 方法=POST 客户端=.
|
|||||||
|
|
||||||
## API 使用说明
|
## API 使用说明
|
||||||
|
|
||||||
|
> ⚠️ **公网部署前必读**:如果你的服务暴露在公网,**必须**设置 `OPENAI_TTS_API_KEY` 或由前置反代(nginx / caddy)承担鉴权。未设置时 `Authorization` 头完全跳过校验,任何能访问 `:8080` 的人都能调用 TTS 合成,消耗你的火山额度。详见[部署 → 公网安全清单](#公网部署安全清单)。
|
||||||
|
|
||||||
### OpenAI 兼容接口
|
### OpenAI 兼容接口
|
||||||
|
|
||||||
**端点:** `POST /v1/audio/speech`
|
**端点:** `POST /v1/audio/speech`
|
||||||
@@ -268,7 +270,7 @@ CORS拦截: 来源="https://..." 路径=/v1/audio/speech 方法=POST 客户端=.
|
|||||||
- `input` — 要合成的文本
|
- `input` — 要合成的文本
|
||||||
- `voice` — 发音人(OpenAI 兼容,实际用 `BYTEDANCE_TTS_SPEAKER`)
|
- `voice` — 发音人(OpenAI 兼容,实际用 `BYTEDANCE_TTS_SPEAKER`)
|
||||||
- `response_format` — 输出格式:`mp3`(默认)/ `opus`(映射 ogg_opus)/ `wav` / `pcm` / `aac` / `flac`(降级到 mp3)
|
- `response_format` — 输出格式:`mp3`(默认)/ `opus`(映射 ogg_opus)/ `wav` / `pcm` / `aac` / `flac`(降级到 mp3)
|
||||||
- `speed` — 语速,0.25 ~ 4.0(火山侧转换为 speech_rate [-50, 100])
|
- `speed` — 语速倍率,客户端接受范围 0.25 ~ 4.0;**火山实际生效范围 0.5 ~ 2.0**(speech_rate [-50, 100]),超出范围会被静默截断,客户端无感反馈
|
||||||
|
|
||||||
**格式映射:**
|
**格式映射:**
|
||||||
|
|
||||||
@@ -358,6 +360,40 @@ scrape_configs:
|
|||||||
|
|
||||||
`/dashboard` 展示服务状态 + 内存 + 配置信息,并内嵌 `/metrics` 预览;Grafana 等工具可直接基于上面指标做面板。
|
`/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;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **最简方案**:反代层直接限制 `/metrics` 只能从 Prometheus 服务器 IP 访问,无需 basic auth
|
||||||
|
|
||||||
## 架构
|
## 架构
|
||||||
|
|
||||||
| 包 | 职责 |
|
| 包 | 职责 |
|
||||||
@@ -410,6 +446,34 @@ docker compose up -d
|
|||||||
|
|
||||||
环境变量通过 `.env` 或 `docker-compose.yml` 传入。
|
环境变量通过 `.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. `code=55000000, message=resource ID is mismatched with speaker related resource`
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/volcano-tts/tts-api/common"
|
"github.com/volcano-tts/tts-api/common"
|
||||||
@@ -94,10 +95,13 @@ func Synthesis(
|
|||||||
|
|
||||||
if resp.StatusCode != 200 {
|
if resp.StatusCode != 200 {
|
||||||
rawBody := ReadErrorBody(resp.Body)
|
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)
|
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{
|
return nil, &UpstreamError{
|
||||||
Code: resp.StatusCode,
|
Code: resp.StatusCode,
|
||||||
Message: fmt.Sprintf("upstream http %d: %s", resp.StatusCode, rawBody),
|
Message: fmt.Sprintf("upstream http %d: %s", resp.StatusCode, safeBody),
|
||||||
Stage: "http",
|
Stage: "http",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ const (
|
|||||||
MaxRequestBodySize = 1024 * 1024
|
MaxRequestBodySize = 1024 * 1024
|
||||||
RateLimitRequests = 100
|
RateLimitRequests = 100
|
||||||
RateLimitWindow = time.Minute
|
RateLimitWindow = time.Minute
|
||||||
MaxResponseTimes = 100
|
|
||||||
MaxErrors = 10
|
|
||||||
MaxConcurrentRequests = 10
|
MaxConcurrentRequests = 10
|
||||||
CleanupInterval = time.Hour
|
CleanupInterval = time.Hour
|
||||||
MaxModelNameLength = 64
|
MaxModelNameLength = 64
|
||||||
|
|||||||
+3
-4
@@ -18,6 +18,7 @@ import (
|
|||||||
"github.com/volcano-tts/tts-api/middleware"
|
"github.com/volcano-tts/tts-api/middleware"
|
||||||
"github.com/volcano-tts/tts-api/setting"
|
"github.com/volcano-tts/tts-api/setting"
|
||||||
"github.com/volcano-tts/tts-api/telemetry"
|
"github.com/volcano-tts/tts-api/telemetry"
|
||||||
|
"github.com/volcano-tts/tts-api/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -46,9 +47,6 @@ func resolveClientFormat(reqFmt string) string {
|
|||||||
}
|
}
|
||||||
return strings.ToLower(reqFmt)
|
return strings.ToLower(reqFmt)
|
||||||
}
|
}
|
||||||
if reqFmt == "" {
|
|
||||||
return setting.TTSOptions.Format
|
|
||||||
}
|
|
||||||
return setting.TTSOptions.Format
|
return setting.TTSOptions.Format
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,7 +230,8 @@ func HealthHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
resp := dto.HealthResponse{
|
resp := dto.HealthResponse{
|
||||||
Status: status,
|
Status: status,
|
||||||
Service: "ByteDance TTS to OpenAI API Adapter",
|
Service: "ByteDance TTS to OpenAI API Adapter",
|
||||||
Version: "2.0.0 (v3 API)",
|
Version: version.Version,
|
||||||
|
Commit: version.Commit,
|
||||||
Uptime: fmt.Sprintf("%.0f seconds", time.Since(startTime).Seconds()),
|
Uptime: fmt.Sprintf("%.0f seconds", time.Since(startTime).Seconds()),
|
||||||
StartTime: startTime.Format(time.RFC3339),
|
StartTime: startTime.Format(time.RFC3339),
|
||||||
Memory: collectMemorySnapshot(),
|
Memory: collectMemorySnapshot(),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ type HealthResponse struct {
|
|||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Service string `json:"service"`
|
Service string `json:"service"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
|
Commit string `json:"commit"`
|
||||||
Uptime string `json:"uptime"`
|
Uptime string `json:"uptime"`
|
||||||
StartTime string `json:"start_time"`
|
StartTime string `json:"start_time"`
|
||||||
Memory map[string]interface{} `json:"memory"`
|
Memory map[string]interface{} `json:"memory"`
|
||||||
|
|||||||
@@ -58,15 +58,6 @@ type V3Usage struct {
|
|||||||
TextWords int `json:"text_words"`
|
TextWords int `json:"text_words"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ByteDanceTTSConfig 是 setting 包的全局 TTS 配置,目前只承载鉴权 / URL / 超时;
|
|
||||||
// 完整的合成参数见 adapter/volcano.Options。
|
|
||||||
type ByteDanceTTSConfig struct {
|
|
||||||
ApiKey string
|
|
||||||
ResourceId string
|
|
||||||
URL string
|
|
||||||
Timeout time.Duration
|
|
||||||
}
|
|
||||||
|
|
||||||
// SynthesisResult 是火山适配器向 controller 返回的最终结果。
|
// SynthesisResult 是火山适配器向 controller 返回的最终结果。
|
||||||
// Format 与 AudioData 的实际编码一致;controller 据此设置响应 Content-Type。
|
// Format 与 AudioData 的实际编码一致;controller 据此设置响应 Content-Type。
|
||||||
type SynthesisResult struct {
|
type SynthesisResult struct {
|
||||||
|
|||||||
+4
-1
@@ -131,7 +131,10 @@ func (AdapterRecorder) UpstreamFinished(speaker, model, format, status string, d
|
|||||||
if audioBytes > 0 {
|
if audioBytes > 0 {
|
||||||
UpstreamBytes.Add(float64(audioBytes), telemetry.Labels{"format": format})
|
UpstreamBytes.Add(float64(audioBytes), telemetry.Labels{"format": format})
|
||||||
}
|
}
|
||||||
if errCode != 0 {
|
// 上游调用只要 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)})
|
UpstreamErrors.Inc(telemetry.Labels{"code": codeLabel(errCode)})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,13 +9,6 @@ import (
|
|||||||
"github.com/volcano-tts/tts-api/setting"
|
"github.com/volcano-tts/tts-api/setting"
|
||||||
)
|
)
|
||||||
|
|
||||||
// InitAPIKeys 已在 setting.InitAuthConfig 中完成,这里保留为 no-op 以维持现有调用顺序。
|
|
||||||
// 实际鉴权逻辑直接读 setting.Auth.APIKeys。
|
|
||||||
func InitAPIKeys() {
|
|
||||||
// 配置由 setting 包统一加载,日志也由 setting.LogStartupSummary 输出。
|
|
||||||
_ = setting.Auth
|
|
||||||
}
|
|
||||||
|
|
||||||
func ValidateAPIKey(r *http.Request) bool {
|
func ValidateAPIKey(r *http.Request) bool {
|
||||||
if len(setting.Auth.APIKeys) == 0 {
|
if len(setting.Auth.APIKeys) == 0 {
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -13,13 +13,6 @@ var (
|
|||||||
corsMaxAgeHeader = "86400"
|
corsMaxAgeHeader = "86400"
|
||||||
)
|
)
|
||||||
|
|
||||||
// InitCORSConfig 已在 setting.InitCORSConfig 中完成,这里保留为 no-op 以维持现有调用顺序。
|
|
||||||
// 实际 CORS 匹配逻辑直接读 setting.CORS.Origins / setting.CORS.AllowAll。
|
|
||||||
func InitCORSConfig() {
|
|
||||||
// 配置由 setting 包统一加载,日志也由 setting.LogStartupSummary 输出。
|
|
||||||
_ = setting.CORS
|
|
||||||
}
|
|
||||||
|
|
||||||
func isValidOrigin(origin string) bool {
|
func isValidOrigin(origin string) bool {
|
||||||
if origin == "" || origin == "null" || origin == "nil" {
|
if origin == "" || origin == "null" || origin == "nil" {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package middleware
|
|||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -23,6 +24,9 @@ func Logger(next http.Handler) http.Handler {
|
|||||||
next.ServeHTTP(rec, r)
|
next.ServeHTTP(rec, r)
|
||||||
duration := time.Since(start)
|
duration := time.Since(start)
|
||||||
|
|
||||||
log.Printf("%s %s %s %d %v", r.Method, r.RequestURI, r.RemoteAddr, rec.statusCode, duration)
|
// 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)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
// 本文件提供带 metrics 埋点的限流 / 并发中间件版本;
|
// 本文件提供带 metrics 埋点的限流 / 并发中间件版本。
|
||||||
// 由于原 ratelimit_middleware.go 在本仓库的云盘同步下被永久占用,
|
// 相比 router 实际使用的实现,本版本额外做了:
|
||||||
// 这里用独立实现覆盖路由使用入口,旧实现保留为未引用代码。
|
// - 加 metrics 埋点(限流拒绝 / 并发拒绝计数)
|
||||||
//
|
// - 仅对 /v1/ 下的业务请求生效,监控路径(/health /metrics /dashboard)不消耗配额
|
||||||
// 行为与原 ratelimit_middleware.go 完全一致,只是多了 metrics 调用。
|
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
@@ -14,7 +13,7 @@ import (
|
|||||||
"github.com/volcano-tts/tts-api/metrics"
|
"github.com/volcano-tts/tts-api/metrics"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RateLimitWithMetrics 是 middleware.RateLimit 的可埋点版本。
|
// RateLimitWithMetrics 是限流中间件,带埋点 + 路径过滤。
|
||||||
func RateLimitWithMetrics(next http.Handler) http.Handler {
|
func RateLimitWithMetrics(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
// 仅对 /v1/ 下的业务请求限流,/health /metrics /dashboard 等监控路径不限流
|
// 仅对 /v1/ 下的业务请求限流,/health /metrics /dashboard 等监控路径不限流
|
||||||
@@ -32,7 +31,7 @@ func RateLimitWithMetrics(next http.Handler) http.Handler {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConcurrencyLimitWithMetrics 是 middleware.ConcurrencyLimit 的可埋点版本。
|
// ConcurrencyLimitWithMetrics 是并发控制中间件,带埋点 + 路径过滤。
|
||||||
func ConcurrencyLimitWithMetrics(next http.Handler) http.Handler {
|
func ConcurrencyLimitWithMetrics(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
// 仅对 /v1/ 下的业务请求统计并发和加锁,监控路径不占用并发槽位
|
// 仅对 /v1/ 下的业务请求统计并发和加锁,监控路径不占用并发槽位
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
package middleware
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
func RateLimit(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func ConcurrencyLimit(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
select {
|
|
||||||
case ConcurrencySem <- struct{}{}:
|
|
||||||
defer func() { <-ConcurrencySem }()
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
default:
|
|
||||||
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
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,6 @@ import (
|
|||||||
|
|
||||||
"github.com/volcano-tts/tts-api/adapter/volcano"
|
"github.com/volcano-tts/tts-api/adapter/volcano"
|
||||||
"github.com/volcano-tts/tts-api/common"
|
"github.com/volcano-tts/tts-api/common"
|
||||||
"github.com/volcano-tts/tts-api/dto"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// 全部环境变量读取的单一入口:其它包不允许直接 os.Getenv,只读这里的全局 Config。
|
// 全部环境变量读取的单一入口:其它包不允许直接 os.Getenv,只读这里的全局 Config。
|
||||||
@@ -317,7 +316,3 @@ func CheckStaticFiles() {
|
|||||||
log.Println("警告: health.html 不存在,/dashboard 路由将返回 404")
|
log.Println("警告: health.html 不存在,/dashboard 路由将返回 404")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保留 dto.ByteDanceTTSConfig 引用避免 import 警告;
|
|
||||||
// 新代码不应再使用这个类型,设置已在 TTSOptions 中。
|
|
||||||
var _ = dto.ByteDanceTTSConfig{}
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user