diff --git a/.github/workflows/build_image.yml b/.github/workflows/build_image.yml index b13a8d27..1d1bdb17 100644 --- a/.github/workflows/build_image.yml +++ b/.github/workflows/build_image.yml @@ -25,6 +25,13 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 + # set up Go + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.26" + check-latest: true + # download Go modules - name: Download Go modules run: go mod download diff --git a/README.md b/README.md index 3f948b3f..e36a5e25 100644 --- a/README.md +++ b/README.md @@ -131,7 +131,8 @@ pnpm dev - **前端界面**: http://localhost:3000 - **API 文档**: http://localhost:8000/swagger/index.html -- **健康检查**: http://localhost:8000/api/health +- **健康检查**: http://localhost:8000/api/v1/health +- **就绪检查**: http://localhost:8000/api/v1/ready ## ⚙️ 配置说明 @@ -140,6 +141,8 @@ pnpm dev | 配置项 | 说明 | 示例 | |--------|------|------| | `app.addr` | 后端服务监听地址 | `:8000` | +| `worker.port` | Worker 探针端口 | `8001` | +| `schedule.port` | Beat/Scheduler 探针端口 | `8002` | | `oauth2.client_id` | OAuth2 客户端 ID | `your_client_id` | | `database.host` | MySQL 数据库地址 | `127.0.0.1` | | `redis.host` | Redis 服务器地址 | `127.0.0.1` | @@ -196,7 +199,8 @@ http://localhost:8000/swagger/index.html ### 主要 API 端点 -- `GET /api/health` - 健康检查 +- `GET /api/v1/health` - 健康检查 +- `GET /api/v1/ready` - 就绪检查 - `GET /api/oauth2/login` - OAuth2 登录 - `GET /api/projects` - 获取项目列表 - `POST /api/projects` - 创建新项目 diff --git a/config.example.yaml b/config.example.yaml index 82ce9ea6..118b90f9 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -96,6 +96,7 @@ log: # Schedule schedule: + port: 8002 # beat/scheduler probe port user_badge_score_dispatch_interval_seconds: 1 update_user_badges_scores_task_cron: "0 2 * * *" update_all_badges_task_cron: "0 1 * * *" @@ -103,6 +104,7 @@ schedule: # Worker worker: + port: 8001 # worker probe port concurrency: 50 # linuxDo diff --git a/docs/docs.go b/docs/docs.go index 5f08baf6..7a46e285 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -677,6 +677,24 @@ const docTemplate = `{ } } }, + "/api/v1/ready": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "health" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/health.ReadyResponse" + } + } + } + } + }, "/api/v1/tags": { "get": { "consumes": [ @@ -817,6 +835,15 @@ const docTemplate = `{ } } }, + "health.ReadyResponse": { + "type": "object", + "properties": { + "data": {}, + "error_msg": { + "type": "string" + } + } + }, "oauth.BasicUserInfo": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index aad87d54..04f9deae 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -668,6 +668,24 @@ } } }, + "/api/v1/ready": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "health" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/health.ReadyResponse" + } + } + } + } + }, "/api/v1/tags": { "get": { "consumes": [ @@ -808,6 +826,15 @@ } } }, + "health.ReadyResponse": { + "type": "object", + "properties": { + "data": {}, + "error_msg": { + "type": "string" + } + } + }, "oauth.BasicUserInfo": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 2f7bc96d..8461a3c5 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -74,6 +74,12 @@ definitions: error_msg: type: string type: object + health.ReadyResponse: + properties: + data: {} + error_msg: + type: string + type: object oauth.BasicUserInfo: properties: avatar_url: @@ -892,6 +898,17 @@ paths: $ref: '#/definitions/project.ListReceiveHistoryChartResponse' tags: - project + /api/v1/ready: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/health.ReadyResponse' + tags: + - health /api/v1/tags: get: consumes: diff --git a/go.mod b/go.mod index 78177bb1..e3f1d69a 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/linux-do/cdk -go 1.24 +go 1.26 require ( github.com/ClickHouse/clickhouse-go/v2 v2.23.2 diff --git a/internal/apps/health/routers.go b/internal/apps/health/routers.go index 60dfe03a..327165e8 100644 --- a/internal/apps/health/routers.go +++ b/internal/apps/health/routers.go @@ -25,8 +25,12 @@ package health import ( - "github.com/gin-gonic/gin" "net/http" + + "github.com/gin-gonic/gin" + "github.com/linux-do/cdk/internal/config" + "github.com/linux-do/cdk/internal/db" + "github.com/linux-do/cdk/internal/logger" ) type HealthResponse struct { @@ -42,3 +46,50 @@ type HealthResponse struct { func Health(c *gin.Context) { c.JSON(http.StatusOK, HealthResponse{}) } + +type ReadyResponse struct { + ErrorMsg string `json:"error_msg"` + Data interface{} `json:"data"` +} + +// Ready godoc +// @Tags health +// @Produce json +// @Success 200 {object} ReadyResponse +// @Router /api/v1/ready [get] +func Ready(c *gin.Context) { + // init + ctx := c.Request.Context() + // check mysql + if config.Config.Database.Enabled { + sqlDB, err := db.DB(ctx).DB() + if err != nil { + logger.ErrorF(c.Request.Context(), "[Ready] mysql check failed: %s", err.Error()) + c.AbortWithStatusJSON(http.StatusInternalServerError, ReadyResponse{}) + return + } + if err := sqlDB.PingContext(ctx); err != nil { + logger.ErrorF(c.Request.Context(), "[Ready] mysql check failed: %s", err.Error()) + c.AbortWithStatusJSON(http.StatusInternalServerError, ReadyResponse{}) + return + } + } + // check redis + if config.Config.Redis.Enabled { + if err := db.Redis.Ping(ctx).Err(); err != nil { + logger.ErrorF(c.Request.Context(), "[Ready] redis check failed: %s", err.Error()) + c.AbortWithStatusJSON(http.StatusInternalServerError, ReadyResponse{}) + return + } + } + // check clickhouse + if config.Config.ClickHouse.Enabled { + if err := db.ChConn.Ping(ctx); err != nil { + logger.ErrorF(c.Request.Context(), "[Ready] clickhouse check failed: %s", err.Error()) + c.AbortWithStatusJSON(http.StatusInternalServerError, ReadyResponse{}) + return + } + } + // response + c.JSON(http.StatusOK, ReadyResponse{}) +} diff --git a/internal/cmd/scheduler.go b/internal/cmd/scheduler.go index 71764326..3bed8cb4 100644 --- a/internal/cmd/scheduler.go +++ b/internal/cmd/scheduler.go @@ -25,9 +25,12 @@ package cmd import ( - "github.com/linux-do/cdk/internal/task/schedule" "log" + "github.com/linux-do/cdk/internal/config" + "github.com/linux-do/cdk/internal/probe" + "github.com/linux-do/cdk/internal/task/schedule" + "github.com/spf13/cobra" ) @@ -36,8 +39,11 @@ var schedulerCmd = &cobra.Command{ Short: "CDK Scheduler", Run: func(cmd *cobra.Command, args []string) { log.Println("[Scheduler] 启动定时任务调度服务") + if err := probe.Start(config.Config.Schedule.Port); err != nil { + log.Fatalf("[Scheduler] 启动探针服务失败: %v", err) + } if err := schedule.StartScheduler(); err != nil { - log.Fatalf("[调度器] 启动失败: %v", err) + log.Fatalf("[Scheduler] 启动失败: %v", err) } }, } diff --git a/internal/cmd/worker.go b/internal/cmd/worker.go index c1cd456a..cf6a3044 100644 --- a/internal/cmd/worker.go +++ b/internal/cmd/worker.go @@ -25,9 +25,12 @@ package cmd import ( - "github.com/linux-do/cdk/internal/task/worker" "log" + "github.com/linux-do/cdk/internal/config" + "github.com/linux-do/cdk/internal/probe" + "github.com/linux-do/cdk/internal/task/worker" + "github.com/spf13/cobra" ) @@ -36,8 +39,11 @@ var workerCmd = &cobra.Command{ Short: "CDK Worker", Run: func(cmd *cobra.Command, args []string) { log.Println("[Worker] 启动任务处理服务") + if err := probe.Start(config.Config.Worker.Port); err != nil { + log.Fatalf("[Worker] 启动探针服务失败: %v", err) + } if err := worker.StartWorker(); err != nil { - log.Fatalf("[工作器] 启动失败: %v", err) + log.Fatalf("[Worker] 启动失败: %v", err) } }, } diff --git a/internal/config/model.go b/internal/config/model.go index a93c4326..64721175 100644 --- a/internal/config/model.go +++ b/internal/config/model.go @@ -134,6 +134,7 @@ type logConfig struct { // scheduleConfig 定时任务配置 type scheduleConfig struct { + Port int `mapstructure:"port"` UserBadgeScoreDispatchIntervalSeconds int `mapstructure:"user_badge_score_dispatch_interval_seconds"` UpdateUserBadgeScoresTaskCron string `mapstructure:"update_user_badges_scores_task_cron"` UpdateAllBadgesTaskCron string `mapstructure:"update_all_badges_task_cron"` @@ -142,6 +143,7 @@ type scheduleConfig struct { // workerConfig 工作配置 type workerConfig struct { + Port int `mapstructure:"port"` Concurrency int `mapstructure:"concurrency"` } diff --git a/internal/probe/server.go b/internal/probe/server.go new file mode 100644 index 00000000..4427bc1f --- /dev/null +++ b/internal/probe/server.go @@ -0,0 +1,76 @@ +/* + * MIT License + * + * Copyright (c) 2025 linux.do + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package probe + +import ( + "errors" + "fmt" + "log" + "net" + "net/http" + "strconv" + "time" + + "github.com/gin-gonic/gin" + "github.com/linux-do/cdk/internal/apps/health" + "github.com/linux-do/cdk/internal/config" +) + +func Start(port int) error { + if port <= 0 { + return fmt.Errorf("invalid probe port: %d", port) + } + if config.Config.App.Env == "production" { + gin.SetMode(gin.ReleaseMode) + } + + addr := net.JoinHostPort("", strconv.Itoa(port)) + listener, err := net.Listen("tcp", addr) + if err != nil { + return err + } + + engine := gin.New() + engine.Use(gin.Recovery()) + apiV1Router := engine.Group(config.Config.App.APIPrefix + "/v1") + { + apiV1Router.GET("/health", health.Health) + apiV1Router.GET("/ready", health.Ready) + } + + server := &http.Server{ + Handler: engine, + ReadHeaderTimeout: 5 * time.Second, + } + + go func() { + if errServe := server.Serve(listener); errServe != nil && !errors.Is(errServe, http.ErrServerClosed) { + log.Printf("[Probe] serve failed: %v\n", errServe) + } + }() + + log.Printf("[Probe] listening on :%d\n", port) + return nil +} diff --git a/internal/router/middlewares.go b/internal/router/middlewares.go index 1bad4eed..380f812e 100644 --- a/internal/router/middlewares.go +++ b/internal/router/middlewares.go @@ -60,9 +60,7 @@ func loggerMiddleware() gin.HandlerFunc { latency := end.Sub(start) // 打印日志 - // 排除健康检查接口 - healthPath := config.Config.App.APIPrefix + "/v1/health" - if c.Request.URL.Path != healthPath { + if !isProbePath(c.Request.URL.Path) { logger.InfoF( ctx, "[LoggerMiddleware] %s %s\nStartTime: %s\nEndTime: %s\nLatency: %d\nClientIP: %s\nResponse: %d %d", @@ -84,3 +82,8 @@ func loggerMiddleware() gin.HandlerFunc { } } } + +func isProbePath(path string) bool { + return path == config.Config.App.APIPrefix+"/v1/health" || + path == config.Config.App.APIPrefix+"/v1/ready" +} diff --git a/internal/router/router.go b/internal/router/router.go index 51f100e8..c2797488 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -98,6 +98,7 @@ func Serve() { { // Health apiV1Router.GET("/health", health.Health) + apiV1Router.GET("/ready", health.Ready) // OAuth apiV1Router.GET("/oauth/login", oauth.GetLoginURL)