Compare commits
10
Commits
v0.1.0
...
b92973cdc9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b92973cdc9 | ||
|
|
be6c2ad34e | ||
|
|
23b962a90e | ||
|
|
9b2a1d1531 | ||
|
|
9c35f780db | ||
|
|
7bedb222d1 | ||
|
|
4e1820d45b | ||
|
|
1b84a6c9ee | ||
|
|
4c93638250 | ||
|
|
45591a4e3a |
@@ -0,0 +1,9 @@
|
||||
*.exe
|
||||
*.md
|
||||
.env
|
||||
.env.example
|
||||
.git
|
||||
.gitignore
|
||||
tts_api_architecture.html
|
||||
代码审查报告.md
|
||||
fix_list.md
|
||||
+4
-12
@@ -9,19 +9,9 @@
|
||||
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音色只能搭配 seed-tts-1.0 Resource ID
|
||||
# 2.0音色只能搭配 seed-tts-2.0 Resource ID
|
||||
# 发音人(音色)ID
|
||||
BYTEDANCE_TTS_SPEAKER=your_speaker_id_here
|
||||
|
||||
# ==========================================
|
||||
@@ -32,8 +22,10 @@ BYTEDANCE_TTS_SPEAKER=your_speaker_id_here
|
||||
BYTEDANCE_TTS_TIMEOUT=30s
|
||||
|
||||
# OpenAI兼容接口的API密钥(可选)
|
||||
# 配置后,客户端请求需要携带 Authorization: Bearer <OPENAI_TTS_API_KEY>
|
||||
OPENAI_TTS_API_KEY=your_openai_compatible_key_here
|
||||
|
||||
# CORS 跨域白名单(逗号分隔,开发环境可设 *)
|
||||
# ALLOWED_ORIGINS=https://example.com,https://app.example.com
|
||||
|
||||
# 服务监听端口,默认8080
|
||||
PORT=8080
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
FROM golang:1.23-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -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"]
|
||||
@@ -0,0 +1,167 @@
|
||||
package volcano
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/volcano-tts/tts-api/common"
|
||||
"github.com/volcano-tts/tts-api/dto"
|
||||
)
|
||||
|
||||
type HTTPClient struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewHTTPClient() *HTTPClient {
|
||||
return &HTTPClient{
|
||||
client: &http.Client{
|
||||
Timeout: common.DefaultTimeout,
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConns: 100,
|
||||
MaxIdleConnsPerHost: 20,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (h *HTTPClient) PostStream(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 h.client.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)
|
||||
}
|
||||
|
||||
func Synthesis(config *dto.ByteDanceTTSConfig, httpClient *HTTPClient, text string, speed float64) (*dto.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": config.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": config.ResourceId,
|
||||
"X-Api-Request-Id": reqID,
|
||||
"X-Api-Key": config.ApiKey,
|
||||
}
|
||||
|
||||
bodyStr, err := json.Marshal(params)
|
||||
if err != nil {
|
||||
log.Printf("JSON marshal fail: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := httpClient.PostStream(config.URL, headers, bodyStr, config.Timeout)
|
||||
if err != nil {
|
||||
log.Printf("http post fail: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
log.Printf("Failed to read error response body: %v", err)
|
||||
} else {
|
||||
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), 8*1024*1024)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
var v3Resp dto.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 &dto.SynthesisResult{AudioData: audioData, ReqID: reqID}, nil
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package common
|
||||
|
||||
import "time"
|
||||
|
||||
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
|
||||
MaxResponseTimes = 100
|
||||
MaxErrors = 10
|
||||
MaxConcurrentRequests = 10
|
||||
CleanupInterval = time.Hour
|
||||
MaxModelNameLength = 64
|
||||
MaxRateLimiterEntries = 100000
|
||||
)
|
||||
@@ -0,0 +1,185 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"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/middleware"
|
||||
"github.com/volcano-tts/tts-api/service"
|
||||
"github.com/volcano-tts/tts-api/setting"
|
||||
)
|
||||
|
||||
var volcanoClient *volcano.HTTPClient
|
||||
|
||||
func InitController() {
|
||||
volcanoClient = volcano.NewHTTPClient()
|
||||
}
|
||||
|
||||
func OpenaiTTSHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
if !middleware.ValidateAPIKey(r) {
|
||||
middleware.SendJSONError(w, http.StatusUnauthorized, "Invalid API key provided.", "invalid_request_error", "invalid_api_key")
|
||||
return
|
||||
}
|
||||
|
||||
if setting.TTSConfigErr != nil {
|
||||
middleware.SendJSONError(w, http.StatusServiceUnavailable, "TTS service configuration error. Please check environment variables and restart the service.", "configuration_error", "service_unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
select {
|
||||
case middleware.ConcurrencySem <- struct{}{}:
|
||||
defer func() { <-middleware.ConcurrencySem }()
|
||||
default:
|
||||
log.Printf("警告: 已达到最大并发请求数限制,拒绝请求 - 客户端IP: %s", middleware.GetClientIP(r))
|
||||
middleware.SendJSONError(w, http.StatusServiceUnavailable, "Server is busy, maximum concurrent requests reached. Please try again later.", "concurrency_limit_error", "max_concurrent_requests")
|
||||
return
|
||||
}
|
||||
|
||||
clientIP := middleware.GetClientIP(r)
|
||||
if !middleware.GlobalRateLimiter.Allow(clientIP) {
|
||||
log.Printf("警告: 已超过IP速率限制,拒绝请求 - 客户端IP: %s", clientIP)
|
||||
middleware.SendJSONError(w, http.StatusTooManyRequests, "Rate limit exceeded. Please try again later.", "rate_limit_error", "rate_limit_exceeded")
|
||||
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") {
|
||||
return
|
||||
}
|
||||
http.Error(w, "Failed to read request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var req dto.OpenAITTSRequest
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
http.Error(w, "Invalid JSON", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Model != "" {
|
||||
if 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") {
|
||||
http.Error(w, "Model name contains invalid characters", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if req.Input == "" {
|
||||
http.Error(w, "Input text is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if 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
|
||||
}
|
||||
|
||||
ttsStart := time.Now()
|
||||
result, err := volcano.Synthesis(&setting.TTSConfig, volcanoClient, req.Input, speed)
|
||||
duration := time.Since(ttsStart)
|
||||
|
||||
if err != nil {
|
||||
service.GlobalStats.AddRequest(false, duration, err.Error())
|
||||
http.Error(w, "TTS synthesis failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
service.GlobalStats.AddRequest(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 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)
|
||||
}
|
||||
|
||||
totalRequests, successfulRequests, failedRequests, totalResponseTime, recentResponseTimes, lastErrors := service.GlobalStats.GetSnapshot()
|
||||
|
||||
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 := setting.CheckEnvironmentVariables()
|
||||
allEnvVarsSet := envCheckStatus["all_required_vars_set"].(bool)
|
||||
|
||||
status := "ok"
|
||||
if !allEnvVarsSet {
|
||||
status = "configuration_error"
|
||||
}
|
||||
|
||||
response := dto.HealthResponse{
|
||||
Status: status,
|
||||
Service: "ByteDance TTS to OpenAI API Adapter",
|
||||
Version: "2.0.0 (v3 API)",
|
||||
Uptime: fmt.Sprintf("%.0f seconds", time.Since(startTime).Seconds()),
|
||||
StartTime: startTime.Format(time.RFC3339),
|
||||
Memory: service.GetMemoryInfo(),
|
||||
APIStats: dto.APIStatsResponse{
|
||||
TotalRequests: int(totalRequests),
|
||||
SuccessfulRequests: successfulRequests,
|
||||
FailedRequests: failedRequests,
|
||||
ErrorRatePercent: fmt.Sprintf("%.2f", errorRate),
|
||||
AvgResponseTimeMs: fmt.Sprintf("%.2f", avgResponseTime),
|
||||
RecentResponseTimesMs: recentResponseTimes,
|
||||
},
|
||||
Errors: dto.ErrorResponse{
|
||||
RecentErrorsCount: len(lastErrors),
|
||||
},
|
||||
ConfigStatus: dto.ConfigStatusResponse{
|
||||
AllRequiredVarsSet: allEnvVarsSet,
|
||||
ConfigError: setting.TTSConfigErr != nil,
|
||||
},
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
var startTime time.Time
|
||||
|
||||
func SetStartTime(t time.Time) {
|
||||
startTime = t
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
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
|
||||
@@ -0,0 +1,31 @@
|
||||
package dto
|
||||
|
||||
type HealthResponse struct {
|
||||
Status string `json:"status"`
|
||||
Service string `json:"service"`
|
||||
Version string `json:"version"`
|
||||
Uptime string `json:"uptime"`
|
||||
StartTime string `json:"start_time"`
|
||||
Memory map[string]interface{} `json:"memory"`
|
||||
APIStats APIStatsResponse `json:"api_stats"`
|
||||
Errors ErrorResponse `json:"errors"`
|
||||
ConfigStatus ConfigStatusResponse `json:"config_status"`
|
||||
}
|
||||
|
||||
type APIStatsResponse struct {
|
||||
TotalRequests int `json:"total_requests"`
|
||||
SuccessfulRequests int64 `json:"successful_requests"`
|
||||
FailedRequests int64 `json:"failed_requests"`
|
||||
ErrorRatePercent string `json:"error_rate_percent"`
|
||||
AvgResponseTimeMs string `json:"avg_response_time_ms"`
|
||||
RecentResponseTimesMs []float64 `json:"recent_response_times_ms"`
|
||||
}
|
||||
|
||||
type ErrorResponse struct {
|
||||
RecentErrorsCount int `json:"recent_errors_count"`
|
||||
}
|
||||
|
||||
type ConfigStatusResponse struct {
|
||||
AllRequiredVarsSet bool `json:"all_required_vars_set"`
|
||||
ConfigError bool `json:"config_error"`
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package dto
|
||||
|
||||
import "time"
|
||||
|
||||
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 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 *V3Usage `json:"usage,omitempty"`
|
||||
}
|
||||
|
||||
type V3Usage struct {
|
||||
TextWords int `json:"text_words"`
|
||||
}
|
||||
|
||||
type ByteDanceTTSConfig struct {
|
||||
ApiKey string
|
||||
ResourceId string
|
||||
Speaker string
|
||||
URL string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
type SynthesisResult struct {
|
||||
AudioData []byte
|
||||
ReqID string
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
module bytedance-tts-openai-adapter
|
||||
module github.com/volcano-tts/tts-api
|
||||
|
||||
go 1.19
|
||||
|
||||
|
||||
+492
@@ -0,0 +1,492 @@
|
||||
<!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://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
|
||||
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||
min-height: 100vh;
|
||||
color: #e0e0e0;
|
||||
padding: 20px;
|
||||
}
|
||||
#app {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.header h1 {
|
||||
font-size: 2em;
|
||||
background: linear-gradient(90deg, #00d4ff, #7b2ff7);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.header .version {
|
||||
color: #888;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.refresh-btn {
|
||||
background: linear-gradient(90deg, #00d4ff, #7b2ff7);
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 10px 24px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
margin-top: 15px;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
.refresh-btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.refresh-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.card {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.card-title {
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.card-title .dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #00d4ff;
|
||||
}
|
||||
.card-title .dot.error {
|
||||
background: #ff4757;
|
||||
}
|
||||
.card-title .dot.warning {
|
||||
background: #ffa502;
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 2.5em;
|
||||
font-weight: bold;
|
||||
background: linear-gradient(90deg, #00d4ff, #7b2ff7);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.stat-label {
|
||||
color: #888;
|
||||
font-size: 14px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.info-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.info-label {
|
||||
color: #888;
|
||||
}
|
||||
.info-value {
|
||||
color: #fff;
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
}
|
||||
.info-value.success {
|
||||
color: #2ed573;
|
||||
}
|
||||
.info-value.error {
|
||||
color: #ff4757;
|
||||
}
|
||||
.info-value.warning {
|
||||
color: #ffa502;
|
||||
}
|
||||
.chart-container {
|
||||
height: 120px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
padding: 10px 0;
|
||||
}
|
||||
.bar {
|
||||
flex: 1;
|
||||
background: linear-gradient(180deg, #7b2ff7, #00d4ff);
|
||||
border-radius: 4px 4px 0 0;
|
||||
min-height: 2px;
|
||||
transition: height 0.3s ease;
|
||||
}
|
||||
.error-list {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.error-item {
|
||||
background: rgba(255, 71, 87, 0.1);
|
||||
border-left: 3px solid #ff4757;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 8px;
|
||||
border-radius: 0 8px 8px 0;
|
||||
font-size: 13px;
|
||||
word-break: break-all;
|
||||
}
|
||||
.error-time {
|
||||
color: #888;
|
||||
font-size: 12px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #888;
|
||||
}
|
||||
.error-box {
|
||||
background: rgba(255, 71, 87, 0.1);
|
||||
border: 1px solid rgba(255, 71, 87, 0.3);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
color: #ff4757;
|
||||
text-align: center;
|
||||
transition: all 0.3s ease;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(-10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.uptime {
|
||||
font-size: 1.5em;
|
||||
font-weight: bold;
|
||||
color: #2ed573;
|
||||
}
|
||||
.progress-ring {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.progress-ring circle {
|
||||
fill: none;
|
||||
stroke-width: 8;
|
||||
}
|
||||
.progress-ring .bg {
|
||||
stroke: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.progress-ring .progress {
|
||||
stroke: url(#gradient);
|
||||
stroke-linecap: round;
|
||||
transform: rotate(-90deg);
|
||||
transform-origin: center;
|
||||
transition: stroke-dashoffset 0.5s ease;
|
||||
}
|
||||
.progress-text {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
text-align: center;
|
||||
}
|
||||
.memory-stat {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
text-align: center;
|
||||
}
|
||||
.memory-stat .value {
|
||||
font-size: 1.2em;
|
||||
font-weight: bold;
|
||||
color: #00d4ff;
|
||||
}
|
||||
.memory-stat .label {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.no-errors {
|
||||
text-align: center;
|
||||
color: #2ed573;
|
||||
padding: 20px;
|
||||
}
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 3px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div class="header">
|
||||
<h1>TTS 服务监控</h1>
|
||||
<div class="version">{{ healthData.service || 'Loading...' }} - {{ healthData.version || '' }}</div>
|
||||
<button class="refresh-btn" @click="fetchHealth(true)" :disabled="loading">
|
||||
{{ loading ? '刷新中...' : '刷新数据' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-box">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div v-if="healthData.status">
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<div class="card-title">
|
||||
<span class="dot" :class="{ error: healthData.status !== 'ok' }"></span>
|
||||
服务状态
|
||||
</div>
|
||||
<div class="stat-value">{{ healthData.status === 'ok' ? '正常运行' : '配置错误' }}</div>
|
||||
<div class="stat-label">运行时长: {{ formatUptime(healthData.uptime) }}</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-title">
|
||||
<span class="dot"></span>
|
||||
请求统计
|
||||
</div>
|
||||
<div style="display: flex; gap: 30px;">
|
||||
<div>
|
||||
<div class="stat-value">{{ healthData.api_stats?.total_requests || 0 }}</div>
|
||||
<div class="stat-label">总请求数</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="stat-value" style="color: #2ed573">{{ healthData.api_stats?.successful_requests || 0 }}</div>
|
||||
<div class="stat-label">成功</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="stat-value" style="color: #ff4757">{{ healthData.api_stats?.failed_requests || 0 }}</div>
|
||||
<div class="stat-label">失败</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-title">
|
||||
<span class="dot" :class="{ warning: errorRate > 10 }"></span>
|
||||
错误率
|
||||
</div>
|
||||
<div class="stat-value" :style="{ color: errorRate > 10 ? '#ff4757' : '#2ed573' }">
|
||||
{{ healthData.api_stats?.error_rate_percent || '0' }}%
|
||||
</div>
|
||||
<div class="stat-label">平均响应: {{ healthData.api_stats?.avg_response_time_ms || '0' }} ms</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<div class="card-title">
|
||||
<span class="dot"></span>
|
||||
配置状态
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">环境变量</span>
|
||||
<span class="info-value" :class="healthData.config_status?.all_required_vars_set ? 'success' : 'error'">
|
||||
{{ healthData.config_status?.all_required_vars_set ? '已配置' : '未配置' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">配置状态</span>
|
||||
<span class="info-value" :class="healthData.config_status?.config_error ? 'error' : 'success'">
|
||||
{{ healthData.config_status?.config_error ? '异常' : '正常' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">启动时间</span>
|
||||
<span class="info-value">{{ healthData.start_time || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-title">
|
||||
<span class="dot"></span>
|
||||
内存使用
|
||||
</div>
|
||||
<div class="memory-stat">
|
||||
<div>
|
||||
<div class="value">{{ formatBytes(healthData.memory?.heap_alloc) }}</div>
|
||||
<div class="label">Heap Alloc</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="value">{{ formatBytes(healthData.memory?.heap_inuse) }}</div>
|
||||
<div class="label">Heap Inuse</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="value">{{ healthData.memory?.goroutines || 0 }}</div>
|
||||
<div class="label">Goroutines</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-title">
|
||||
<span class="dot"></span>
|
||||
响应时间趋势
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<div v-for="(time, index) in chartData" :key="index" class="bar"
|
||||
:style="{ height: Math.max(2, (time / maxResponseTime) * 100) + '%' }"
|
||||
:title="time.toFixed(1) + 'ms'">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-title">
|
||||
<span class="dot" :class="{ error: recentErrorsCount > 0 }"></span>
|
||||
错误记录 ({{ recentErrorsCount }})
|
||||
</div>
|
||||
<div v-if="recentErrorsCount === 0" class="no-errors">
|
||||
暂无错误记录
|
||||
</div>
|
||||
<div v-else class="error-list">
|
||||
<div class="error-item">
|
||||
<div class="error-time">检测到 {{ recentErrorsCount }} 条最近错误,详细信息请查看服务器日志</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading && !healthData.status" class="loading">
|
||||
加载中...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const { createApp, ref, computed, onMounted } = Vue;
|
||||
|
||||
createApp({
|
||||
setup() {
|
||||
const healthData = ref({});
|
||||
const loading = ref(false);
|
||||
const error = ref(null);
|
||||
const isAutoRefresh = ref(false);
|
||||
|
||||
const deepUpdate = (target, source) => {
|
||||
for (const key of Object.keys(source)) {
|
||||
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
|
||||
if (!target[key]) target[key] = {};
|
||||
deepUpdate(target[key], source[key]);
|
||||
} else {
|
||||
target[key] = source[key];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const fetchHealth = async (showLoading = false) => {
|
||||
if (showLoading) {
|
||||
loading.value = true;
|
||||
}
|
||||
const oldError = error.value;
|
||||
try {
|
||||
const response = await axios.get('/health');
|
||||
deepUpdate(healthData.value, response.data);
|
||||
if (response.data.config_status?.all_required_vars_set !== false) {
|
||||
error.value = null;
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.response && e.response.data) {
|
||||
deepUpdate(healthData.value, e.response.data);
|
||||
if (e.response.status === 503) {
|
||||
error.value = '服务配置异常,请检查环境变量配置';
|
||||
} else {
|
||||
error.value = '服务异常: ' + (e.message || '未知错误');
|
||||
}
|
||||
} else {
|
||||
error.value = '无法获取服务状态: ' + (e.message || '未知错误');
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const errorRate = computed(() => {
|
||||
return parseFloat(healthData.value.api_stats?.error_rate_percent || 0);
|
||||
});
|
||||
|
||||
const recentErrorsCount = computed(() => {
|
||||
return healthData.value.errors?.recent_errors_count || 0;
|
||||
});
|
||||
|
||||
const chartData = computed(() => {
|
||||
return healthData.value.api_stats?.recent_response_times_ms || [];
|
||||
});
|
||||
|
||||
const maxResponseTime = computed(() => {
|
||||
const times = chartData.value;
|
||||
if (times.length === 0) return 100;
|
||||
return Math.max(...times, 100);
|
||||
});
|
||||
|
||||
const formatBytes = (bytes) => {
|
||||
if (!bytes) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return (bytes / Math.pow(k, i)).toFixed(1) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const formatUptime = (seconds) => {
|
||||
if (!seconds) return '-';
|
||||
const s = parseInt(seconds);
|
||||
const d = Math.floor(s / 86400);
|
||||
const h = Math.floor((s % 86400) / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
if (d > 0) return `${d}天 ${h}小时`;
|
||||
if (h > 0) return `${h}小时 ${m}分钟`;
|
||||
return `${m}分钟`;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchHealth(true);
|
||||
setInterval(() => fetchHealth(false), 10000);
|
||||
});
|
||||
|
||||
return {
|
||||
healthData,
|
||||
loading,
|
||||
error,
|
||||
fetchHealth,
|
||||
errorRate,
|
||||
recentErrorsCount,
|
||||
chartData,
|
||||
maxResponseTime,
|
||||
formatBytes,
|
||||
formatUptime
|
||||
};
|
||||
}
|
||||
}).mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,82 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/volcano-tts/tts-api/common"
|
||||
"github.com/volcano-tts/tts-api/controller"
|
||||
"github.com/volcano-tts/tts-api/middleware"
|
||||
"github.com/volcano-tts/tts-api/router"
|
||||
"github.com/volcano-tts/tts-api/service"
|
||||
"github.com/volcano-tts/tts-api/setting"
|
||||
)
|
||||
|
||||
func main() {
|
||||
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
||||
log.SetPrefix("[TTS-Server] ")
|
||||
|
||||
middleware.InitAPIKeys()
|
||||
middleware.InitCORSConfig()
|
||||
middleware.InitRateLimiter()
|
||||
setting.CheckStaticFiles()
|
||||
service.InitStats()
|
||||
controller.InitController()
|
||||
|
||||
setting.TTSConfigErr = setting.InitTTSConfig()
|
||||
if setting.TTSConfigErr != nil {
|
||||
log.Printf("警告: 配置初始化失败: %v", setting.TTSConfigErr)
|
||||
log.Printf("服务将继续运行,但TTS功能不可用,请检查环境变量配置")
|
||||
} else {
|
||||
log.Printf("配置初始化成功")
|
||||
}
|
||||
|
||||
controller.SetStartTime(time.Now())
|
||||
|
||||
r := router.Setup()
|
||||
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
port = common.DefaultPort
|
||||
}
|
||||
|
||||
server := &http.Server{
|
||||
Addr: ":" + port,
|
||||
Handler: 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", 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")
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var validAPIKeys []string
|
||||
|
||||
func InitAPIKeys() {
|
||||
apiKey := os.Getenv("OPENAI_TTS_API_KEY")
|
||||
if apiKey != "" {
|
||||
validAPIKeys = strings.Split(apiKey, ",")
|
||||
for i, k := range validAPIKeys {
|
||||
validAPIKeys[i] = strings.TrimSpace(k)
|
||||
}
|
||||
log.Printf("已配置 %d 个有效的API密钥", len(validAPIKeys))
|
||||
} else {
|
||||
log.Println("警告: OPENAI_TTS_API_KEY 环境变量未设置,将允许所有请求")
|
||||
}
|
||||
}
|
||||
|
||||
func ValidateAPIKey(r *http.Request) bool {
|
||||
if len(validAPIKeys) == 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 validAPIKeys {
|
||||
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,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
allowedOrigins []string
|
||||
allowAllOrigins bool
|
||||
corsMaxAgeHeader = "86400"
|
||||
)
|
||||
|
||||
func normalizeOrigin(origin string) string {
|
||||
origin = strings.TrimSpace(origin)
|
||||
origin = strings.TrimRight(origin, "/")
|
||||
return strings.ToLower(origin)
|
||||
}
|
||||
|
||||
func InitCORSConfig() {
|
||||
origins := os.Getenv("ALLOWED_ORIGINS")
|
||||
if origins == "" {
|
||||
log.Println("警告: ALLOWED_ORIGINS 环境变量未设置")
|
||||
log.Println("出于安全考虑,跨域请求将被拒绝。如需开放跨域请配置 ALLOWED_ORIGINS")
|
||||
log.Println("开发环境可设置 ALLOWED_ORIGINS=* 允许所有来源(不可与凭据共用)")
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.Split(origins, ",")
|
||||
for _, p := range parts {
|
||||
o := strings.TrimSpace(p)
|
||||
if o == "" {
|
||||
continue
|
||||
}
|
||||
if o == "*" {
|
||||
allowAllOrigins = true
|
||||
continue
|
||||
}
|
||||
allowedOrigins = append(allowedOrigins, normalizeOrigin(o))
|
||||
}
|
||||
|
||||
if allowAllOrigins {
|
||||
log.Println("警告: ALLOWED_ORIGINS=*,将允许所有来源跨域请求(不携带凭据)")
|
||||
}
|
||||
if len(allowedOrigins) > 0 {
|
||||
log.Printf("已配置 %d 个允许的跨域来源白名单", len(allowedOrigins))
|
||||
}
|
||||
}
|
||||
|
||||
func isValidOrigin(origin string) bool {
|
||||
if origin == "" || origin == "null" || origin == "nil" {
|
||||
return false
|
||||
}
|
||||
if !strings.HasPrefix(origin, "http://") && !strings.HasPrefix(origin, "https://") {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func matchOrigin(origin string) (string, bool) {
|
||||
if !isValidOrigin(origin) {
|
||||
return "", false
|
||||
}
|
||||
if allowAllOrigins {
|
||||
return "*", true
|
||||
}
|
||||
normalized := normalizeOrigin(origin)
|
||||
for _, allowed := range allowedOrigins {
|
||||
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")
|
||||
|
||||
if origin != "" {
|
||||
allowOrigin, matched := matchOrigin(origin)
|
||||
if matched {
|
||||
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")
|
||||
}
|
||||
vary := w.Header().Get("Vary")
|
||||
if vary == "" {
|
||||
w.Header().Set("Vary", "Origin")
|
||||
} else if !strings.Contains(vary, "Origin") {
|
||||
w.Header().Set("Vary", vary+", Origin")
|
||||
}
|
||||
} else {
|
||||
log.Printf("CORS拦截: 来源=%q 路径=%s 方法=%s 客户端=%s",
|
||||
origin, r.URL.Path, r.Method, GetClientIP(r))
|
||||
}
|
||||
}
|
||||
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"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) {
|
||||
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)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/volcano-tts/tts-api/common"
|
||||
)
|
||||
|
||||
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{}
|
||||
)
|
||||
|
||||
func InitRateLimiter() {
|
||||
GlobalRateLimiter = &RateLimiter{
|
||||
requests: make(map[string][]time.Time),
|
||||
limit: common.RateLimitRequests,
|
||||
window: common.RateLimitWindow,
|
||||
}
|
||||
ConcurrencySem = make(chan struct{}, common.MaxConcurrentRequests)
|
||||
}
|
||||
|
||||
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
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func GetClientIP(r *http.Request) string {
|
||||
xForwardedFor := r.Header.Get("X-Forwarded-For")
|
||||
if xForwardedFor != "" {
|
||||
ips := strings.Split(xForwardedFor, ",")
|
||||
if len(ips) > 0 {
|
||||
ip := strings.TrimSpace(ips[0])
|
||||
if ip != "" {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
xRealIP := strings.TrimSpace(r.Header.Get("X-Real-IP"))
|
||||
if xRealIP != "" {
|
||||
return xRealIP
|
||||
}
|
||||
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
return host
|
||||
}
|
||||
@@ -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" {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/volcano-tts/tts-api/controller"
|
||||
"github.com/volcano-tts/tts-api/middleware"
|
||||
)
|
||||
|
||||
func Setup() *mux.Router {
|
||||
r := mux.NewRouter()
|
||||
|
||||
r.Use(middleware.CORS)
|
||||
r.Use(middleware.SecurityHeaders)
|
||||
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")
|
||||
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/volcano-tts/tts-api/common"
|
||||
)
|
||||
|
||||
type Stats struct {
|
||||
totalRequests int64
|
||||
successfulRequests int64
|
||||
failedRequests int64
|
||||
totalResponseTime time.Duration
|
||||
recentResponseTimes []float64
|
||||
responseTimesIndex int
|
||||
lastErrors []string
|
||||
errorsIndex int
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
var GlobalStats *Stats
|
||||
|
||||
func InitStats() {
|
||||
GlobalStats = &Stats{
|
||||
recentResponseTimes: make([]float64, common.MaxResponseTimes),
|
||||
lastErrors: make([]string, common.MaxErrors),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Stats) AddRequest(success bool, responseTime time.Duration, errMsg string) {
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
|
||||
s.totalRequests++
|
||||
s.totalResponseTime += responseTime
|
||||
|
||||
s.recentResponseTimes[s.responseTimesIndex] = responseTime.Seconds() * 1000
|
||||
s.responseTimesIndex = (s.responseTimesIndex + 1) % common.MaxResponseTimes
|
||||
|
||||
if success {
|
||||
s.successfulRequests++
|
||||
} else {
|
||||
s.failedRequests++
|
||||
if errMsg != "" {
|
||||
errInfo := fmt.Sprintf("%s: %s", time.Now().Format(time.RFC3339), errMsg)
|
||||
s.lastErrors[s.errorsIndex] = errInfo
|
||||
s.errorsIndex = (s.errorsIndex + 1) % common.MaxErrors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Stats) GetSnapshot() (totalRequests int64, successfulRequests int64, failedRequests int64,
|
||||
totalResponseTime time.Duration, recentResponseTimes []float64, lastErrors []string) {
|
||||
s.mutex.RLock()
|
||||
defer s.mutex.RUnlock()
|
||||
|
||||
totalRequests = s.totalRequests
|
||||
successfulRequests = s.successfulRequests
|
||||
failedRequests = s.failedRequests
|
||||
totalResponseTime = s.totalResponseTime
|
||||
|
||||
recentResponseTimes = make([]float64, 0, common.MaxResponseTimes)
|
||||
for _, t := range s.recentResponseTimes {
|
||||
if t > 0 {
|
||||
recentResponseTimes = append(recentResponseTimes, t)
|
||||
}
|
||||
}
|
||||
|
||||
lastErrors = make([]string, 0, common.MaxErrors)
|
||||
for _, e := range s.lastErrors {
|
||||
if e != "" {
|
||||
lastErrors = append(lastErrors, e)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package setting
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/volcano-tts/tts-api/common"
|
||||
"github.com/volcano-tts/tts-api/dto"
|
||||
)
|
||||
|
||||
var (
|
||||
TTSConfig dto.ByteDanceTTSConfig
|
||||
TTSConfigErr error
|
||||
)
|
||||
|
||||
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 := common.DefaultTimeout
|
||||
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 = dto.ByteDanceTTSConfig{
|
||||
ApiKey: apiKey,
|
||||
ResourceId: resourceId,
|
||||
Speaker: speaker,
|
||||
URL: url,
|
||||
Timeout: timeout,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
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 CheckStaticFiles() {
|
||||
if _, err := os.Stat("health.html"); os.IsNotExist(err) {
|
||||
log.Println("警告: health.html 不存在,/dashboard 路由将返回 404")
|
||||
}
|
||||
}
|
||||
-778
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user