refactor(health dashboard & tts server): 优化健康页面展示与服务配置

- 重构健康页面配置状态展示,替换原有资源ID和发音人字段
- 简化错误记录展示逻辑,改为统计展示错误数量并提示查看日志
- 调整HTTP客户端连接池配置与请求体缓冲区大小
- 重构速率限制逻辑位置,优化请求处理流程
- 重写CORS配置,支持环境变量白名单和通配符模式
- 增加静态文件检查与更严格的跨域校验
- 调整服务器超时配置,适配更长时间的请求处理
This commit is contained in:
sun
2026-05-22 23:32:10 +08:00
parent 4c93638250
commit 1b84a6c9ee
2 changed files with 128 additions and 64 deletions
+14 -16
View File
@@ -316,12 +316,10 @@
</span> </span>
</div> </div>
<div class="info-row"> <div class="info-row">
<span class="info-label">Resource ID</span> <span class="info-label">配置状态</span>
<span class="info-value">{{ healthData.config_status?.resource_id || '-' }}</span> <span class="info-value" :class="healthData.config_status?.config_error ? 'error' : 'success'">
</div> {{ healthData.config_status?.config_error ? '异常' : '正常' }}
<div class="info-row"> </span>
<span class="info-label">发音人</span>
<span class="info-value">{{ healthData.config_status?.speaker || '-' }}</span>
</div> </div>
<div class="info-row"> <div class="info-row">
<span class="info-label">启动时间</span> <span class="info-label">启动时间</span>
@@ -366,15 +364,15 @@
<div class="card"> <div class="card">
<div class="card-title"> <div class="card-title">
<span class="dot" :class="{ error: recentErrors.length > 0 }"></span> <span class="dot" :class="{ error: recentErrorsCount > 0 }"></span>
错误记录 ({{ recentErrors.length }}) 错误记录 ({{ recentErrorsCount }})
</div> </div>
<div v-if="recentErrors.length === 0" class="no-errors"> <div v-if="recentErrorsCount === 0" class="no-errors">
暂无错误记录 暂无错误记录
</div> </div>
<div v-else class="error-list"> <div v-else class="error-list">
<div v-for="(err, index) in recentErrors" :key="index" class="error-item"> <div class="error-item">
<div class="error-time">{{ err }}</div> <div class="error-time">检测到 {{ recentErrorsCount }} 条最近错误,详细信息请查看服务器日志</div>
</div> </div>
</div> </div>
</div> </div>
@@ -420,8 +418,8 @@
} catch (e) { } catch (e) {
if (e.response && e.response.data) { if (e.response && e.response.data) {
deepUpdate(healthData.value, e.response.data); deepUpdate(healthData.value, e.response.data);
if (e.response.status === 503 && e.response.data.config_status?.config_error_message) { if (e.response.status === 503) {
error.value = e.response.data.config_status.config_error_message; error.value = '服务配置异常,请检查环境变量配置';
} else { } else {
error.value = '服务异常: ' + (e.message || '未知错误'); error.value = '服务异常: ' + (e.message || '未知错误');
} }
@@ -437,8 +435,8 @@
return parseFloat(healthData.value.api_stats?.error_rate_percent || 0); return parseFloat(healthData.value.api_stats?.error_rate_percent || 0);
}); });
const recentErrors = computed(() => { const recentErrorsCount = computed(() => {
return healthData.value.errors?.recent_errors || []; return healthData.value.errors?.recent_errors_count || 0;
}); });
const chartData = computed(() => { const chartData = computed(() => {
@@ -481,7 +479,7 @@
error, error,
fetchHealth, fetchHealth,
errorRate, errorRate,
recentErrors, recentErrorsCount,
chartData, chartData,
maxResponseTime, maxResponseTime,
formatBytes, formatBytes,
+114 -48
View File
@@ -107,7 +107,7 @@ func init() {
Timeout: DEFAULT_TIMEOUT, Timeout: DEFAULT_TIMEOUT,
Transport: &http.Transport{ Transport: &http.Transport{
MaxIdleConns: 100, MaxIdleConns: 100,
MaxIdleConnsPerHost: 10, MaxIdleConnsPerHost: 20,
IdleConnTimeout: 90 * time.Second, IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second, TLSHandshakeTimeout: 10 * time.Second,
}, },
@@ -344,7 +344,7 @@ func synthesis(text string, speed float64) (*SynthesisResult, error) {
var audioData []byte var audioData []byte
scanner := bufio.NewScanner(resp.Body) scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(make([]byte, 1024*1024), 1024*1024) scanner.Buffer(make([]byte, 1024*1024), 8*1024*1024)
for scanner.Scan() { for scanner.Scan() {
line := scanner.Bytes() line := scanner.Bytes()
@@ -472,21 +472,6 @@ func openaiTTSHandler(w http.ResponseWriter, r *http.Request) {
return 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 { select {
case concurrencySem <- struct{}{}: case concurrencySem <- struct{}{}:
defer func() { <-concurrencySem }() defer func() { <-concurrencySem }()
@@ -504,13 +489,28 @@ func openaiTTSHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, MAX_REQUEST_BODY_SIZE)) 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
}
r.Body = http.MaxBytesReader(w, r.Body, MAX_REQUEST_BODY_SIZE)
body, err := io.ReadAll(r.Body)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "request body too large") { if strings.Contains(err.Error(), "request body too large") {
http.Error(w, "Request body too large", http.StatusRequestEntityTooLarge) return
} else {
http.Error(w, "Failed to read request body", http.StatusBadRequest)
} }
http.Error(w, "Failed to read request body", http.StatusBadRequest)
return return
} }
@@ -678,47 +678,112 @@ func (rec *statusRecorder) WriteHeader(code int) {
rec.ResponseWriter.WriteHeader(code) rec.ResponseWriter.WriteHeader(code)
} }
var allowedOrigins []string 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() { func initCORSConfig() {
origins := os.Getenv("ALLOWED_ORIGINS") origins := os.Getenv("ALLOWED_ORIGINS")
if origins != "" { if origins == "" {
allowedOrigins = strings.Split(origins, ",")
for i, origin := range allowedOrigins {
allowedOrigins[i] = strings.TrimSpace(origin)
}
log.Printf("已配置 %d 个允许的跨域来源", len(allowedOrigins))
} else {
log.Println("警告: ALLOWED_ORIGINS 环境变量未设置") log.Println("警告: ALLOWED_ORIGINS 环境变量未设置")
log.Println("将使用反射模式允许所有请求来源,生产环境建议配置白名单") 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 checkStaticFiles() {
if _, err := os.Stat("health.html"); os.IsNotExist(err) {
log.Println("警告: health.html 不存在,/dashboard 路由将返回 404")
}
}
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 corsMiddleware(next http.Handler) http.Handler { func corsMiddleware(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) {
origin := r.Header.Get("Origin") origin := r.Header.Get("Origin")
allowOrigin := ""
if len(allowedOrigins) == 0 { if origin != "" {
allowOrigin = origin allowOrigin, matched := matchOrigin(origin)
} else { if matched {
for _, allowed := range allowedOrigins { w.Header().Set("Access-Control-Allow-Origin", allowOrigin)
if allowed == origin { w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
allowOrigin = origin w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
break 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")
} }
} }
} }
if allowOrigin != "" {
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-Allow-Credentials", "true")
}
if r.Method == http.MethodOptions { if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK) if origin != "" {
if _, matched := matchOrigin(origin); !matched {
w.WriteHeader(http.StatusNoContent)
return
}
}
w.WriteHeader(http.StatusNoContent)
return return
} }
@@ -734,6 +799,7 @@ func main() {
initAPIKeys() initAPIKeys()
initCORSConfig() initCORSConfig()
checkStaticFiles()
ttsConfigErr = initTTSConfig() ttsConfigErr = initTTSConfig()
if ttsConfigErr != nil { if ttsConfigErr != nil {
@@ -782,8 +848,8 @@ func main() {
server := &http.Server{ server := &http.Server{
Addr: ":" + port, Addr: ":" + port,
Handler: router, Handler: router,
ReadTimeout: 15 * time.Second, ReadTimeout: 30 * time.Second,
WriteTimeout: 15 * time.Second, WriteTimeout: 120 * time.Second,
IdleTimeout: 60 * time.Second, IdleTimeout: 60 * time.Second,
} }