refactor(health dashboard & tts server): 优化健康页面展示与服务配置
- 重构健康页面配置状态展示,替换原有资源ID和发音人字段 - 简化错误记录展示逻辑,改为统计展示错误数量并提示查看日志 - 调整HTTP客户端连接池配置与请求体缓冲区大小 - 重构速率限制逻辑位置,优化请求处理流程 - 重写CORS配置,支持环境变量白名单和通配符模式 - 增加静态文件检查与更严格的跨域校验 - 调整服务器超时配置,适配更长时间的请求处理
This commit is contained in:
+14
-16
@@ -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,
|
||||||
|
|||||||
+112
-46
@@ -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 {
|
|
||||||
if allowed == origin {
|
|
||||||
allowOrigin = origin
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if allowOrigin != "" {
|
|
||||||
w.Header().Set("Access-Control-Allow-Origin", 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-Methods", "GET, POST, OPTIONS")
|
||||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
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")
|
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 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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user