feat(P3): Docker健康检查 + Worker优雅重启 + 前端错误监控

This commit is contained in:
Sisyphus
2026-04-25 16:07:58 +08:00
parent 025be6dd78
commit 3613cfe5a3
5 changed files with 468 additions and 404 deletions

View File

@@ -17,6 +17,7 @@ import (
"time" "time"
amqp "github.com/rabbitmq/amqp091-go" amqp "github.com/rabbitmq/amqp091-go"
"gorm.io/gorm"
) )
const ( const (
@@ -37,6 +38,21 @@ const (
popularityBindingKey = "video.popularity.*" popularityBindingKey = "video.popularity.*"
) )
func connectWithRetry(name string, maxRetries int, fn func() error) {
for i := 0; i < maxRetries; i++ {
if err := fn(); err == nil {
return
}
wait := time.Duration(1<<i) * time.Second
if wait > 30*time.Second {
wait = 30 * time.Second
}
log.Printf("%s 不可用,%v 后重试 (%d/%d)...", name, wait, i+1, maxRetries)
time.Sleep(wait)
}
log.Fatalf("%s: 超过最大重试次数", name)
}
func main() { func main() {
// 加载配置 // 加载配置
configPath := os.Getenv("CONFIG_PATH") configPath := os.Getenv("CONFIG_PATH")
@@ -53,11 +69,13 @@ func main() {
} else { } else {
log.Printf("Config loaded from file: %s", configPath) log.Printf("Config loaded from file: %s", configPath)
} }
// 连接数据库 // 连接数据库(带重试)
sqlDB, err := db.NewDB(cfg.Database) var sqlDB *gorm.DB
if err != nil { connectWithRetry("MySQL", 10, func() error {
log.Fatalf("Failed to connect database: %v", err) var err error
} sqlDB, err = db.NewDB(cfg.Database)
return err
})
defer db.CloseDB(sqlDB) defer db.CloseDB(sqlDB)
// 连接 Redis用于流行度更新 // 连接 Redis用于流行度更新
@@ -77,12 +95,14 @@ func main() {
log.Printf("Redis connected (popularity worker enabled)") log.Printf("Redis connected (popularity worker enabled)")
} }
} }
// 连接 RabbitMQ // 连接 RabbitMQ(带重试)
url := "amqp://" + cfg.RabbitMQ.Username + ":" + cfg.RabbitMQ.Password + "@" + cfg.RabbitMQ.Host + ":" + strconv.Itoa(cfg.RabbitMQ.Port) + "/" url := "amqp://" + cfg.RabbitMQ.Username + ":" + cfg.RabbitMQ.Password + "@" + cfg.RabbitMQ.Host + ":" + strconv.Itoa(cfg.RabbitMQ.Port) + "/"
conn, err := amqp.Dial(url) var conn *amqp.Connection
if err != nil { connectWithRetry("RabbitMQ", 10, func() error {
log.Fatalf("Failed to connect rabbitmq: %v", err) var err error
} conn, err = amqp.Dial(url)
return err
})
defer conn.Close() defer conn.Close()
// 创建 RabbitMQ 通道 // 创建 RabbitMQ 通道
ch, err := conn.Channel() ch, err := conn.Channel()

View File

@@ -71,6 +71,11 @@ services:
condition: service_healthy condition: service_healthy
rabbitmq: rabbitmq:
condition: service_healthy condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -qO- --post-data='{}' --header='Content-Type: application/json' http://localhost:8080/account/findByID || exit 1"]
interval: 10s
timeout: 5s
retries: 3
worker: worker:
build: build:
@@ -87,6 +92,11 @@ services:
condition: service_healthy condition: service_healthy
rabbitmq: rabbitmq:
condition: service_healthy condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "pgrep worker || exit 1"]
interval: 15s
timeout: 5s
retries: 3
frontend: frontend:
build: build:
@@ -97,6 +107,11 @@ services:
- "5173:80" - "5173:80"
depends_on: depends_on:
- backend - backend
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:80/ || exit 1"]
interval: 10s
timeout: 5s
retries: 3
volumes: volumes:
mysql_data: mysql_data:

View File

@@ -1,4 +1,5 @@
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { reportError } from '../utils/error-reporter'
export class ApiError extends Error { export class ApiError extends Error {
status: number status: number
@@ -51,7 +52,9 @@ export async function postJson<T>(path: string, body: unknown, options?: { authR
data && typeof data === 'object' && (data as ApiErrorBody).error data && typeof data === 'object' && (data as ApiErrorBody).error
? String((data as ApiErrorBody).error) ? String((data as ApiErrorBody).error)
: `请求失败 (${res.status})` : `请求失败 (${res.status})`
throw new ApiError(msg, res.status, data) const apiErr = new ApiError(msg, res.status, data)
reportError(apiErr, { path, status: res.status })
throw apiErr
} }
return data as T return data as T
@@ -92,7 +95,9 @@ export async function postForm<T>(path: string, body: FormData, options?: { auth
data && typeof data === 'object' && (data as ApiErrorBody).error data && typeof data === 'object' && (data as ApiErrorBody).error
? String((data as ApiErrorBody).error) ? String((data as ApiErrorBody).error)
: `请求失败 (${res.status})` : `请求失败 (${res.status})`
throw new ApiError(msg, res.status, data) const apiErr = new ApiError(msg, res.status, data)
reportError(apiErr, { path, status: res.status })
throw apiErr
} }
return data as T return data as T

View File

@@ -3,8 +3,14 @@ import { createPinia } from 'pinia'
import './style.css' import './style.css'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
import { reportError } from './utils/error-reporter'
const app = createApp(App) const app = createApp(App)
app.use(createPinia()) app.use(createPinia())
app.use(router) app.use(router)
app.config.errorHandler = (err, _instance, info) => {
reportError(err instanceof Error ? err : new Error(String(err)), { info })
}
app.mount('#app') app.mount('#app')

View File

@@ -0,0 +1,18 @@
export function reportError(error: Error, context?: Record<string, unknown>) {
if (import.meta.env.DEV) {
console.error('[ErrorReporter]', error.message, context)
return
}
fetch('/api/error-report', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: error.message,
stack: error.stack,
context,
timestamp: new Date().toISOString(),
}),
}).catch(() => {
/* 静默失败,避免错误上报自身导致循环 */
})
}